TypeScript types avances - 12 - Pattern matching type avec ts-pattern

Comment utiliser ts-pattern pour du pattern matching exhaustif et type-safe en TypeScript. Le match que le langage n'a pas nativement.

12 - Pattern matching type avec ts-pattern

Ce que tu vas apprendre

  • Pourquoi TypeScript n'a pas de match natif 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/else sur un boolean
  • Un switch simple 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

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