TypeScript types avances - 05 - Branded types : typage nominal en TypeScript

Comment simuler un typage nominal en TypeScript pour empecher de melanger des valeurs du meme type primitif. UserId vs OrderId, euros vs dollars.

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 UserId de OrderId
  • 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: string est 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 as cast 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

Sources

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.