Accueil Nos publications Blog Modéliser l’absence de valeur en TypeScript – Pattern Null-object

Modéliser l’absence de valeur en TypeScript – Pattern Null-object

header-software-engineering

Dans l’article précédent, nous avons vu que l’absence de valeur est classiquement représentée par la valeur “null”, ce qui fragilise le code. Pour ne rien arranger, JavaScript dispose de 2 valeurs “null”, undefined ou null selon le contexte (respectivement implicite/transitoire vs intentionnel/permanent), sauf lorsqu’elles sont confondues, ce qui est fréquent.

TypeScript améliore un peu le tableau avec son mode strictNullCheck. L’utilisation des valeurs “null” est alors explicite en rendant les types “nullables”. Mais cela n’enlève pas le travail supplémentaire côté code appelant pour gérer le cas du “null”.

Pour s’en décharger, nous pouvons chercher des valeurs “vides” plutôt “null”. Cela rend le code plus simple et plus sûr :

  • Le code appelant n’a pas besoin de gérer le cas “null”, la valeur “vide” se traite comme n’importe quelle autre valeur valide.
  • Dans une class, les champs initialisés avec une valeur “vide” ne sont plus transitoirement undefined (notés champ?: type;) et leur définition est plus succincte, s’en avoir à spécifier le type (champ = valeur_vide;), type qu’il faut parfois importer (import { type } from '...';).
  • De même, cela permet d’éviter l’opérateur “bang” !d’assertion de non-nullité, dangereux car désactivant localement le <code class="language-plaintext">strictNullCheck.
<p>Les exemples de valeurs “vides” que nous avons vus concernaient des types natifs : <code class="language-plaintext">false pour un boolean, la chaîne vide '' pour une string, 0 pour un number, le tableau vide []

Comment faire lorsqu’il s’agit d’objets ? C’est l’objet du pattern Null-object utilisé traditionnellement en OOP. Nous verrons ses bénéfices, ses contraintes, ses limitations ainsi qu’une version modifiée plus adaptée à une approche fonctionnelle.

🏷 Sommaire

Description du pattern Null-object

Le nom du pattern vient du fait qu’il permet d’éviter l’emploi des “nulls” pour signifier l’absence de valeur. Son objectif est de construire un objet “vide” de manière à ce que cet état particulier soit transparent pour le code appelant. De cette manière, le code appelant n’a besoin ni de se protéger du “null”, ni de savoir qu’il manipule un objet “vide”.

Tout repose sur le fait que l’objet “vide” respecte le contrat du type attendu. Il doit parfaitement se substituer à un objet normal, “non-vide”, c’est-à-dire respecter le LSP des principes SOLID. C’est la contrainte principale de ce pattern, comme nous allons le constater dans nos tentatives de modélisation de null-objects.

☝️ Note

Le pattern Null-object ne fait pas partie des Design Patterns du Gang of Four (1994) mais peut être considéré comme un cas particulier des State pattern et Strategy pattern. De plus, il a été mentionné un peu après dans deux “bibles” du programmeur : le Refactoring de Martin Fowler (1999, 2018) avec son Special Case et Agile Software Development: Principles, Patterns and Practices d’Oncle Bob (2002) dans le chapitre qui lui est dédié.

Implémenter un Null-object

Toute implémentation d’objet “vide” est ad hoc c’est-à-dire spécifique au type concerné. Cependant, dans tout type, nous pouvons répartir ses membres en deux familles :

  • Les Commands sont les opérations sans valeur de retour, i.e. les fonctions renvoyant void. Le comportement se manifeste par un effet de bord : modification de l’objet courant, écriture dans un fichier…
  • Les Queries sont les opérations avec une valeur de retour. Lorsque l’on suit à la lettre le principe CQS, une “vraie” Query n’a pas d’effet de bord, mais cela n’a pas d’importance dans notre problématique.

Command

Une Command “vide” est donc une fonction no-op. L’implémentation générique est une fonction vide, sans corps : function noop() {}. Dans certains cas, il faudra aussi déclarer les paramètres attendus pour respecter le contrat, même s’ils ne sont pas utilisés.

Exemple typique de commandes : les fonctions de Log. On peut proposer deux implémentations sous la forme d’objets littéraux, l’une qui encapsule la console, l’autre “vide” :

export interface Logger {
    log(...args: any[]): void;
    error(...args: any[]): void;
}

export namespace Logger {
    export const Console: Logger = {
        log: console.log.bind(console),
        error: console.error.bind(console),
    };

    export const Null: Logger = {
        log: noop,
        error: noop,
    };

    function noop() {}
}

À l’usage, on fait un import { Logger } from '...' puis on choisit l’un ou l’autre des Singletons. Par exemple, dans les tests unitaires d’un élément utilisant un Logger, on peut désactiver les logs en lui fournissant Logger.Null.

Query

Une Query “vide” renvoie une valeur que l’on peut considérer comme “vide” pour notre usage et correspondant au type attendu en retour de la Query. Il n’a donc pas de recette universelle.

☝️ Attention

Ne pas tomber dans la facilité ! Il ne s’agit pas ici de rendre ce type “nullable” pour renvoyer null car cela annule tous les bénéfices du pattern Null-object. Autre mauvaise idée : émettre une exception telle que throw new Error('Aucune valeur à fournir') car cela rompt le LSP et déporte le problème sur le code appelant.

En fait, on peut énoncer une règle générale pour implémenter un Null-object même si je ne suis pas sûr que cela aide beaucoup d’entre vous :

« When can you implement a Null-object? When the return types of your methods are monoids. »
🔗 Null-object as identity, par Mark Seemann (2018)

« Monoïde : c’est quoi ce truc ?! 😵 » me direz-vous. C’est un terme employé dans des langages fonctionnels. Derrière ce nom élaboré se cache un concept intuitif, basé sur trois critères :

  1. Un ensemble de valeurs,
  2. Une opération combinant 2 valeurs en 1,
  3. Une valeur “neutre” : en la combinant, à gauche comme à droite, à toute autre valeur V de l’ensemble, on retrouve V.

Exemples :

Ensemble Opération Élément neutre
Nombres (number) Addition (+) Zéro (0)
Nombres (number) Multiplication (*) Un (1)
Tableaux (Array) Concaténation (concat()) Tableau vide ([])
Fonctions ((t: T) => T) Composition (f(g(x))) Identité (x => x)

Traduisons ces critères à la sémantique du Null-object :

  1. L’ensemble de valeurs est le type de retour de notre Query.
  2. L’opération est liée à l’usage que l’on fait de la valeur de retour.
  3. L’élément “neutre” est la valeur “vide” que doit renvoyer notre Query pour la “nullifier”.

Est-on plus avancé ? Oui, le monoïde est une manière plus formelle de voir les choses. Mais en fait pas vraiment 😆 ! On n’a toujours pas de recette pour trouver la valeur “vide”, si tant est qu’elle existe ! Il n’y a pas d’autre solution que d’étudier au cas par cas, sachant que parfois on pourra implémenter un Null-object, parfois non.

Tester-Doer

Ce pattern consiste en un couple <em>Query/Command{ canDo(): boolean; do(): void; }</em>, la Query permettant de vérifier si la Command peut être exécutée, cette dernière émettant une exception si ce n’est pas le cas. Une telle interface se prête bien au Null-object : il suffit que la Query renvoie tout le temps false pour indiquer de ne pas déclencher la Command.

const nullTesterDoer = {
  canDo: () => false,
  do() {}
};

Cas concrets

Employee

Il s’agit de l’exemple, proposé par Oncle Bob, que j’ai un peu revisité. On cherche à récupérer depuis la base un Employee en utilisant la fonction findEmployee(by: (x: Employee) => boolean) prenant en paramètre un prédicat de recherche. Que faire quand aucun Employee n’est trouvé ?

  1. Émettre une Error ? C’est envisageable quand on cherche un Employee par Id car on s’attend à ce qu’il existe. En revanche ici, on sait que notre fonction générique de recherche peut ne rien trouver, il faut donc indiquer cette absence de valeur d’une manière propre.
  2. Renvoyer null ? Alors le type de retour sera Employee | null et le code appelant devra gérer explicitement ce null, sinon 💥.
  3. Utiliser un type de retour qui indique cette absence de valeur ? Oui, nous étudierons cette piste prometteuse dans le prochain article.
  4. Renvoyer un NullEmployee ? Voyons cela.

Le type Employee a une interface simple qui sert pour la paie, sachant qu’un employé est payé soit le vendredi, soit le dernier jour du mois :

interface Employee {
  isDayOfThePay(date: Date): boolean;
  pay(): void;
}

On retrouve le pattern Tester-Doer, compatible avec un Null-object : il suffit que isDayOfThePay() renvoie tout le temps false pour ne pas déclencher la paie de l’employé.

Folder

Imaginons une fonction getFolder(path: string) qui renvoie un objet Folder avec un nom et un répertoire parent. Le répertoire racine n’a pas de parent. Comment modéliser cette absence de valeur ?

  • On l’indique habituellement par un champ optionnel parent?: Folder.
  • On pourrait aussi proposer un type union Folder | RootFolder avec RootFolder sans champ parent.

Dans les deux cas, cela augmente la complexité cyclomatique du code appelant devant gérer les deux types de répertoires. Tentons un Null-object qui sera utilisé en tant que parent du répertoire racine et pour indiquer un répertoire non trouvé.

interface Folder {
  readonly name: string;
  readonly parent: Folder; // 👈 Obligatoire → Null-object pour le répertoire racine
}

const nullFolder: Folder = {
  name: '',
  parent: ???, // 👈 ❓
};

Quelle valeur choisir pour le parent du nullFolder ?

  • parent: null → non seulement cela ne compile pas (car parent n’est pas “nullable”) mais cela contredit le pattern Null-object !
  • parent: nullFolder → erreur de compilation « Block-scoped variable ‘nullFolder’ used before its declaration »
  • ✔️ get parent() { return nullFolder; }

Désormais, cherchons à calculer la profondeur du répertoire. Rien de bien compliqué avec une fonction récursive :

function depth(folder: Folder): number {
  return 1 + depth(folder.parent);
}

Résultat : une belle erreur stack-overflow 💥 ! On voit les limites du pattern Null-Object, surtout quand on ne fait pas partout de l’OOP.

Justement, si l’on faisait plutôt de l’OOP ? Dans ce cas, depth n’est pas une fonction extérieure au type Folder mais un de ses membres, ici une propriété (plutôt qu’une méthode). Il suffit alors de surcharger cette propriété dans le Null-object, au prix d’un code plus verbeux :

class Folder {
  // ℹ Private nested class
  private static readonly _Null = class extends Folder {
    get depth() {
      return 0;
    }
  }

  static readonly Null: Folder = new Folder._Null('');

  constructor(
    readonly path: string,
    private readonly _parent?: Folder,
  ) { }

  get parent(): Folder {
    return this._parent ?? Folder.Null;
  }

  get depth(): number {
    return 1 + this.parent.depth;
  }
}

// Tests
const root = new Folder('/');
const a = new Folder('/a', root);
const b = new Folder('/a/b', a);

console.log([Folder.Null, root, a, b].map(x => [x.path, x.depth]));
// [LOG]: [["", 0], ["/", 1], ["/a", 2], ["/a/b", 3]] 

Null-object explicite

Faisons un compromis sur le côté “transparent pour le client” et permettons-lui de savoir que l’objet qu’il manipule est “vide”. La façon la plus simple consiste alors à ajouter un champ isEmpty.

Nous disposons d’un exemple en C♯ avec la classe DirectoryInfo prenant un chemin en entrée et renvoyant un objet indiquant par sa propriété Exists si le dossier a été trouvé ou non. Dans le cas d’un répertoire “vide”, les dates CreationTimeUtc, LastAccessTimeUtc et LastWriteTimeUtc ont bien une valeur “vide” mais pas celle à laquelle je m’attendais : j’ai obtenu 01/01/1601 00:00:00 ce qui est l’epoch de NTFS de mon poste Windows plutôt que celle de .NET (01/01/0001 00:00:00 – cf. DateTime.MinValue).

Voyons si un tel objet est sympa à utiliser. Considérons la fonctionnalité consistant à envoyer un mail à un client. Dans une application Angular, on pourrait l’implémenter de cette manière :

export class MailSenderService {
  constructor(
    private readonly clientRepository: ClientRepository,
    private readonly mailTemplateRepository: MailTemplateRepository,
  ) {}

  sendMail(clientId: string, mailTemplateId: string): boolean {
    // Null-object explicit
    const client = this.clientRepository.find(clientId);
    if (!client.exists) {
      return false;
    }

    // Objet nullable
    const mailTemplate = this.mailTemplateRepository.find(mailTemplateId);
    if (mailTemplate == null) {
      return false;
    }

    return this.performSendMail(client, mailTemplate);
  }

  private performSendMail(): boolean {
    // ...
  }
}

Ce service dépend de 2 repositories pour récupérer le client et le template de mail. Leur méthode find ne se comporte pas pareil :

  • Celle de ClientRepository renvoie un objet non nullable mais pouvant être “vide”, l’indiquant avec le champ exists.
  • Celle de MailTemplateRepository renvoie un objet nullable.

En fin de compte, les deux usages sont similaires : le code appelant doit vérifier si l’objet reçu est vide ou null. Ce type de null-object explicite n’a donc pas un grand intérêt. Ce n’est donc pas la bonne approche.

Quid d’inverser le contrôle ? En effet, un objet implémentant le null-object sait qu’il est vide. Il suffit donc de lui déléguer l’action à effectuer lorsqu’il n’est pas vide. Dans notre cas, cela peut prendre la forme d’une méthode whenExists(action: () => void): void. L’action est déclenchée par l’objet non-vide mais pas par l’objet vide. Voyons ce que cela donne à l’usage, en ayant modifié la méthode find de MailTemplateRepository pour y renvoyer un tel null-object :

export class MailSenderService {
  // ...
  sendMail(clientId: string, mailTemplateId: string): boolean {
    let result = false;

    const client = this.clientRepository.find(clientId);
    client.whenExists(() => {
      const mailTemplate = this.mailTemplateRepository.find(mailTemplateId);
      mailTemplate.whenExists(() => {
        result = this.performSendMail(client, mailTemplate);
      });
    });

    return result;
  }
  // ...
}

Bilan : même si la complexité cyclomatique descend de 3 à 1, je trouve que le code est moins facile à lire du fait de l’imbrication des callbacks et de la mise à jour de la variable result au milieu de tout cela. Arf ! Le retour du Callback hell ! 😱

Mais il faut « raison garder ». J’ai fait exprès de prendre ce cas pour montrer les limites du pattern. Dans un cas plus simple (ni template de mail, ni valeur de retour), le pattern est tout à fait adapté et le code est à la fois lisible et de faible complexité cyclomatique :

export class MailSenderService {
  // ...
  sendMail(clientId: string): void {
    const client = this.clientRepository.find(clientId);
    client.whenExists(() => this.performSendMail(client));
  }
  // ...
}

Quand on a plusieurs fois ce dernier type de cas, on peut utiliser les mixins pour en généraliser l’implémentation.

Null-object mixins

Les mixins servent à combiner plusieurs comportements au sein d’un même objet. Ils permettent de contourner l’absence d’héritage multiple.

Il existe deux manières d’implémenter des mixins en TypeScript : soit à base de class et d’héritage, soit directement sur l’objet cible.

Class mixin

Le comportement que l’on va “mixer” correspond à l’interface suivante :

interface INullObject {
  readonly exists: boolean;
  whenExists(action: () => void): void;
}

Pour montrer le fait que l’on peut appliquer plusieurs mixins, on la sépare en deux. On en profite pour proposer le comportement complémentaire :

interface INullObject {
  readonly empty: boolean;
  readonly exists: boolean;
}

interface IReactiveNullObject {
  whenEmpty(action: () => void): void;
  whenExists(action: () => void): void;
}

Un mixin est une fonction qui :

  • Prend une classe en entrée,
  • Renvoie une nouvelle classe étendant celle en entrée avec un nouveau comportement.

Quand on parle de classe ici, il ne s’agit pas du type, au niveau TypeScript. Le mixin opère au niveau JavaScript, au runtime, ce qui oblige à enlever le sucre syntaxique des class et à considérer la fonction constructeur utilisée pour implémenter une instance de la classe, représentée par le type Constructor :

type Constructor<T = {}> = new (...args: any[]) => T;

Pour correspondre aux deux cas de figure, “vide” (empty) ou “non-vide” (existing), nous sommes amenés à créer 4 mixins :

// Null-object mixins: NullObject
interface INullObject {
  readonly empty: boolean;
  readonly exists: boolean;
}

function NullObjectEmpty<TBase extends Constructor>(Base: TBase) {
  return class NullObjectEmpty extends Base implements INullObject {
    readonly empty = true;
    readonly exists = false;
  };
}

function NullObjectExisting<TBase extends Constructor>(Base: TBase) {
  return class NullObjectExisting extends Base implements INullObject {
    readonly empty = false;
    readonly exists = true;
  };
}

// Null-object mixins: ReactiveNullObject
interface IReactiveNullObject {
  whenEmpty(action: () => void): void;
  whenExists(action: () => void): void;
}

function ReactiveNullObjectEmpty<TBase extends Constructor>(Base: TBase) {
  return class ReactiveNullObjectEmpty extends Base implements IReactiveNullObject {
    whenEmpty(action) { action(); }
    whenExists() { }
  };
}

function ReactiveNullObjectExisting<TBase extends Constructor>(Base: TBase) {
  return class ReactiveNullObjectExisting extends Base implements IReactiveNullObject {
    whenEmpty() { }
    whenExists(action) { action(); }
  };
}

En pratique, on applique un seul mixin, le normal ou le réactif, selon la manière souhaitée de gérer le null-object. Pour notre exemple, nous allons combiner les mixins normaux et réactifs correspondant à leur cas respectif, empty ou existing :

interface IClient {
  readonly id: string;
  readonly name: string;
}

const Client =
  ReactiveNullObjectExisting(
    NullObjectExisting(
      class Client implements IClient {
        constructor(
          readonly id: string,
          readonly name: string,
        ) { }
      }));

const EmptyClient =
  ReactiveNullObjectEmpty(
    NullObjectEmpty(
      class EmptyClient implements IClient {
        readonly id = '';
        readonly name = '';
      }));

Procédons à un test rapide pour évaluer comment cela se comporte :

const c1 = new Client('C123', 'Romain');
const c2 = new EmptyClient();

[c1, c2].forEach((client, index) => {
  console.log(`Client n°${index + 1} » is empty: ${client.empty}`);
  client.whenExists(() => console.log(`» id: ${client.id}, name: ${client.name}`));
});

// Console output
// > Client n°1 » is empty: false
// > » id: C123, name: Romain
// > Client n°2 » is empty: true

L’IntelliSense se comporte bien lors de l’instanciation (new Client()) où les arguments sont bien indiqués : Client(id: string, name: string)). Cependant, le résultat n’est pas terrible pour les variables c1 et c2 :

const c1: ReactiveNullObjectExisting<{
    new (...args: any[]): NullObjectExisting<typeof Client>.NullObjectExisting;
    prototype: NullObjectExisting<any>.NullObjectExisting;
} & typeof Client>.ReactiveNullObjectExisting & NullObjectExisting<...>.NullObjectExisting & Client

const c2: ReactiveNullObjectEmpty<{
    new (...args: any[]): NullObjectEmpty<typeof EmptyClient>.NullObjectEmpty;
    prototype: NullObjectEmpty<any>.NullObjectEmpty;
} & typeof EmptyClient>.ReactiveNullObjectEmpty & NullObjectEmpty<...>.NullObjectEmpty & EmptyClient

💡 Playground TypeScript associé

On peut améliorer cela avec une interface Client désignant le type final et un namespace du même nom exposant la fonction Factorycreate() et le Singletonempty, l’ensemble permettant de masquer les détails d’implémentation :

interface ClientInfo {
  readonly id: string;
  readonly name: string;
}

interface Client extends ClientInfo, INullObject, IReactiveNullObject {}

namespace Client {
  class ClientImpl implements ClientInfo {
    constructor(
      readonly id: string,
      readonly name: string,
    ) { }
  }

  const ClientCtor = ReactiveNullObjectExisting(NullObjectExisting(ClientImpl));
  const EmptyClientCtor = ReactiveNullObjectEmpty(NullObjectEmpty(ClientImpl));

  export const create = ({ id, name }: ClientInfo): Client => new ClientCtor(id, name);
  export const empty: Client = new EmptyClientCtor('(empty)', '');
}

// Usage
const c1 = Client.create({ id: 'C123', name: 'Romain' });
const c2 = Client.empty;

💡 Playground associé

Object mixin

Ici, l’ajout du comportement se réalise sur l’objet lui-même, en lui ajoutant directement les champs et méthodes du mixin. On peut donc se baser sur des objets templates et utiliser les fonctionnalités natives de mix d’objets, Object.assign() ou l’opérateur spread… :

// Null-object mixins: NullObject
interface INullObject {
  readonly empty: boolean;
  readonly exists: boolean;
}

const nullObjectEmpty: INullObject    = Object.freeze({ empty: true, exists: false });
const nullObjectExisting: INullObject = Object.freeze({ empty: false, exists: true });

// Null-object mixins: ReactiveNullObject
interface IReactiveNullObject {
  whenEmpty(action: () => void): void;
  whenExists(action: () => void): void;
}

const reactiveNullObjectEmpty: IReactiveNullObject = {
  whenEmpty(action) { action(); },
  whenExists() { }
};

const reactiveNullObjectExisting: IReactiveNullObject = {
  whenEmpty() { },
  whenExists(action) { action(); }
};

// Client
interface ClientInfo {
  readonly id: string;
  readonly name: string;
}

interface Client extends ClientInfo, INullObject, IReactiveNullObject {}

namespace Client {
  class ClientImpl implements ClientInfo {
    constructor(
      readonly id: string,
      readonly name: string,
    ) { }
  }

  export const create = ({ id, name }: ClientInfo): Client =>
    Object.assign(
      new ClientImpl(id, name),
      reactiveNullObjectExisting,
      nullObjectExisting);

  export const empty: Client = Object.freeze({
    id: '(empty)',
    name: '',
    ...reactiveNullObjectEmpty,
    ...nullObjectEmpty,
  });
}

// Usage
const c1 = Client.create({ id: 'C123', name: 'Romain' });
const c2 = Client.empty;

[c1, c2].forEach((client, index) => {
  console.log(`Client n°${index + 1} » is empty: ${client.empty}`);
  client.whenExists(() => console.log(`» id: ${client.id}, name: ${client.name}`));
});

💡 Playground associé

Remarques :

  • Le code est un peu plus court et plus simple à comprendre avec les object-mixins plutôt que les class-mixins.
  • L’inconvénient peut se situer au niveau des performances, mais cela reste à confirmer sur le terrain.
  • L’emploi de Object.freeze() est une sécurité pour utiliser le code en JavaScript. Cela n’est pas strictement nécessaire dans une codebase entièrement en TypeScript.
  • Le changement d’implémentation dans le namespace Client a été totalement transparent à l’usage. On voit les bénéfices de cette abstraction et son encapsulation.

Conclusion

Le pattern Null-object est intéressant pour modéliser l’absence de valeur d’un objet dès lors que la transparence pour le code appelant est respectée. L’enjeu est en effet de sécuriser et simplifier le code appelant par l’absence respective de null et de guard clause.

L’implémentation d’un Null-object est toujours ad hoc mais se réalise de multiples manières – objet littéral, instance particulière de la classe même ou d’une classe dérivée et dédiée – tant que l’on respecte le contrat du type à “nullifier”.

Cette implémentation n’est pas toujours possible. Certes les membres qui sont des Commands et des couples Tester/Doer s’y prêtent bien mais pour les Queries, il n’y a pas de recette miracle. C’est pourquoi le pattern Null-object est adapté à l’orienté-objet où il est fait bon usage de l’encapsulation, de la loi de Déméter(a.k.a Tell don’t ask) et du principe ISP(i.e. des interfaces rôles).

On peut y remédier en assouplissant la contrainte de transparence pour le code appelant. On obtient une variante du pattern où le code appelant sait explicitement qu’il a affaire à un objet “nullable”. Deux approches ont été envisagées :

Ces deux variantes du pattern Null-object peuvent être industrialisées au moyen de mixins soit de classe, soit d’objet, le choix se faisant en évaluant le rapport « simplicité d’implémentation / performance ».

Pour conclure, le pattern Null-object peut s’avérer possible, pertinent voire élégant dans certains cas mais nécessite quand même une certaine quantité de travail pour l’implémenter de façon à coller au contexte, ce qui représente toujours une possibilité qu’un bug se glisse dans l’affaire et nous fasse perdre tout bénéfice à éviter le null.

On ne peut pas conclure un si long article sur un demi-échec ! N’existe-t-il pas une solution simple et universelle pour modéliser l’absence de valeur ? La réponse n’est pas à chercher dans l’OOP mais dans l’approche fonctionnelle, avec un type appelé Maybe, Option ou encore Optional, objet de notre prochain article.

🎁 Les plus impatients peuvent d’ores et déjà visionner mon webinar sur ce sujet.

© SOAT
Toute reproduction interdite sans autorisation de la société SOAT