Emulation de typage nominal en TypeScript

À la réflexion, ce titre sonne un peu « Les chevaliers de l’an mille au lac de Paladru »… Vous avez la ref’ ? Laissez-moi vous guider, en commençant par quelques définitions.
Typage nominal ou structurel
On parle de typage nominal lorsqu’un type est défini par son nom. Java propose un typage nominal. Si 2 types ont des noms différents, par exemple Numerator et Denominator, le compilateur les considérera comme différents quand bien même ces types seraient identiques dans les faits (même comportement).
Cela s’oppose au typage structurel, dans lequel un type est défini par sa structure. TypeScript propose un typage structurel. Par exemple, toujours au sujet des fractions, les types Numerator et Denominator définis ci-dessous sont entièrement substituables du point de vue du compilateur :
type Numerator = {
value: number;
};
type Denominator = {
value: number;
};
On parle de duck typing, parce que si ça vole et que ça fait « coin coin » comme un canard, c’est que c’est un canard 🦆
Limites du typage structurel
Le choix du typage structurel par les créateurs de TypeScript présente de nombreux avantages, qui sont largement documentés sur le Web. Malheureusement, ce choix s’avère limitant pour qui veut faire de la programmation défensive, qui vise à « assurer le fonctionnement continu d’un logiciel dans des circonstances imprévues ».
Cette approche suppose un état d’esprit (à juste titre) paranoïaque, qui nous interdit de faire confiance à qui que ce soit, utilisateur ou même développeur : que ce soit intentionnel ou non, des données vous parviendront tôt ou tard, qui mettront le programme en échec. Exemple : la fameuse division par 0.
L’idée est donc d’effectuer les validations nécessaires le plus tôt possible et d’encapsuler la donnée validée dans une abstraction que l’on pourra manipuler en toute sécurité dans la suite du programme. D’où la recommandation d’encapsuler les primitives du langage (string, number etc.) et de faire émerger les abstractions métier correspondantes (Domain-Driven Design mon Amour 💜).
Ainsi, nous ne saurions nous contenter des types Numerator et Denominator précédemment définis. Ces types étant identiques du point de vue du compilateur, ils peuvent être intervertis par erreur du développeur ou de l’utilisateur. Tôt ou tard, un dénominateur sera pris pour un numérateur (problématique), ou réciproquement (encore plus problématique car cela pourrait conduire à une division par 0).
Autrement dit, nous voudrions les garanties du typage nominal, tandis que nous travaillons avec un typage structurel.
Que faire ?
Émulation de typage nominal
Eh bien nous pouvons toujours émuler un typage nominal. Pour cela, nous allons utiliser :
-
Un symbole (
DENOMINATOR_SYMBOL), que nous aurons soin de ne pas exporter ; -
Un constructeur (
createDenominator), fonction qui accède au symbole et l’utilise dans l’objet qu’elle crée. Ce constructeur effectue les contrôles sans lesquels il serait abusif de parler de dénominateur.
Ainsi :
-
Les types
NumeratoretDenominatorne sont plus intervertibles ; -
Créer un objet
Denominatorn’est possible qu’en passant par le constructeur, interdisant de ce fait des dénominateurs nuls.
Elle est pas belle, la vie ? 🏝️🍸
Le code complet est disponible sur GitHub : https://github.com/mathieueveillard/nominal-typing-emulation
Branding / flavouring
J’ai montré cette approche en prenant l’exemple d’objets (type Numerator = { value: number; };), mais dans la « vraie vie », quand il n’y a qu’une seule valeur à encapsuler, ce qui est notamment le cas des identifiants, on voudra probablement utiliser une version plus légère, appelée branding ou flavouring.
type Branded<T> = string & { __brand: T };
Que l’on utilisera comme suit :
const CLIENT_SYMBOL = Symbol();
type ClientId = Branded<typeof CLIENT_SYMBOL>;
L’idée est la même, sauf que l’on travaille sur une primitive du langage (ici : string). C’est plus facile à manipuler qu’un objet, puisque le type ainsi créé est un sous-type de string.