TypeScript en pratique - 06 - Error handling type

Le problème du try/catch, le pattern Result, les discriminated unions d'erreurs, et quand throw vs return.

06 - Error handling type

Ce que tu vas apprendre

  • Pourquoi try/catch casse le système de types
  • Le pattern Result pour des erreurs typees
  • Les discriminated unions d'erreurs
  • neverthrow : une lib pour le pattern Result
  • Quand throw et quand return — la ligne de séparation

Prerequisites

Avoir lu l'article sur le typage d'API REST.


Le problème avec try/catch

En TypeScript, catch attrape unknown :

typescripttry {
  const user = await fetchUser(id)
} catch (err) {
  // err est unknown — tu ne sais rien sur l'erreur
  console.error(err)
}

Avant TypeScript 4.4, err etait any. Depuis, c'est unknown (avec useUnknownInCatchVariables dans le tsconfig). C'est mieux — mais le problème fondamental reste.

throw peut lancer n'importe quoi. Un Error, une string, un number, null. TypeScript ne peut pas typer ce qui sort d'un throw parce que n'importe quelle fonction dans la call stack peut throw n'importe quoi.

typescript// Tout ca est valide
throw new Error("oops")
throw "oops"
throw 42
throw null
throw { code: "NOT_FOUND" }

Résultat : chaque catch est un trou dans le système de types. Tu perds l'information sur ce qui a echoue et pourquoi.

Le pattern Result

L'idee est simple : au lieu de throw, on retourne un objet qui represente soit le succes, soit l'erreur.

typescripttype Result<T, E> =
  | { ok: true; value: T }
  | { ok: false; error: E }

function ok<T>(value: T): Result<T, never> {
  return { ok: true, value }
}

function err<E>(error: E): Result<never, E> {
  return { ok: false, error }
}

Utilisation :

typescripttype UserNotFoundError = { type: "USER_NOT_FOUND"; userId: string }
type DatabaseError = { type: "DATABASE_ERROR"; message: string }

type FindUserError = UserNotFoundError | DatabaseError

async function findUser(id: string): Promise<Result<User, FindUserError>> {
  try {
    const user = await db.user.findUnique({ where: { id } })
    if (!user) {
      return err({ type: "USER_NOT_FOUND", userId: id })
    }
    return ok(user)
  } catch (e) {
    return err({ type: "DATABASE_ERROR", message: String(e) })
  }
}

L'appelant est force de gerer les deux cas :

typescriptconst result = await findUser("123")

if (result.ok) {
  console.log(result.value.name) // type: User ✅
} else {
  switch (result.error.type) {
    case "USER_NOT_FOUND":
      console.log(`User ${result.error.userId} not found`)
      break
    case "DATABASE_ERROR":
      console.log(`DB error: ${result.error.message}`)
      break
  }
}

Le type de l'erreur est connu. Le switch est exhaustif — si tu ajoutes un nouveau type d'erreur, TypeScript te signale les endroits ou il n'est pas gere.

Discriminated unions d'erreurs

Le pattern fonctionne bien avec des hierarchies d'erreurs. Chaque erreur a un type discriminant :

typescripttype ValidationError = {
  type: "VALIDATION"
  fields: Record<string, string[]>
}

type NotFoundError = {
  type: "NOT_FOUND"
  resource: string
  id: string
}

type UnauthorizedError = {
  type: "UNAUTHORIZED"
  requiredRole: string
}

type ConflictError = {
  type: "CONFLICT"
  field: string
  value: string
}

type AppError = ValidationError | NotFoundError | UnauthorizedError | ConflictError

Chaque erreur porte les donnees nécessaires pour la traiter. NotFoundError a le resource et l'id. ValidationError a les champs et leurs messages. Pas besoin de parser un message d'erreur string.

Convertir une AppError en réponse HTTP

typescriptfunction errorToResponse(error: AppError): { status: number; body: object } {
  switch (error.type) {
    case "VALIDATION":
      return { status: 400, body: { error: error.type, fields: error.fields } }
    case "NOT_FOUND":
      return { status: 404, body: { error: error.type, resource: error.resource } }
    case "UNAUTHORIZED":
      return { status: 401, body: { error: error.type } }
    case "CONFLICT":
      return { status: 409, body: { error: error.type, field: error.field } }
  }
}

Ce switch est exhaustif. Si tu ajoutes type: "RATE_LIMITED" a AppError, TypeScript signale que le switch ne couvre pas ce cas. C'est la force des discriminated unions vues dans la serie TypeScript avance.

Classes d'erreur custom vs unions

L'approche classique utilise des classes :

typescriptclass NotFoundError extends Error {
  constructor(public resource: string, public id: string) {
    super(`${resource} ${id} not found`)
    this.name = "NotFoundError"
  }
}

class ValidationError extends Error {
  constructor(public fields: Record<string, string[]>) {
    super("Validation failed")
    this.name = "ValidationError"
  }
}

Le problème : instanceof ne fonctionne pas apres sérialisation (JSON, passage entre workers, entre serveur et client). Et le système de types ne force pas l'exhaustivite — un catch qui teste instanceof NotFoundError oublie facilement un cas.

Les unions de types sont plus robustes parce qu'elles fonctionnent sur la structure, pas sur l'identité de l'objet. Un switch sur error.type est exhaustif.

Mon avis : utilise les classes si tu restes dans un seul process et que tu as besoin du stack trace. Utilise les unions pour les erreurs qui traversent des frontieres (API, workers, sérialisation).

neverthrow

neverthrow est une lib qui formalise le pattern Result avec des méthodes utilitaires :

typescriptimport { ok, err, Result, ResultAsync } from "neverthrow"

function divide(a: number, b: number): Result<number, string> {
  if (b === 0) return err("Division by zero")
  return ok(a / b)
}

const result = divide(10, 2)
  .map(n => n * 2)        // transforme la valeur si ok
  .mapErr(e => `Error: ${e}`) // transforme l'erreur si err

// Pour l'async
function fetchUser(id: string): ResultAsync<User, ApiError> {
  return ResultAsync.fromPromise(
    fetch(`/api/users/${id}`).then(r => r.json()),
    (e) => ({ type: "NETWORK" as const, message: String(e) })
  )
}

// Chaining
const result = await fetchUser("123")
  .andThen(user => fetchOrders(user.id))
  .map(orders => orders.filter(o => o.status === "active"))

neverthrow ajoute map, andThen (flatMap), match, et d'autres combinateurs. C'est pratique quand tu chaînes beaucoup d'opérations qui peuvent échouer.

Le coût : c'est une dépendance, et ton équipe doit connaître l'API. Sur un petit projet, le Result maison suffit. Sur un projet avec beaucoup de logique métier, neverthrow réduit le boilerplate.

Error handling en async

Le pattern Result fonctionne avec les Promises :

typescriptasync function createOrder(
  userId: string,
  items: OrderItem[]
): Promise<Result<Order, CreateOrderError>> {
  // Verifier l'utilisateur
  const userResult = await findUser(userId)
  if (!userResult.ok) {
    return err({ type: "USER_NOT_FOUND", userId })
  }

  // Verifier le stock
  const stockResult = await checkStock(items)
  if (!stockResult.ok) {
    return err({ type: "OUT_OF_STOCK", items: stockResult.error.items })
  }

  // Creer la commande
  const order = await db.order.create({
    data: { userId, items, total: stockResult.value.total }
  })

  return ok(order)
}

Chaque étape peut échouer avec un type d'erreur différent. L'appelant recoit un Result qui couvre tous les cas d'erreur possibles.

Quand throw, quand return

Sur paltemps.fr, j'utilise cette regle :

throw pour les erreurs de programmation — les bugs qui ne devraient jamais arriver :

typescriptfunction assertNonNull<T>(value: T | null, message: string): T {
  if (value === null) {
    throw new Error(message) // Bug — ca ne devrait pas arriver
  }
  return value
}

return Result pour les erreurs métier — les cas previsibles que l'appelant doit gerer :

typescriptasync function transferMoney(
  from: string,
  to: string,
  amount: number
): Promise<Result<Transfer, TransferError>> {
  // Solde insuffisant n'est pas un bug — c'est un cas metier
  if (account.balance < amount) {
    return err({ type: "INSUFFICIENT_FUNDS", balance: account.balance, amount })
  }
  // ...
}

La distinction : un bug crashe l'application (throw). Un cas métier previsible retourne une erreur typee (Result). Le code appelant ne devrait jamais avoir a try/catch une erreur métier.

Le piège du Promise.reject

typescript// ❌ Meme probleme que throw — pas type
async function fetchUser(id: string): Promise<User> {
  const res = await fetch(`/api/users/${id}`)
  if (!res.ok) {
    return Promise.reject(new Error("Not found"))
  }
  return res.json()
}

Promise.reject a le meme problème que throw : l'erreur n'est pas dans le type de retour. L'appelant doit deviner ce qui peut échouer. Prefere retourner un Result.


Résumé

  • try/catch perd l'information de type — catch est toujours unknown
  • Le pattern Result encode le succes et l'erreur dans le type de retour
  • Les discriminated unions d'erreurs avec un champ type donnent un switch exhaustif
  • neverthrow formalise le pattern avec map, andThen, match
  • throw pour les bugs (erreurs de programmation), return Result pour les erreurs métier

Article précédent : 05 - Typer une API REST

Article suivant : 07 - Types avec React

Sources

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