06 - Error handling type
Ce que tu vas apprendre
- Pourquoi
try/catchcasse 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/catchperd l'information de type —catchest toujoursunknown- Le pattern Result encode le succes et l'erreur dans le type de retour
- Les discriminated unions d'erreurs avec un champ
typedonnent unswitchexhaustif neverthrowformalise le pattern avecmap,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
- neverthrow GitHub par Giorgio Delgado
- Effect TS - Error Management
- Total TypeScript - Errors in TypeScript par Matt Pocock