Alors que ça fait quelque temps que j'ai commencé à entendre parler du concept, j'en vois finalement très peu en ligne et encore moins en production. Donc je vous en parle que la pratique se diffuse et que vous utilisiez au maximum l'aide que peut vous fournir TypeScript !

TypeScript ? Types ? Kézako ?

Déjà revenons à la base : c'est quoi TypeScript ?

TypeScript est une superset de JavaScript, qui va vous offrir -- au prix d'une phase de transpilation (conversion du code TypeScript en code JavaScript) -- tout un système de typing (interfaces et types) vous permettant de typer explicitement tout votre application et ainsi améliorer votre expérience de développeur via une autocomplétion beaucoup plus précise et la qualité du code en validant que vous ne faites pas n'importe quoi.

Point à bien comprendre par contre : aucun de vos typings n'existe à l'exécution comme ça n'existe pas en JavaScript. Contrairement à des langages comme Java ou C# où il y a pas mal de validation à l'exécution (rien que pour les cast), avec TypeScript tout est fait exclusivement à la compilation. Et vous pouvez mentir ou faire taire le compilateur...

Pour faire taire le compilateur rien de plus simple : const s: string = new Date() as any;. Ce code va parfaitement fonctionner, car au runtime on ne vérifie rien, et dès qu'il est face à un any le compilateur vous laisse faire ce que vous voulez, c'est à vous de vous assurer que tout est ok (dites-vous un truc simple : si vous utilisez un any, vous devenez le compilateur !). Donc n'utilisez pas de any si vous avez le choix !

Comme je disais : vous pouvez mentir à TypeScript. En fait à chaque fois que vous faite un cast c'est un mensonge que vous faite, car si vos types sont bien pensés dès le début vous n'êtes pas censé avoir à cast à tout bout de champ. Mais il y a des cas où c'est pratique, et je vais vous montrer ça un peu plus loin !

Concept de Branded Types

Partons d'un code simple :

interface Person {
    id: string;
}
interface Space {
    id: string;
}

const person1: Person = { id: '1' };
const space1: Space = { id: '1' };

C'est classique, je pense que rien ne vous choque. On a 2 interfaces, chacune définisse la signature d'un objet métier, chacune à un identifiant id qui est une string. On peut créer un objet de chaque type sans souci.

Mais que se passe-t-il si on commence à faire des choses comme ça :

const space2: Space = person1;
const person2: Person = { id: space1.id };
const person3: Person = { id: space2.id + "3" };

Là je ne pense pas que vous trouviez ça toujours aussi normal… En fait en effet on est défini ce qu'on attend pour chaque objet métier, mais on a pas du tout pris mis de sécurité sur le mauvais usage de nos éléments… Pourtant aussi bien du point de vue de TypeScript que de JavaScript tout est ok !

Autre exemple (ou je vais encore faire n'importe quoi évidemment ! 😈) :

const amountInEUR = 10;
const amountInUSD = 20;
const amountInJPY = 3_000;
const amountInKRW = 40_000;
const total = amountInEUR + amountInUSD + amountInJPY + amountInKRW; // currency? 🤔

Ce code est parfaitement ok ! Techniquement parlant en tout cas ! Fonctionnellement, c'est un autre problème… En effet je pense que vous êtes d'accord que je ne peux pas bêtement additionner des montants dans des devises différentes sans conversion préalable, pourtant c'est parfaitement valide techniquement parlant, car ce ne sont que des number, et que 40 000 Won de Corée du Sud soit environ égal à 27€ (taux change au moment où j'écris), TypeScript et JavaScript s'en foutent ce ne sont que 2 nombres 🤷

TypeScript peut nous aider à faire mieux, il suffit juste de lui donner plus d'info.

Construire un Branded Type !

Comme je vous le laissais entendre juste avant, l'idée serait de dire à TypeScript un truc du genre "cette string, ce n'est pas une string random, c'est un id de Person donc incompatible avec le reste". On parle parfois d'ajouter un "Tag" ou une "Brand" à un type (vous verrez à peu près autant le nom "tagged type" que "branded type", j'avoue que "branded type" me semble moins confusant à l'usage mais pas plus d'avis que ça).

Donc ce qu'on voudrait écrire c'est ça :

type PersonId = Brand<string, 'PersonId'>;
type SpaceId = Brand<string, 'SpaceId'>;

interface Person {
    id: PersonId;
}
interface Space {
    id: SpaceId;
}

On ajoute peu de complexité. On rend les signatures de Person et Space plus précise. Mais ça implique qu'on ne peut plus écrire ça :

const person1: Person = { id: '1' }; // compilation error
const space1: Space = { id: '1' }; // compilation error

Il va falloir qu'on cast explicitement notre id dans le bon type :

const person1: Person = { id: '1' as PersonId };
const space1: Space = { id: '1' as SpaceId };

Mais je pense que vous vous demandez d'où je sors ce type Brand ? Hé bien c'est un type que j'ai créé (pas une invention personnelle non plus, c'est une idée qu'on retrouve largement en ligne) :

type Brand<TInner, TBrandName> = { __brand: TBrandName } & TInner;

En vrai on pourrait faire mieux. Déjà notre type Brand est assez "simpliste" et ne tient pas compte de tous les cas. Ensuite il faut faire des casts explicites pour que ça fonctionne et personnellement je ne suis pas super fan de faire ça...

En mieux et sur étagère !

Vous me connaissez, je suis sympa, j'ai créé une librairie qui propose un type Brand tout prêt et des fonctions utilitaires pour aller avec ! 🤓

Déjà vous pouvez jouer avec une démo entièrement configurée ici : https://stackblitz.com/~/github.com/kuroidoruido/brand-demo

Pour faire ça chez vous dans votre projet : npm i --save @anthonypena/types-utils

Ensuite vous pouvez importer Brand qui est un type du même genre que ce que je vous ai montré plus tôt, mais aussi fromBrand et toBrand qui vont vous aider à manipuler vos types sans cast explicites !

import { Brand, fromBrand, toBrand } from '@anthonypena/types-utils';

type PersonId = Brand<string, 'PersonId'>;
type SpaceId = Brand<string, 'SpaceId'>;

interface Person {
    id: PersonId;
}
interface Space {
    id: SpaceId;
}

const person1: Person = { id: toBrand('1') };
const space1: Space = { id: toBrand('1') };

const space2Id: SpaceId = toBrand('2');
const space2IdString: string = fromBrand(space2Id);
const person2: Person = { id: space2Id }; // compilation error
const person3: Person = space1; // compilation error
const person4: Person = { id: toBrand(space2Id+'') };
const person5: Person = { id: toBrand(space2Id+person1.id) };
const person6: Person = { id: toBrand(space2IdString) };

Là je vous ai mis quelques manipulations basiques, comme vous le voyez on peut avoir des aides sympas tout en ayant toujours moyen de bricoler pour casser le système de type… On reste en TypeScript, donc on peut facilement mentir… Mais ! Si on reste sur des manipulations "naturelles" on ne peut pas faire d'erreur sans faire n'importe quoi ! 😎

Le code n'est pas très compliqué, je vous invite complètement à aller voir comment est fait le code ici : https://github.com/kuroidoruido/js-libs/blob/main/libs/types-utils/src/brand.ts

J'en profite pour vous inviter à jeter un coup d'œil à la librairie (même si elle manque encore beaucoup de documentation !) qui propose quelques utilitaires sympas !

Conclusion

Ce n'est pas révolutionnaire comme système, ça peut compliquer un peu vos développements donc peut-être pas quelque chose à utiliser massivement mais ponctuellement sur les éléments clés ? En tout cas n'hésitez pas à tester pour voir ça vous aide au quotidien !

Sources :

Crédit photo : https://pixabay.com/photos/cow-lick-tongue-head-cow-head-1715829/