Intermédiaire

Modéliser l’absence de valeur en TypeScript

Temps de lecture : 13 minutes

L’absence de valeur est souvent modélisée par la valeur “null” dans les langages de programmation, ce qui peut avoir des conséquences fâcheuses dans une base de code comme nous allons l’expliquer. TypeScript est un langage jeune et dont la raison d’être est d’avoir une base de code robuste plus aisément qu’en JavaScript. Il propose ainsi des possibilités qui en font un langage très intéressant, en particulier pour adresser les problèmes liés aux nulls.

Nous verrons des réponses variées correspondant à autant d’articles :

  • Exposition du problème en général et en TypeScript en mode strict null check,
  • Présentation des classes et de leur implémentation en mode strict null check,
  • Design pattern “Null Object” amélioré grâce à la continuation et aux mixins,
  • Différentes implémentations du type Option / Maybe et du pattern matching sous-jacent,
  • Comparaison des différentes solutions

Généralités sur null

La valeur null, pratique en surface pour permettre d’avoir une valeur par défaut pour une référence à un objet, pose de sérieux problèmes à la réalisation de logiciels fiables. Accéder à un membre d’un objet qui s’avère null se traduit à l’exécution par une erreur / exception : NullPointerException en Java, NullReferenceException en C♯, Uncaught TypeError: Cannot read property xxx of null en JavaScript… au point d’amener l’inventeur du null à considérer qu’il s’agit de son “erreur à un milliard de dollars”.

Les langages ne sont pas tous égaux en matière de null. Certains langages comme TCL n’ont pas d’équivalent, d’autres l’ont conservé (pour différentes raisons) mais en le rendant non idiomatique, à l’exemple du F♯.

Dans les autres langages, comment se prémunir de ces “maudites” erreurs au runtime ? La technique de base pour s’en prémunir est de l’ordre de la programmation défensive telle que le morceau de code ci-dessous. C’est plus long à écrire, augmente la complexité cyclomatique et nuit beaucoup à la lecture du code :

function fn(a?: any) {
  if (a != null) {
    // Opérations sûres avec a
  }
}

Curiosité des nullables en C♯

En C♯, les types se décomposent en deux familles : les types référence (les classes) et les types valeur (les structs). De manière grossière, la différence se situe dans la gestion de la mémoire, soit par référence partageable, soit par accès à une copie de la zone mémoire. Les types primitifs de taille fixe sont des types valeur : bool, int, float… mais pas string, de taille variable.

Les types valeur ne peuvent pas être null stricto sensu. Cela se complique avec le type générique T?, sucre syntaxique du type valeur System.Nullable<T>T est un autre type valeur, généralement un primitif de taille fixe. Le type T? sert à contenir soit une valeur de type T, soit aucune valeur. C’est donc une modélisation de l’absence de valeur pour un type valeur et c’est cela qui nous intéresse.

L’instanciation et la comparaison se font de manière idiomatique avec la valeur primitive “wrappée” ou avec null selon le cas :

Cas Idiomatique Équivalent Comparaison par valeur
Avec valeur int? i = 1; var i = new Nullable<int>(1); i == 1
Sans valeur int? i = null; var i = new Nullable<int>(); i == null

Dans ce dernier cas, on se retrouve à pouvoir tester i.HasValue avec un i initialisé à null sans déclencher de NullReferenceException, ce qui n’a plus rien de magique quand on connaît les équivalences opérées par le compilateur C♯ 😉

Attrait pour TypeScript

Cet article est issu d’expérimentations s’appuyant sur une littérature variée pour trouver des façons alternatives de coder, plus élégantes et plus robustes. Ces expérimentations ont été grandement facilitées par les possibilités offertes par TypeScript. C’est ce qui en fait son attrait pour moi.

Je ne suis pas le seul à apprécier les possibilités du TypeScript. Scott Wlaschin, évangéliste F♯, classe TypeScript avec F♯ (évidemment) et Kotlin comme les trois langages d’entreprise suffisamment satisfaisants selon ses propres mais rigoureux critères. Plus de détails dans son article.

Pourtant, ce n’était pas gagné. TypeScript aurait pu n’être qu’une simple surcouche à JavaScript, en cherchant à rester au plus proche de JavaScript. Or, JavaScript est considéré par certains comme un langage jouet, pas assez sérieux. Il est vrai qu’il présente de sérieux défauts, mais également de grandes qualités, ces deux facettes étant bien décrites par Douglas Crockford dans son livre de 2008 JavaScript: The Good Parts. D’autres langages comme le CoffeeScript ont cherché à améliorer sa syntaxe mais sans trouver une adhésion suffisante.

TypeScript a été lancé et reste soutenu par Microsoft, ceci en concurrence avec d’autres initiatives similaires chez les concurrents dont certaines plus web friendly telles que Google avec Dart ! TypeScript apporte du typage statique sur un langage dynamique. C’est également audacieux. Mais c’est le pari tenté pour contrevenir aux défauts du JavaScript tout en conservant ses bons côtés, dans une certaine mesure cependant.

En fait, TypeScript va beaucoup plus loin. Ce qui nous intéresse plus particulièrement dans cet article est son système de types très élaboré. C’est ce qui lui permet de supporter le dynamicité du JavaScript dans une grande majorité de cas sans que cela soit trop complexe à mettre en place. Pour illustrer la puissance du système de typage qui continue d’ailleurs à être amélioré régulièrement, citons le fait qu’il est Turing-complet. Certains se sont amusés à en faire la démonstration, telle que cette modélisation d’un additionneur binaire sur 8 bits directement sous forme de types :

type Bit = 0 | 1;
type Int8 = [Bit, Bit, Bit, Bit, Bit, Bit, Bit, Bit];
type Int8Add<A extends Int8, B extends Int8...

type One = [1, 0, 0, 0, 0, 0, 0, 0];
type Two = [0, 1, 0, 0, 0, 0, 0, 0];
type TwoC = Int8Add<One, One>; // OK: [0, 1, 0, 0, 0, 0, 0, 0]

type Three = [1, 1, 0, 0, 0, 0, 0, 0];
type ThreeC = Int8Add<One, Two>; // OK: [1, 1, 0, 0, 0, 0, 0, 0]

null et undefined

JavaScript comporte deux littéraux similaires : null et undefined, tous deux représentants du “null” à leur manière. Il y a beaucoup de confusion autour de ces deux valeurs. Éclaircissons tout cela de ce pas !

Terminologie et notation

Commençons néanmoins par statuer sur notre nouveau problème : quand on dit null et nullable, est-ce au sens strict, faisant juste référence à null, ou au sens large, incluant aussi undefined ?

On trouve en anglais le terme nullish pour parler de nullable au sens large mais cela ne résout le problème ni pour null ni pour nullabilité. Il existe également le mot nil duquel on pourrait en déduire des néologismes comme nilabilité ou nullibilité mais cela serait trop, à mon avis.

👉 Nous continuerons donc à utiliser “null”, “nullable”, “nullabilité” notés ainsi, entre guillemets, quand ils sont à prendre au sens large. Quand à null noté ainsi, il sera pris au sens strict, désignant la valeur ou le type.

undefined

undefined représente ce qui est absent de manière implicite et possiblement transitoire. Il est présent à de multiples endroits dans le langage JavaScript :

  • Valeur d’une variable déclarée mais pas encore initialisée : let vv === undefined
  • Valeur et type d’une variable globale non déclarée : window.variableNotDeclared === undefined et typeof variableNotDeclared === 'undefined'
  • Élément non présent dans un tableau : const a = []a[0] === undefined
  • Argument non spécifié lors de l’appel d’une fonction : function f(a) { return a; }f() === undefined
  • Valeur implicite renvoyée par une fonction "void", sans valeur de retour, que l’on y fasse return; ou non : function f() {}f() === undefined
  • Valeur renvoyée par l’opérateur void : (void 'any-expression') === undefined
  • Membre absent d’un objet : const o = {}o.a === undefined (*)
  • (Par extension en TypeScript) Membre optionnel d’une classe : class C { a? }new C().a === undefined

(*) Attention ! La réciproque n’est pas vraie : il ne suffit pas qu’un champ soit undefined pour qu’il soit absent de l’objet. Le critère discriminant est 'a' in o.

👋 Curiosités de undefined

  • La valeur undefined est une propriété de l’objet global. On pourrait donc modifier sa valeur, ce qui saboterait toutes les expressions x === undefined 😰 ! Heureusement, cela n’est plus possible depuis ES5, implémentée par tous les moteurs JavaScript modernes.
  • Plus pernicieux encore : undefined n’est pas un mot clé réservé ! On peut donc déclarer une variable ou un argument d’une fonction ayant pour nom undefined. On peut le vérifier en exécutant (function(undefined) { console.log(undefined, typeof undefined); })('foo'); qui imprime "foo string" en console. Quelle merveilleuse possibilité pour saboter une base de code 🤒 !
  • On peut contourner ces problèmes en utilisant l’opérateur void, par exemple x === void 0. Mais cette expression peut échouer si x n’a pas été défini !
  • Au final, on se reportera sur une expression du type typeof x === 'undefined' pour être définitivement tranquille.

null

null représente ce qui est absent de manière explicite, intentionnelle, significative et possiblement définitive. Ainsi, null est souvent utilisé en valeur de retour d’une API lorsqu’une valeur est attendue mais qu’aucune ne convient, par exemple  document.getElementById() et String::match(regexp).

Mais cela n’est pas malheureusement systématique. Par exemple, les méthodes suivantes renvoient undefined en valeur de repli :

👋 Curiosité de null
typeof null renvoie 'object' plutôt que null mais pour des raisons historiques. En fait, null ne se limite pas aux objets et s’utilise également en substitut de valeurs primitives.

Comparaison de null et undefined

Les aspects transitoire pour undefined et significatif pour null se retrouvent dans la façon dont ils sont traités dans les opérations arithmétiques :

  • null est considéré comme une valeur absente, ce qui équivaut à la valeur 0 pour un nombre.
    (null * 1) === 0
    (null + 1) === 1
  • undefined est considéré comme une valeur temporaire ne devant pas arriver jusqu’à là. Quand c’est néanmoins le cas, l’opération échoue en produisant la valeur NaN.
    isNaN(undefined * 1) === true

👋 NaN
Dans la bataille, on a omis NaN, not a number, qui représente une opération arithmétique qui a échoué, c’est-à-dire une absence de valeur en sortie, un peu comme null. Mais on s’en tiendra à cette évocation, les choses étant déjà assez compliquées avec nos deux actuels représentants du “null”.

Les aspects transitoire / significatif se retrouvent également dans la sérialisation en JSON :

  • Un champ de valeur null est sérialisé, sa valeur étant significative.
    JSON.stringify({ a: null }) === '{"a":null}'
  • Un champ de valeur undefined est ignoré comme s’il était absent.
    JSON.stringify({ a: undefined }) === '{}'

☝️ Modélisation des DTO pour une Web API C♯

La sérialisation en JSON nous donne l’occasion d’évoquer la modélisation des DTO d’un Front en TypeScript en relation avec une Web API en C♯ telle que ASP.NET Core. Vu que le langage ne supporte pas les champs optionnels, cela impacte les DTO.

• Les DTO émis par la Web API ont tous leurs champs, au besoin avec la valeur null pour indiquer l’absence de valeur, implicite ou intentionnelle.
• Les DTO reçus avec un champ aussi bien absent que null sont désérialisés en un objet avec le champ null.

Cela signifie que si l’on modélise nos DTO avec des champs optionnels (type FooDto { bar?: string; }), ils seront bien interprétés par la Web API mais ils seront retraduits et renvoyés avec ces mêmes champs null ! Le plus simple alors est de basculer sur des champs juste strictement nullables : type FooDto { bar: string | null; }. C’est plus contraignant mais il n’y aura pas de mauvaises surprises au runtime.

Voici en synthèse les différences entre null et undefined :

Éléments undefined null
Image 🚧 En travaux / Vide à remplir 🚫 Impasse
Absence de valeur dans le temps ⏳ Plutôt temporaire 🔒 Plutôt définitive
Qui donne cette valeur ? Moteur JavaScript → implicite Auteur du code → intentionnel
typeof ✔️ 'undefined' ⚠️ 'object'
Équivalence arithmétique NaN 0

null et undefined en TypeScript

TypeScript a repris les deux mots clés null et undefined, pour les valeurs mais également pour les types : tant undefined que null sont des types spécifiques.

Il y a néanmoins une subtilité de taille : selon que le mode strict null check soit activé ou non, null et undefined sont exclus ou non des valeurs acceptables pour n’importe quel autre type, à l’exception toutefois des deux Top Types any et unknown. Nous détaillerons ce mode dans un instant.

Guards : gérer les “nulls”

En JavaScript, l’accès à une propriété d’une variable déclenche également une erreur de “référence null” quand la variable est “null”. L’erreur varie en fonction du contexte et du navigateur. Concrètement, lorsque l’on exécute o.x :

  • Quand o n’est pas définie, on a l’erreur “not defined” :
    • ReferenceError: o is not defined
  • Quand o est null ou undefined, on a l’erreur “no properties” :
    • TypeError: Cannot read property 'x' of {nil} (Chrome)
    • TypeError: {nil} has no properties (Firefox)
    • Avec nil remplacé par null ou undefined

Pour se prémunir de ces erreurs en programmation défensive, on enrobe le code concerné dans un bloc if selon le principe if o is defined and not nullish then it’s safe. null et undefined étant falsy, on pourrait se contenter de if (o), sauf que cela écarte également false et 0 qui sont également falsy !

null et undefined sont équivalents mais pas strictement égaux : null == undefined et null !== undefined. On pourrait donc utiliser if (o != null), en écartant le cas où o ne serait pas déclarée.

En alternative, on peut encapsuler o != null ou son équivalent plus rigoureux o !== null && o !== undefined dans une fonction isNotNullOrUndefined, isNotNullish, isNotNil. Pour faire de cette fonction une type guard et profiter de l’IntelliSense TypeScript, il suffit de l’indiquer dans son type :

function isNotNil<T>(x: T | null | undefined): x is T {
  return x != null;
}

💡 Astuce
Il existe une autre option : le chaînage optionnel, disponible depuis TypeScript 3.7 mais déjà connu de ceux qui font du C♯ 6. La syntaxe est élégante (o?.x), supportée en chaîne (a?.b?.c), mais de portée limitée à l’expression courante.

Avis sur null et undefined

Toutes ces subtilités font que les opinions sont assez divergentes sur l’usage du null :

  • Douglas Crockford pense que null est une mauvaise idée et qu’il vaudrait mieux n’utiliser que undefined. Prudence cependant : ce monsieur a fait progresser le JavaScript en maturité, mais il peut aussi adopter des positions extrémistes.
  • L’équipe TypeScript n’utilise pas null et ne recommande pas son usage dans ses guidelines sans toutefois donner d’explications.
  • À l’inverse, null est largement utilisé dans Angular pour indiquer l’absence de quelque chose, par exemple :
    • La classe DatePipe du module @angular/common a une méthode transform(value, format) pour formater une date, renvoyant null quand la valeur spécifiée est null ou vide.
    • La classe AbstractControl du module @angular/forms a une méthode get(path) qui renvoie le contrôle enfant de chemin spécifié ou null s’il n’a pas été trouvé.
    • La fonction ValidatorFn du module @angular/forms renvoie soit un objet ValidationErrors indiquant les validations en échec, soit null quand la validation a réussi.
    • Mais nous n’avons aucune mention sur null dans son Style Guide.

Mon avis sur la question a évolué pendant l’écriture de cet article et surtout suite aux retours de relecture. Ces retours m’ont fait beaucoup plus approfondir la question, au point de tripler la taille de cette section !

  • J’étais au départ réservé sur l’intérêt d’avoir null en plus de undefined. Je voyais aussi des erreurs d’usage qui n’amélioraient pas sa côte de popularité, en particulier le fait d’initialiser les variables à null comme on fait en Java / C♯ alors que ce n’est pas idiomatique en JavaScript / TypeScript.
  • Ayant mieux cerné les différences de null par rapport à undefined, je comprends mieux l’usage de ce premier pour signifier l’absence intentionnelle de valeur en particulier en retour d’une fonction hors du happy path. Je pense même qu’on devrait quasiment ne jamais voir de undefined dans le code mais plutôt des champs optionnels (donc des a?: string). La valeur undefined sera toujours présente au runtime mais de manière implicite.
  • Je vous laisse vous faire votre propre opinion. L’important reste à mes yeux l’homogénéité de vos bases de code, que l’emploi du null et/ou undefined soit un choix d’équipe et un choix respecté.

Mode strict null check

Ce mode est apparu avec la version 2.0 de TypeScript. Il s’active avec l’option du compilateur TypeScript --strictNullChecks ou son équivalent dans le fichier tsconfig.json. “null” est à prendre au sens large : ce mode permet au compilateur de détecter une “référence null”, par exemple en émettant une erreur de compilation Object x is possibly 'undefined'. C’est donc un must have à activer sur tout nouveau projet TypeScript.

Revers de la médaille : on ne peut plus ignorer les “nulls”, faire semblant qu’ils ne peuvent pas arriver dans telle ou telle partie du code. On est obligé de les prendre en compte :

  • En se protégeant à l’aide de guards “if not null then …”,
  • En indiquant les types “nullables”, T devenant T | null | undefined dans le pire des cas.

Cela crée une pollution visuelle : le code devient moins lisible mais, deuxième effet Kiss Cool, explicite, honnête en matière de typage. Tout l’objet de ces articles est d’évoquer des alternatives toujours Type Safe mais également plus élégantes, plus simples à lire.

☝️ A partir d’ici, nous serons implicitement en mode strict null check.

Analysons les impacts de ce mode sur la gestion de la valeur par défaut. Pour de plus amples détails sur ce mode, je vous invite à la lire la release note de la version 2.0 très intéressante.

Valeur par défaut

En activant mode strict null check, null et undefined sont ôtés des valeurs possibles pour un élément d’un type quelconque, car ce type est considéré désormais comme “non-nullable” par défaut. Plusieurs options sont possibles pour que cela compile à nouveau :

  1. 👍 Conserver le type “non-nullable” et fournir une valeur non “null” :
    • Cela permet d’avoir toujours une valeur significative et de ne pas avoir à se protéger des “nulls”.
    • On peut généralement s’appuyer sur l’inférence de type à partir de la valeur fournie, ce qui donne un code plus succinct et plus proche du JavaScript : let x: MonTypelet x = MaValeurParDefaut.
    • La valeur que l’on fournit peut alors être la valeur “neutre” / “vide” du type en question (cf. § suivant).
  2. ✔️ Spécifier que le type est “nullable” :
    • Basculer sur un type union pour rajouter au type existant | null ou | undefined ou | null | undefined (ou | nil avec l’astuce ci-dessous) : let x: string = nulllet x: string | null = null.
    • Les champs optionnels et les paramètres optionnels sans valeur par défaut représentent un cas particulier. Ils sont spécifiés en utilisant la syntaxe a?: string. Bien que par nature supportant la valeur undefined, il n’est pas nécessaire d’indiquer T | undefined car c’est implicite. En revanche, pas de changement concernant null, toujours à mentionner dans le type : a?: string | null.
  3. 💣 Débrancher le compilateur :
    • Avec l’opérateur d’assertion non null !appelé opérateur “bang” 💥 – quand la “non-nullité” est garantie par le contexte que l’on connaît mais qui est inaccessible ou mal interprété par le compilateur. On utilise cet opérateur soit dans les expressions comme a!.b, soit dans les déclarations de champ dans les classes : class C { a!: string; ... }. Nous en reparlerons dans le prochain article sur les classes.
    • Avec l’assertion de type préférée des débutants en TypeScript : as any
      → C’est un aveu d’échec à trouver un type satisfaisant…

💡 Astuce
On peut utiliser l’alias type nil = null | undefined pour une syntaxe plus condensée : a: string | nil. On pourra mettre ce type dans un fichier src/types/utility-types.ts et on référencera le dossier dans le fichier tsconfig.json pour rendre ces types accessibles dans tout le projet sans avoir besoin d’imports :

{
  "compilerOptions": {
    "typeRoots": [
      "node_modules/@types",
      "src/types"
    ]
  }
}

Valeurs vides

Afin de garder des types “non-nullables”, on peut modéliser leur absence de valeur par une valeur vide. Voyons en quelques exemples :

  • Pour les types primitifs : chaîne vide '', 0 ou -1 pour les nombres, false pour les booléens.
  • Pour les enums, on peut leur rajouter un membre None ou équivalent si c’est cohérent avec les autres membres.
  • Pour les tableaux, [] ne convient pas tel quel car son type est any[]. Il faut indiquer le type cible : messages: string[] = []. On pourrait également écrire messages = new Array<string>() mais c’est moins idiomatique.
  • Pour les objets, ne convient pas non plus. Son type est c’est-à-dire un objet litéral sans membre propre. On peut avoir recours à une assertion de type telle que address = {} as Address qui est plus laxiste en matière de compatibilité de types. Mais c’est risqué. Un type correct serait Partial<Address> mais c’est reculer pour mieux sauter : chaque membre sera optionnel !

Au besoin, on peut avoir recours à des patterns comme le Null Object ou le type Maybe que l’on détaillera chacun dans leur article respectif.

👋 Curiosité en TypeScript

On peut séparer déclaration et affectation d’une variable, y compris “non-nullable” : let a: string; ... a = '';.
Entre les deux, a est undefined.

» Cela ne figure pas sur son type. C’est déroutant ! 😕

Concrètement, le type indique que l’on ne peut pas assigner la valeur undefined, mais pas que la variable ne vaut jamais undefined, non ? 😅

» En fait, si ! Pour les développeurs, c’est vrai ! Comment ? 😲

En pratique, le compilateur nous garantit que la variable que l’on va utiliser ne sera jamais undefined car, si l’on tente de s’en servir pendant cette phase transitoire, on aura l’erreur de compilation Variable 'a' is used before being assigned 😆

Conclusion

Nous avons vu que l’absence de valeur était souvent représentée par “null” et que cela rendait le code sensible à une catégorie de bugs pernicieux. C’est le cas aussi en JavaScript, mais avec un “null” double : undefined représente l’absence de valeur implicite et plutôt transitoire, null indique une valeur absente de manière intentionnelle et plutôt définitive.

Ce qui démarque TypeScript est son mode strict null check qui rend explicite et choisie cette potentielle absence de valeur via les types “nullables”. Cela rend également explicite – car obligatoire – la gestion de la “nullabilité” et le fardeau qui en découle. Mais le code est plus "honnête", il ne cache pas de “nulls”. C’est également une occasion de chercher une modélisation la plus possible dénuée de “nulls”.

Cela nous a amené à considérer l’emploi de valeurs “vides” dont nous avons quelques exemples. Nous continuerons cette analyse en nous concentrons sur les classes qui tiennent une place importante en TypeScript, pour aborder au final des modélisations alternatives plus élégantes.

Remerciements

👏 Un grand merci à Nourdine Falola pour sa relecture pointue et ses suggestions éclairantes.

© SOAT
Toute reproduction interdite sans autorisation de l’auteur

Nombre de vue : 491

AJOUTER UN COMMENTAIRE