05 - Branded types : typage nominal en TypeScript
Ce que tu vas apprendre
- Le problème du typage structurel quand deux strings ou numbers representent des choses différentes
- Comment créer un branded type pour différencier
UserIddeOrderId - Les patterns de construction (fonctions factory, assertion functions)
- Les cas d'usage concrets : IDs, devises, unités de mesure
Prerequisites
Avoir lu les articles sur les generics et l'egalite structurelle.
Le bug a 4000 euros
Un dev junior sur un projet client avait écrit une fonction de transfert entre comptes. Le type etait simple :
typescriptfunction transfer(from: string, to: string, amount: number) {
// debite from, credite to
}
transfer(orderId, userId, total)
Le problème : il a inverse from et to. Ou plutot, il a passe un orderId a la place d'un accountId. Les deux sont des strings. Le compilateur n'a rien dit. Le transfert est parti sur le mauvais compte. 4000 euros envoyes au mauvais endroit.
Le typage structurel de TypeScript ne distingue pas deux strings qui representent des choses différentes. UserId, OrderId, AccountId sont tous string. Le compilateur les considéré comme interchangeables.
Le typage nominal vs structurel
En Java ou C#, deux classes avec le meme contenu sont différentes :
java// Java — typage nominal
class UserId { String value; }
class OrderId { String value; }
UserId userId = new UserId("123");
OrderId orderId = new OrderId("456");
// userId = orderId; // ❌ erreur de compilation
En TypeScript, le typage est structurel (on l'a vu dans l'article sur l'egalite). Deux types avec la meme forme sont compatibles :
typescripttype UserId = string
type OrderId = string
const userId: UserId = "user_123"
const orderId: OrderId = "order_456"
const x: UserId = orderId // ✅ compile — les deux sont string
Les branded types simulent un typage nominal dans un système structurel.
Creer un branded type
L'idee : ajouter une propriété fictive (un "brand") qui n'existe pas au runtime mais qui differencie les types a la compilation.
typescripttype Brand<T, B extends string> = T & { readonly __brand: B }
type UserId = Brand<string, "UserId">
type OrderId = Brand<string, "OrderId">
type AccountId = Brand<string, "AccountId">
Maintenant :
typescriptconst userId = "user_123" as UserId
const orderId = "order_456" as OrderId
function getUser(id: UserId) { /* ... */ }
getUser(userId) // ✅
getUser(orderId) // ❌ Type 'OrderId' is not assignable to type 'UserId'
getUser("raw") // ❌ Type 'string' is not assignable to type 'UserId'
Le compilateur refuse de passer un OrderId là où un UserId est attendu. La propriété __brand n'existe pas au runtime (c'est un phantom type), mais le système de types la voit et fait la distinction.
Fonctions factory pour créer des valeurs brandees
Le as UserId est un cast. C'est mieux que rien, mais ca ne valide pas la valeur. Une approche plus robuste : une fonction factory.
typescriptfunction createUserId(raw: string): UserId {
if (!raw.startsWith("user_")) {
throw new Error(`Invalid UserId: ${raw}`)
}
return raw as UserId
}
function createOrderId(raw: string): OrderId {
if (!raw.startsWith("order_")) {
throw new Error(`Invalid OrderId: ${raw}`)
}
return raw as OrderId
}
const userId = createUserId("user_123") // ✅ type: UserId
const bad = createUserId("order_456") // 💥 throw au runtime
Le cast as est confine dans la factory. Le reste du code ne manipule que des UserId valides. C'est le seul endroit ou tu as besoin de faire confiance au dev.
Cas d'usage concrets
Devises et montants
typescripttype EUR = Brand<number, "EUR">
type USD = Brand<number, "USD">
function createEUR(amount: number): EUR { return amount as EUR }
function createUSD(amount: number): USD { return amount as USD }
function convertToUSD(eur: EUR, rate: number): USD {
return createUSD((eur as number) * rate)
}
const price = createEUR(100)
const converted = convertToUSD(price, 1.08) // ✅
const wrong = convertToUSD(createUSD(50), 1.08) // ❌ USD n'est pas EUR
Plus jamais d'addition accidentelle d'euros et de dollars.
Unites de mesure
typescripttype Meters = Brand<number, "Meters">
type Kilometers = Brand<number, "Kilometers">
type Miles = Brand<number, "Miles">
function metersToKm(m: Meters): Kilometers {
return ((m as number) / 1000) as Kilometers
}
function kmToMiles(km: Kilometers): Miles {
return ((km as number) * 0.621371) as Miles
}
Le crash de Mars Climate Orbiter en 1999 (125 millions de dollars) etait du a une confusion entre livres-force et newtons. Des branded types auraient rendu l'erreur impossible a compiler.
Timestamps et dates
typescripttype UnixTimestamp = Brand<number, "UnixTimestamp">
type ISODateString = Brand<string, "ISODateString">
function now(): UnixTimestamp {
return Date.now() as UnixTimestamp
}
function toISO(ts: UnixTimestamp): ISODateString {
return new Date(ts as number).toISOString() as ISODateString
}
IDs d'entités sur paltemps.fr
Sur paltemps.fr, chaque entité a son type d'ID :
typescripttype UserId = Brand<string, "UserId">
type PlaceId = Brand<string, "PlaceId">
type BookingId = Brand<string, "BookingId">
function getBooking(id: BookingId): Promise<Booking> { /* ... */ }
function getPlace(id: PlaceId): Promise<Place> { /* ... */ }
// Impossible de passer un UserId a getPlace
getPlace(userId) // ❌
Ca a elimine une classe entière de bugs ou des IDs etaient melangees entre les endpoints de l'API.
Pattern alternatif : unique symbol
Une autre approche utilise unique symbol pour le brand :
typescriptdeclare const UserIdBrand: unique symbol
declare const OrderIdBrand: unique symbol
type UserId = string & { readonly [UserIdBrand]: typeof UserIdBrand }
type OrderId = string & { readonly [OrderIdBrand]: typeof OrderIdBrand }
L'avantage : chaque brand est garanti unique par le compilateur (pas de risque de collision de strings). L'inconvenient : plus verbeux. L'article sur les symbols couvre ce pattern en détail.
Branded types et sérialisation
Les branded types sont effaces au runtime. Un UserId est juste un string en JSON. Quand tu recois des donnees d'une API, tu dois re-brander :
typescriptinterface ApiUser {
id: string // string brut depuis le JSON
name: string
}
function toUser(raw: ApiUser): User {
return {
id: createUserId(raw.id), // re-brand avec validation
name: raw.name
}
}
Le re-branding est le point d'entree ou tu valides les donnees externes. Apres ca, le reste du code est type-safe.
Quand utiliser les branded types
Utilise-les quand :
- Deux valeurs du meme type primitif representent des concepts différents (IDs, devises, unités)
- Une confusion entre ces valeurs peut causer un bug silencieux
- Tu veux forcer le passage par une factory qui valide la valeur
Ne les utilise pas quand :
- Le type primitif suffit (un
name: stringest juste un string) - La distinction n'a pas d'impact sur la logique métier
- Ca ajoute de la complexité sans réduire les bugs
Résumé
- Le typage structurel de TypeScript ne distingue pas deux strings/numbers qui representent des concepts différents
- Les branded types ajoutent un "tag" fantome qui differencie les types a la compilation sans impact au runtime
- Les fonctions factory confinent le
ascast et ajoutent de la validation - Cas d'usage principaux : IDs d'entités, devises, unités de mesure, timestamps
- Les branded types sont effaces au runtime — il faut re-brander les donnees entrantes
Article précédent : 04 - Template literal types
Article suivant : 06 - satisfies, as const et const generics