12 - Pattern matching type avec ts-pattern
Ce que tu vas apprendre
- Pourquoi TypeScript n'a pas de
matchnatif et ce qui manque - Comment ts-pattern comble ce manque avec un pattern matching type-safe
- Les patterns : literaux, wildcards, guards, objets imbriques
- Des exemples concrets avec des discriminated unions et des résultats d'API
Prerequisites
Avoir lu les articles sur les discriminated unions et les type guards.
Ce qui manque a switch
On a vu dans la serie 1 que switch + discriminated unions + exhaustive check avec never fonctionne bien. Mais switch a des limites :
typescript// ❌ switch ne pattern-match pas sur la structure
switch (result) {
case { ok: true }: // ❌ impossible — switch compare par reference
break
}
// ❌ switch ne destructure pas
switch (event.type) {
case "click":
// event est narrow, mais je dois acceder a event.x, event.y manuellement
break
}
// ❌ switch ne combine pas les conditions
switch (true) { // hack moche
case x > 0 && x < 10:
break
}
Des langages comme Rust, Haskell, Scala, et meme le futur ECMAScript (proposition TC39 Pattern Matching) ont du pattern matching natif. TypeScript ne l'a pas encore.
ts-pattern : le match pour TypeScript
ts-pattern est une lib de Gabriel Vergnaud qui ajoute du pattern matching type-safe en TypeScript. Elle pese ~2KB minifiee et n'a aucune dépendance.
typescriptimport { match, P } from "ts-pattern"
type ApiResult =
| { status: "loading" }
| { status: "error"; error: string; code: number }
| { status: "success"; data: User }
function render(result: ApiResult) {
return match(result)
.with({ status: "loading" }, () => <Spinner />)
.with({ status: "error", code: 404 }, () => <NotFound />)
.with({ status: "error" }, ({ error }) => <Error message={error} />)
.with({ status: "success" }, ({ data }) => <UserProfile user={data} />)
.exhaustive()
}
Chaque .with() est un pattern. Le premier pattern qui correspond est exécuté. .exhaustive() vérifié a la compilation que tous les cas sont geres.
La différence avec switch
typescript// Avec switch — verbose, pas de destructuration
switch (result.status) {
case "loading":
return <Spinner />
case "error":
if (result.code === 404) return <NotFound />
return <Error message={result.error} />
case "success":
return <UserProfile user={result.data} />
}
// Avec ts-pattern — concis, destructuration, sous-patterns
match(result)
.with({ status: "error", code: 404 }, () => <NotFound />)
.with({ status: "error" }, ({ error }) => <Error message={error} />)
// ...
ts-pattern permet de matcher sur des sous-propriétés (code: 404) directement dans le pattern. Avec switch, il faut imbriquer des if.
Les patterns disponibles
Literaux et wildcards
typescriptmatch(value)
.with(42, () => "la reponse")
.with("hello", () => "salut")
.with(true, () => "vrai")
.with(P._, () => "autre chose") // wildcard — matche tout
Objets et sous-patterns
typescriptmatch(user)
.with({ role: "admin" }, (u) => `Admin: ${u.name}`)
.with({ role: "user", verified: true }, (u) => `Verified: ${u.name}`)
.with({ role: "user", verified: false }, (u) => `Unverified: ${u.name}`)
.exhaustive()
P.string, P.number, P.boolean
typescriptmatch(input)
.with(P.string, (s) => s.toUpperCase())
.with(P.number, (n) => n.toFixed(2))
.with(P.boolean, (b) => b ? "oui" : "non")
.with(P.nullish, () => "vide")
.exhaustive()
Tableaux
typescriptmatch(items)
.with([], () => "liste vide")
.with([P._], ([single]) => `un element: ${single}`)
.with([P._, P._], ([a, b]) => `deux elements: ${a}, ${b}`)
.with(P.array(P.number), (nums) => `${nums.length} nombres`)
.otherwise(() => "autre")
Guards avec P.when
typescriptmatch(age)
.with(P.when((n) => n < 0), () => "invalide")
.with(P.when((n) => n < 18), () => "mineur")
.with(P.when((n) => n < 65), () => "actif")
.otherwise(() => "retraite")
P.select pour extraire des valeurs
typescripttype Response =
| { type: "user"; data: { id: string; name: string } }
| { type: "error"; message: string }
match(response)
.with({ type: "user", data: P.select() }, (data) => {
// data est { id: string; name: string }
return data.name
})
.with({ type: "error", message: P.select() }, (msg) => {
return `Erreur: ${msg}`
})
.exhaustive()
P.select() extrait la valeur matchee et la passe au callback.
Exemple concret : gestion d'événements
Sur paltemps.fr, je gere les événements de webhooks avec ts-pattern :
typescripttype WebhookEvent =
| { type: "payment.completed"; amount: number; orderId: string }
| { type: "payment.failed"; error: string; orderId: string }
| { type: "subscription.created"; userId: string; plan: string }
| { type: "subscription.cancelled"; userId: string; reason: string }
function handleWebhook(event: WebhookEvent) {
return match(event)
.with({ type: "payment.completed", amount: P.when(a => a > 1000) }, (e) => {
notifyAdmin(`Gros paiement: ${e.amount}€ sur commande ${e.orderId}`)
processPayment(e.orderId)
})
.with({ type: "payment.completed" }, (e) => {
processPayment(e.orderId)
})
.with({ type: "payment.failed" }, (e) => {
handleFailedPayment(e.orderId, e.error)
})
.with({ type: "subscription.created" }, (e) => {
activatePlan(e.userId, e.plan)
})
.with({ type: "subscription.cancelled" }, (e) => {
deactivateUser(e.userId, e.reason)
})
.exhaustive()
}
Le premier pattern pour payment.completed a un guard sur le montant. Les gros paiements ont un traitement special. L'ordre des patterns compte : le plus spécifique en premier.
.exhaustive() vs .otherwise()
.exhaustive() vérifié a la compilation que tous les cas sont geres. Si tu ajoutes un nouveau type d'événement, le compilateur te le signale.
.otherwise() est le catch-all. Il gere tout ce qui n'a pas matche.
typescript// ✅ Exhaustive — le compilateur te force a gerer chaque cas
match(event).with(/* ... */).exhaustive()
// ✅ Otherwise — catch-all pour les cas non geres
match(event).with(/* ... */).otherwise((e) => {
console.warn(`Evenement non gere: ${e.type}`)
})
// ✅ run() — retourne void, pas de verification
match(event).with(/* ... */).run()
Mon avis : utilise .exhaustive() par défaut. C'est le meme filet de sécurité que le pattern never dans un switch, mais en plus lisible.
Quand utiliser ts-pattern
- Quand tu as des discriminated unions avec des sous-patterns (status + sous-propriétés)
- Quand tu as plus de 4-5 branches avec des conditions imbriquees
- Quand tu veux de l'exhaustivite avec de la destructuration
- Quand tu veux du pattern matching sur des objets imbriques
Quand ne pas l'utiliser :
- Un simple
if/elsesur un boolean - Un
switchsimple sur 2-3 cas sans sous-conditions - Quand l'équipe ne connaît pas la lib (la courbe d'apprentissage est un coût)
Résumé
- ts-pattern apporte du pattern matching type-safe a TypeScript avec vérification d'exhaustivite
- Les patterns matchent sur les literaux, les objets, les tableaux, les types, et les guards
P.select()extrait des valeurs,P.when()ajoute des conditions,P._matche tout.exhaustive()vérifié a la compilation que tous les cas sont geres- Prefere ts-pattern aux switch imbriques quand les conditions sont complexes
Article précédent : 11 - Symbols et opaque types
Article suivant : 13 - Typer l'asynchrone
Sources
- ts-pattern - GitHub par Gabriel Vergnaud
- TC39 - Pattern Matching Proposal
- ts-pattern documentation