Modéliser l’absence de valeur en TypeScript
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>
où 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 v
⇒v === undefined
- Valeur et type d’une variable globale non déclarée :
window.variableNotDeclared === undefined
ettypeof 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 expressionsx === 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 nomundefined
. 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 exemplex === void 0
. Mais cette expression peut échouer six
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 :
Array::find()
,Array::pop()
,Map::get()
→ Probablement en lien, conceptuel ou au niveau implémentation, avec les accesseurs de propriétés,array[unknown_index]
renvoyantundefined
.Object::getOwnPropertyDescriptor()
→ Là, je ne vois pas pourquoi cela ne renvoie pasnull
!
👋 Curiosité de
null
typeof null
renvoie'object'
plutôt quenull
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 valeur0
pour un nombre.
→(null * 1) === 0</codebr><br /> → <code class="language-plaintext">(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 valeurNaN
.
→isNaN(undefined * 1) === true
👋
NaN
Dans la bataille, on a omisNaN
, 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 commenull
. 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 quenull
sont désérialisés en un objet avec le champnull
.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 champsnull
! 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 Typesany 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
estnull
ouundefined
, on a l’erreur “no properties” :TypeError: Cannot read property 'x' of {nil}</codei>(Chrome)</i></li> <li><code class="language-plaintext">TypeError: {nil} has no properties</codei>(Firefox)</i></li> <li>Avec <code class="kb-btn">nil
remplacé parnull
ouundefined
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 queundefined
. 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éthodetransform(value, format)
pour formater une date, renvoyantnull
quand la valeur spécifiée estnull
ou vide. - La classe
AbstractControl
du module@angular/forms
a une méthodeget(path)
qui renvoie le contrôle enfant de chemin spécifié ounull
s’il n’a pas été trouvé. - La fonction
ValidatorFn
du module@angular/forms
renvoie soit un objetValidationErrors
indiquant les validations en échec, soitnull
quand la validation a réussi. - Mais nous n’avons aucune mention sur
null
dans son Style Guide.
- La classe
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 deundefined
. 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 deundefined
dans le code mais plutôt des champs optionnels (donc desa?: string
). La valeurundefined
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/ouundefined
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
devenantT | 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 :
- 👍 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: MonType
let x = MaValeurParDefaut
. - La valeur que l’on fournit peut alors être la valeur “neutre” / “vide” du type en question (cf. § suivant).
- ✔️ 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 = null
let 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 valeurundefined
, il n’est pas nécessaire d’indiquerT | undefined
car c’est implicite. En revanche, pas de changement concernantnull
, toujours à mentionner dans le type :a?: string | null
.
- Basculer sur un type union pour rajouter au type existant
- 💣 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 commea!.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</codebr><br /> → C’est un aveu d’échec à trouver un type satisfaisant…</li> </ul> </li> </ol> <blockquote> <p>💡 <strong>Astuce</strongbr><br /> On peut utiliser l’alias <code class="language-plaintext">type nil = null | undefined
pour une syntaxe plus condensée :a: string | nil
. On pourra mettre ce type dans un fichiersrc/types/utility-types.ts
et on référencera le dossier dans le fichiertsconfig.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
enum
s, on peut leur rajouter un membreNone
ou équivalent si c’est cohérent avec les autres membres. - Pour les tableaux,
[]
ne convient pas tel quel car son type estany[]
. Il faut indiquer le type cible :messages: string[] = []
. On pourrait également écriremessages = new Array<string>()
mais c’est moins idiomatique. - Pour les objets,
ne convient pas non plus. Son type est <code class="kb-btn"/code> c’est-à-dire un objet litéral sans membre propre. On peut avoir recours à une assertion de type telle que <code class="language-plaintext">address = {} as Address
qui est plus laxiste en matière de compatibilité de types. Mais c’est risqué. Un type correct seraitPartial<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
estundefined
.» 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 jamaisundefined
, 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 compilationVariable '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 - Pour les types primitifs : chaîne vide
- Avec l’opérateur d’assertion non null