Result Pattern vs Throw : Gérer les erreurs proprement en TypeScript
Publié le
Développeur React & Next.js freelance
Après plus de 7 ans à développer des applications React et Next.js, j'ai une conviction : le code doit pouvoir se lire. Et quand je lis une fonction, je veux comprendre immédiatement ce qu'elle fait, ce qu'elle retourne, et ce qui peut mal se passer.
Le problème avec throw ? Les erreurs sont invisibles. Rien dans la signature d'une fonction ne vous dit qu'elle peut exploser à tout moment.
Le pattern Result résout ce problème. Avec lui, TypeScript nous contraint à expliciter les cas d'erreur et de succès. Le flux devient visible directement dans la signature. Plus de surprise, plus d'erreur silencieuse.
Dans cet article, vous allez découvrir :
- Pourquoi
throw/try-catchpose problème dans du code métier - Comment le pattern Result rend les erreurs explicites et typées
- Des exemples concrets avant/après (validation, Server Actions, API)
- Quand utiliser Result et quand garder throw
Le problème avec throw/try-catch
Les erreurs sont invisibles dans les signatures
Regardez cette fonction :
const getUserById = async (id: string): Promise<User> => {
const user = await db.user.findUnique({ where: { id } })
if (!user) {
throw new Error('Utilisateur non trouvé')
}
return user
}La signature dit Promise<User>. Elle ment. Cette fonction peut aussi lancer une erreur si l'utilisateur n'existe pas, si la connexion à la base de données échoue, ou si l'ID est invalide.
Le développeur qui utilise cette fonction doit :
- Lire l'implémentation pour découvrir les erreurs possibles
- Deviner s'il faut un
try-catchou non - Espérer que personne ne modifie la fonction sans prévenir
En Java, les "checked exceptions" forcent à déclarer les erreurs dans la signature. TypeScript n'a pas ça. Une fonction peut throw n'importe quoi, n'importe quand, sans que le compilateur ne bronche.
Le flux de contrôle devient imprévisible
Quand une erreur peut surgir n'importe où, le flux de contrôle devient un labyrinthe :
const createOrder = async (userId: string, productId: string) => {
const user = await getUserById(userId) // Peut throw
const product = await getProductById(productId) // Peut throw
const stock = await checkStock(productId) // Peut throw
const payment = await processPayment(user, product) // Peut throw
const order = await saveOrder(user, product, payment) // Peut throw
await sendConfirmationEmail(user, order) // Peut throw
return order
}Où mettre le try-catch ? Autour de tout ? Autour de chaque ligne ? Et si une erreur à l'étape 4 nécessite un rollback des étapes 1-3 ?
Le pire, c'est le catch qui ne fait rien d'utile :
try {
const order = await createOrder(userId, productId)
} catch (error) {
console.error(error) // Et ensuite ? L'utilisateur voit quoi ?
}L'erreur est loggée. L'utilisateur voit un écran blanc. Le bug est "géré".
Les tests deviennent complexes
Tester les cas d'erreur avec throw demande une gymnastique :
it('should throw when user not found', async () => {
await expect(getUserById('invalid-id')).rejects.toThrow('Utilisateur non trouvé')
})Ça marche, mais :
- On teste le message d'erreur (fragile si le wording change)
- On ne peut pas facilement tester différents types d'erreurs
- Le test ne documente pas les erreurs attendues
Le pattern Result : une meilleure alternative
Définition et syntaxe de base
Le pattern Result utilise les discriminated union types pour représenter explicitement le succès ou l'échec d'une opération :
type Success<T> = { success: true; data: T }
type Failure<E> = { success: false; error: E }
type Result<T, E = Error> = Success<T> | Failure<E>Une fonction qui retourne Result<User, UserError> dit clairement :
- "Je peux réussir et te donner un
User" - "Je peux échouer et te donner un
UserError" - "Tu dois gérer les deux cas"
Le discriminant success permet à TypeScript de faire du narrowing automatique : dans le bloc if (result.success), le compilateur sait que result.data existe.
Exemple concret : Server Action Next.js
Les Server Actions sont un cas idéal pour Result. Le serveur renvoie une erreur structurée que le client comprend sans parser de message.
AVANT — avec throw :
'use server'
export const createUser = async (formData: FormData) => {
const email = formData.get('email') as string
const existingUser = await db.user.findUnique({ where: { email } })
if (existingUser) {
throw new Error('Email déjà utilisé')
}
const user = await db.user.create({ data: { email } })
return user
}Côté client, vous recevez une erreur générique. Impossible de savoir si c'est un email dupliqué, une erreur de validation, ou un problème serveur.
APRÈS — avec Result :
'use server'
type CreateUserError =
| { code: 'EMAIL_TAKEN' }
| { code: 'VALIDATION_ERROR'; message: string }
| { code: 'DATABASE_ERROR' }
type CreateUserResult = Result<{ id: string; email: string }, CreateUserError>
export const createUser = async (formData: FormData): Promise<CreateUserResult> => {
const email = formData.get('email') as string
if (!email || !email.includes('@')) {
return { success: false, error: { code: 'VALIDATION_ERROR', message: 'Email invalide' } }
}
const existingUser = await db.user.findUnique({ where: { email } })
if (existingUser) {
return { success: false, error: { code: 'EMAIL_TAKEN' } }
}
try {
const user = await db.user.create({ data: { email } })
return { success: true, data: { id: user.id, email: user.email } }
} catch {
return { success: false, error: { code: 'DATABASE_ERROR' } }
}
}Composant client :
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { createUser } from '@/app/actions/create-user'
export const SignupForm = () => {
const router = useRouter()
const [error, setError] = useState<string | null>(null)
const handleSubmit = async (formData: FormData) => {
setError(null)
const result = await createUser(formData)
if (result.success) {
router.push('/dashboard')
} else {
switch (result.error.code) {
case 'EMAIL_TAKEN':
setError('Cet email est déjà utilisé. Connectez-vous ou utilisez un autre email.')
break
case 'VALIDATION_ERROR':
setError(result.error.message)
break
case 'DATABASE_ERROR':
setError('Une erreur est survenue. Veuillez réessayer.')
break
}
}
}
return (
<form action={handleSubmit}>
{error && <p className="text-red-500">{error}</p>}
{/* ... */}
</form>
)
}Les erreurs deviennent explicites
Avec le pattern Result :
- La signature dit tout :
Result<User, CreateUserError>— pas de surprise - TypeScript force à gérer les deux cas : impossible d'ignorer l'erreur sans un cast explicite
- Le narrowing est automatique : après
if (result.success), le compilateur connaît le type exact - Les erreurs sont typées :
CreateUserErrora une structure définie, pas juste unmessage: string
Aller plus loin : composition de Results
Quand plusieurs opérations dépendent l'une de l'autre, le code peut devenir verbeux avec les if successifs. Des patterns comme flatMap ou combine permettent de chaîner et agréger les Results de manière élégante.
Si vous avez besoin de composer beaucoup d'opérations qui peuvent échouer, des librairies comme Effect offrent des abstractions puissantes (Either, pipe, generators). Elles ajoutent de la complexité mais deviennent pertinentes sur des projets avec beaucoup de logique métier.
Pour la plupart des cas, le pattern Result simple présenté ici suffit largement. Commencez par là, et explorez Effect si vous atteignez les limites.
Quand utiliser quoi
Garder throw pour les cas exceptionnels
Le pattern Result n'est pas universel. throw reste approprié pour :
Les erreurs de programmation :
const divide = (a: number, b: number): number => {
if (b === 0) {
throw new Error('Division by zero') // Bug du développeur, pas de l'utilisateur
}
return a / b
}Les situations irrécupérables au démarrage :
const config = loadConfig()
if (!config.DATABASE_URL) {
throw new Error('DATABASE_URL is required') // L'app ne peut pas fonctionner
}L'interruption volontaire du flux (middleware d'auth) :
const requireAuth = (request: Request) => {
const token = request.headers.get('Authorization')
if (!token) {
throw new AuthError('Unauthorized') // Intercepté par error boundary
}
return verifyToken(token)
}Utiliser Result pour la logique métier
Result est adapté pour :
- Validations utilisateur : l'erreur est attendue et doit être affichée
- Opérations qui peuvent "normalement" échouer : email déjà pris, stock insuffisant
- Appels à des services externes : réseau, API tierces
- Tout ce que le code appelant doit gérer explicitement
Règle pratique
Posez-vous cette question :
"L'erreur doit-elle être gérée par le code appelant, ou doit-elle remonter jusqu'à un error boundary global ?"
- Gérée par le code appelant → Result
- Remontée à un error boundary → throw
Migration progressive
Vous êtes convaincu par cette approche simple et pratique, et vous vous demandez comment l'intégrer dans votre projet React & Next.js existant ?
Commencer par les nouvelles fonctions
Pas besoin de tout réécrire. Adoptez Result pour le nouveau code :
// Nouvelle fonction : utilise Result
const validateOrder = (data: OrderData): Result<ValidOrder, OrderError> => {
// ...
}
// Fonction existante : reste avec throw (pour l'instant)
const processPayment = async (order: Order): Promise<Payment> => {
// ...
}Convertir progressivement
- Identifiez les fonctions les plus appelées
- Convertissez de l'intérieur vers l'extérieur
- Mettez à jour les appelants au fur et à mesure
Erreurs courantes à éviter
Mélanger Result et throw dans la même fonction
// Mauvais : incohérent
const processData = (data: unknown): Result<ProcessedData, ValidationError> => {
if (!data) {
throw new Error('Data required') // Incohérent !
}
if (!isValid(data)) {
return { success: false, error: { code: 'INVALID' } }
}
return { success: true, data: process(data) }
}Une fonction = un style. Choisissez Result ou throw, pas les deux.
Oublier de gérer le cas d'erreur
TypeScript ne force pas à utiliser result.error, juste à vérifier result.success :
const result = await createUser(data)
if (result.success) {
// OK
} else {
// TypeScript ne râle pas si on ignore result.error
console.log('Erreur') // Mauvais : on perd l'info
}Solution : utilisez un exhaustive check ou loggez l'erreur explicitement.
Sur-engineering avec des monades complexes
Libraries comme fp-ts ou Effect offrent des patterns avancés (Maybe, Either, TaskEither...). Elles sont puissantes mais ajoutent de la complexité.
Le Result simple présenté ici couvre 95% des cas. Commencez par là avant d'explorer les monades fonctionnelles.
Questions fréquentes
Qu'est-ce que le Result Pattern en TypeScript ?
Le Result Pattern est une technique de gestion d'erreurs qui utilise un type union (discriminated union) pour représenter explicitement le succès ou l'échec d'une opération : { success: true, data: T } | { success: false, error: E }. Contrairement à throw, les erreurs sont visibles dans la signature de la fonction et TypeScript force le développeur à les gérer.
Quelle est la différence entre Result Pattern et try-catch ?
Avec try-catch, les erreurs sont invisibles dans la signature — vous devez lire l'implémentation pour savoir si une fonction peut échouer. Avec le Result Pattern, le type de retour Result<User, NotFoundError> indique explicitement les cas d'erreur possibles. TypeScript vous oblige à vérifier result.success avant d'accéder aux données.
Quand utiliser throw vs Result Pattern ?
Utilisez throw pour les erreurs de programmation (bugs), les situations irrécupérables (config manquante au démarrage), ou l'interruption volontaire du flux (middleware d'auth). Utilisez Result pour la logique métier : validations utilisateur, opérations qui peuvent "normalement" échouer (email déjà pris, stock insuffisant), et appels à des services externes.
Le Result Pattern fonctionne-t-il avec les Server Actions Next.js ?
Oui, c'est même un cas d'usage idéal. Le serveur retourne un Result typé que le client peut exploiter pour afficher des messages d'erreur précis selon le code d'erreur (EMAIL_TAKEN, VALIDATION_ERROR, etc.), sans parser de message d'erreur générique.
Faut-il utiliser une librairie comme fp-ts ou Effect ?
Pour la plupart des projets, le Result Pattern simple (3 lignes de types) suffit. Les librairies comme Effect ou fp-ts apportent des abstractions puissantes (Either, pipe, composition) mais ajoutent de la complexité. Commencez simple, et explorez ces outils si vous atteignez les limites du pattern basique.
Conclusion
Le pattern Result transforme la gestion d'erreurs en TypeScript :
- Erreurs explicites : la signature dit tout, pas de surprise
- Erreurs typées : chaque cas d'erreur a une structure définie
- Tests simplifiés : pas besoin de
expect().toThrow(), juste des assertions sur des objets - Code prévisible : le flux de contrôle est visible, pas caché dans des throws
Adoptez ce pattern si :
- Votre projet a de la logique métier avec des erreurs utilisateur
- Vous voulez que TypeScript vous aide à ne rien oublier
- Vous valorisez le code explicite et lisible
Gardez throw pour les erreurs exceptionnelles (bugs, config manquante, interruptions volontaires).
Le code doit pouvoir se lire. Avec Result, les erreurs se lisent aussi.

À propos de l'auteur
Je suis Dimitri Dumont, développeur freelance spécialisé React & Next.js depuis plus de 7 ans. J'ai accompagné 22 startups et réalisé 43 missions avec une note de 5/5. J'applique ces patterns au quotidien pour continuer de livrer rapidement du code évolutif. En savoir plus →
Cet article vous a été utile ?
Je peux vous accompagner sur votre projet React & Next.js.
Discutons de votre projet →