Une fonction demande un timeout en millisecondes. Vous passez 5000. Plus tard, une autre fonction demande un timeout en secondes. Vous passez 5. Entre les deux, vous appelez setTimeout(duration, callback) et il ne se passe rien pendant une heure et vingt-trois minutes.
TypeScript ne vous sauve pas ici. 5000 et 5 sont tous deux de type number. Le compiler ne voit aucune différence entre une distance en mètres et une distance en pieds, une température en Celsius et une température en Fahrenheit, un timestamp et une durée. Votre suite de tests ne le détecte probablement pas non plus, parce que le calcul est correct. Ce sont simplement les unités qui sont fausses.
La solution consiste à cesser de traiter les unités comme de la documentation et à commencer à les traiter comme des types.
Pourquoi number est le mauvais type pour les grandeurs physiques
TypeScript utilise le structural typing. Deux objets sont compatibles si leurs formes correspondent. C’est généralement une feature, mais pour les primitives comme number, cela signifie que tous les nombres sont interchangeables. Un number est un number est un number.
Les runtime checks peuvent attraper les erreurs d’unité, mais ils sont coûteux à maintenir et faciles à ignorer. Il faudrait valider chaque argument de fonction, chaque réponse d’API, chaque constante définie dans un autre fichier. En pratique, personne ne le fait. Les checks deviennent des commentaires, et les commentaires mentent.
L’alternative consiste à encoder l’unité directement dans le type. À la compilation, Seconds et Milliseconds deviennent des types incompatibles. Multipliez Meters par Meters et vous obtenez SquareMeters. Ajoutez Miles à Kilometers et le compiler refuse. À l’exécution, la valeur reste simplement un nombre. Il n’y a pas d’objet wrapper, pas de validation runtime, pas de coût en performance. C’est une zero-cost abstraction.
Comment les phantom types transforment un nombre en une branded unit
TypeScript ne supporte pas le nominal typing pour les primitives, mais il supporte les intersection types et les unique symbols. Vous pouvez brander une primitive pour que deux brands soient incompatibles même lorsque la valeur sous-jacente est identique.
Voici le pattern :
type Brand<T, B> = T & { readonly __brand: B };
type Meters = Brand<number, "Meters">;
type Kilometers = Brand<number, "Kilometers">;
type Seconds = Brand<number, "Seconds">;
type Milliseconds = Brand<number, "Milliseconds">;
La propriété __brand n’existe pas à l’exécution. C’est un phantom type. Il n’existe que dans le type system. Mais cela suffit à rendre Meters et Kilometers mutuellement incompatibles.
Vous ne pouvez pas accidentellement assigner un number brut à une branded type. C’est le but. Vous devez en construire une explicitement, ce qui vous force à déclarer l’unité.
Un système d’unités fonctionnel en TypeScript
Voici une implémentation minimale mais complète qui gère la construction, la conversion et l’arithmétique.
type Brand<T, B> = T & { readonly __brand: B };
type Meters = Brand<number, "Meters">;
type Kilometers = Brand<number, "Kilometers">;
type Seconds = Brand<number, "Seconds">;
type Milliseconds = Brand<number, "Milliseconds">;
type MetersPerSecond = Brand<number, "MetersPerSecond">;
function meters(value: number): Meters {
return value as Meters;
}
function kilometers(value: number): Kilometers {
return value as Kilometers;
}
function seconds(value: number): Seconds {
return value as Seconds;
}
function milliseconds(value: number): Milliseconds {
return value as Milliseconds;
}
function toMeters(km: Kilometers): Meters {
return meters(km * 1000);
}
function toSeconds(ms: Milliseconds): Seconds {
return seconds(ms / 1000);
}
function toMilliseconds(s: Seconds): Milliseconds {
return milliseconds(s * 1000);
}
function addMeters(a: Meters, b: Meters): Meters {
return meters(a + b);
}
function speed(distance: Meters, time: Seconds): MetersPerSecond {
return (distance / time) as MetersPerSecond;
}
Usage :
const d1 = kilometers(5);
const d2 = meters(200);
const t = seconds(10);
// This compiles.
const totalDistance = addMeters(toMeters(d1), d2);
const velocity = speed(totalDistance, t);
// This does not.
const bad = addMeters(d1, d2);
// ^^^ Argument of type 'Kilometers' is not assignable to parameter of type 'Meters'.
const alsoBad = speed(totalDistance, milliseconds(5000));
// ^^^^^ Argument of type 'Milliseconds' is not assignable to parameter of type 'Seconds'.
L’erreur apparaît là où le bug est introduit, pas là où la valeur est finalement utilisée. Vous n’avez pas besoin de remonter velocity à travers trois fichiers pour découvrir que quelqu’un a passé des millisecondes à un paramètre qui attendait des secondes.
Dériver des compound units à partir des base units
Le pattern s’adapte aux compound units. Au lieu d’écrire MetersPerSecond à la main, vous pouvez le dériver des base types en utilisant un generic constructor.
type Per<A, B> = Brand<number, { numerator: A; denominator: B }>;
type Times<A, B> = Brand<number, { left: A; right: B }>;
type MetersPerSecond = Per<Meters, Seconds>;
type SquareMeters = Times<Meters, Meters>;
function per<A, B>(numerator: Brand<number, A>, denominator: Brand<number, B>): Per<A, B> {
return (numerator / denominator) as Per<A, B>;
}
function times<A, B>(left: Brand<number, A>, right: Brand<number, B>): Times<A, B> {
return (left * right) as Times<A, B>;
}
En pratique, vous n’avez peut-être pas besoin d’une dimensional analysis complète. La plupart des équipes atteignent des rendements décroissants après une douzaine d’unit types environ. Le but n’est pas de modéliser la physique. Le but est d’éliminer la catégorie de bug la plus coûteuse : celle où le calcul fonctionne mais les unités ne fonctionnent pas.
Les trade-offs à connaître
Les branded types ne sont pas gratuits. Ils coûtent en ergonomie.
Chaque littéral doit être enveloppé dans un constructor. setTimeout(callback, 5000) devient setTimeout(callback, milliseconds(5000)). C’est plus de frappe au clavier. Si votre équipe n’utilise pas les constructors de manière cohérente, vous vous retrouvez avec des unsafe casts éparpillés dans la codebase. Le pattern ne fonctionne que si tout le monde l’utilise.
Le type inference devient aussi bruyant. Les array methods et les generic functions peuvent exposer le brand dans les messages d’erreur. Un number[] brut est plus facile à lire qu’un (number & { readonly __brand: "Milliseconds" })[]. Vous aurez peut-être besoin de type aliases pour garder les signatures lisibles.
La serialization est un autre point de friction. JSON n’a aucune notion de branded types. Lorsque vous envoyez une valeur Meters sur le réseau, elle arrive comme un number brut de l’autre côté. Vous devez reconstruire le brand à la frontière. C’est le bon endroit pour le faire, mais c’est du code supplémentaire.
La plus grande limitation est que c’est une technique TypeScript-only. Si votre système inclut des services Python, des microservices Go, ou des consumers en plain JavaScript, les brands disparaissent à la frontière du langage. Vous avez toujours besoin de validation runtime aux bords du système. Les branded types protègent le code TypeScript interne. Ils ne remplacent pas les schemas pour les données externes.
Comment introduire cela sans agacer votre équipe
Ne brandez pas chaque nombre dans votre codebase. Commencez par les paramètres qui ont causé de vrais incidents.
- Identifiez les trois derniers bugs liés aux unités en production. Cherchez les millisecondes contre secondes, les devises dans des denominations différentes, la latitude contre la longitude, ou les screen coordinates contre les document coordinates.
- Brandez ces types spécifiques. Ajoutez des constructors et des fonctions de conversion.
- Mettez à jour les fonctions où ces valeurs sont utilisées. Laissez le compiler vous guider.
- Ajoutez une lint rule qui interdit le
numberbrut pour ces paramètres.
Ne brandez pas les loop counters, les array indices, ou les pourcentages. Ceux-ci sont sans dimension. Ajouter un brand là-bas, c’est de la cérémonie sans valeur.
Si vous travaillez dans un langage avec un nominal typing plus fort, vous avez de meilleures options. Les utilisateurs de Rust devraient regarder le crate uom. F# et OCaml ont des units of measure intégrées dans le compiler. Le structural type system de TypeScript fait de cela un workaround, pas une feature de premier ordre. Mais le workaround est suffisant pour attraper de vrais bugs.
FAQ
Cela ajoute-t-il du overhead à l’exécution ?
Non. Le brand est un construct compile-time-only. Après compilation, meters(100) n’est que le nombre 100. Il n’y a pas d’objet wrapper, pas de propriété supplémentaire, pas de runtime check.
Et la multiplication et la division ?
Vous avez besoin de fonctions explicites ou d’overloaded operators. TypeScript ne supporte pas l’operator overloading, donc distance / time doit passer par une fonction per() ou speed(). C’est verbeux, mais c’est aussi la raison pour laquelle le bug est attrapé.
Puis-je utiliser cela avec des third-party libraries ?
Seulement si la library accepte votre branded type. Si setTimeout attend un number, vous pouvez passer Milliseconds parce que le brand est une intersection avec number. L’inverse n’est pas vrai. Si une library retourne un number, vous devez explicitement le brander avant de l’utiliser comme unit type.
Comment gérer les fractional units comme HalfSeconds ?
Utilisez la base unit et un constructor. halfSeconds(1) retourne Milliseconds(500). Ne créez pas de brand pour chaque subdivision. Gardez le nombre de base units petit.
Arrêtez d’écrire les noms d’unités dans les noms de variables
Appeler une variable timeoutInMs, c’est de la documentation. La documentation dérive. L’appeler timeout: Milliseconds, c’est un type. Les types sont enforceés.
La prochaine fois que vous déboguez un problème et réalisez que le timeout était décalé d’un facteur mille, demandez-vous si le nom de variable suffisait. Ce n’était pas le cas. Encodez l’unité dans le type, laissez le compiler faire le travail, et cessez de vous faire confiance pour vous souvenir si cette fonction particulière veut des secondes ou des millisecondes.