Clean Code
Chapitre 7 / 14 · 11 min de lecture

La gestion des erreurs

Des exceptions plutôt que des codes d'erreur, définir le flot normal, et ne jamais retourner (ni passer) null.

Un code propre se lit comme une prose — mais il doit aussi être robuste. Or rien n'enlaidit plus vite une fonction que la gestion des erreurs cousue à même la logique métier : chaque appel suivi de sa vérification, chaque retour gardé par un if, jusqu'à ce que l'algorithme principal disparaisse sous les précautions. Le lecteur ne voit plus ce que fait le code, seulement ce qui pourrait mal tourner.

La thèse de ce chapitre est que lisibilité et robustesse ne sont pas en conflit. À condition de traiter la gestion des erreurs comme une préoccupation distincte — quelque chose que l'on peut lire et raisonner indépendamment de la logique principale. Voyons comment.

Préférer les exceptions aux codes de retour

Aux origines, beaucoup de langages n'avaient pas d'exceptions. Pour signaler une erreur, on positionnait un drapeau ou on renvoyait un code que l'appelant devait inspecter. Le résultat est un code où la logique se noie dans les vérifications :

// ❌ Avant : codes de retour, logique noyée
function sendShutDown(): void {
	const handle = getHandle(DEV1);
	if (handle !== DeviceHandle.INVALID) {
		const record = retrieveDeviceRecord(handle);
		if (record.status !== DEVICE_SUSPENDED) {
			pauseDevice(handle);
			clearDeviceWorkQueue(handle);
			closeDevice(handle);
		} else {
			logger.log("Périphérique suspendu. Arrêt impossible.");
		}
	} else {
		logger.log(`Handle invalide pour : ${DEV1}`);
	}
}

Le problème des codes de retour est qu'ils encombrent l'appelant. Celui-ci doit vérifier l'erreur immédiatement après l'appel — et il est facile de l'oublier. En lançant une exception là où l'erreur est détectée, le code appelant redevient limpide :

// ✅ Après : exceptions, logique et erreurs séparées
function sendShutDown(): void {
	try {
		tryToShutDown();
	} catch (error) {
		logger.log(error);
	}
}

function tryToShutDown(): void {
	const handle = getHandle(DEV1);
	const record = retrieveDeviceRecord(handle);
	pauseDevice(handle);
	clearDeviceWorkQueue(handle);
	closeDevice(handle);
}

function getHandle(id: DeviceId): DeviceHandle {
	// ...
	throw new DeviceShutDownError(`Handle invalide pour : ${id}`);
}

Ce n'est pas qu'une affaire d'esthétique. Le code est meilleur parce que deux préoccupations qui étaient enchevêtrées — l'algorithme d'arrêt du périphérique et la gestion d'erreur — sont maintenant séparées. On peut lire et comprendre chacune indépendamment.

Écrire le try-catch-finally en premier

Une exception définit une portée (scope) dans votre programme. Quand vous exécutez du code dans le try, vous déclarez que l'exécution peut s'interrompre à n'importe quel point pour reprendre dans le catch.

En un sens, les blocs try sont comme des transactions. Votre catch doit laisser le programme dans un état cohérent, quoi qu'il arrive dans le try.

C'est pourquoi il est judicieux de commencer par le try-catch-finally lorsqu'on écrit du code susceptible de lever une exception. Cela vous force à définir d'emblée ce que l'appelant doit attendre, quel que soit l'échec rencontré à l'intérieur.

Le procédé se marie parfaitement avec le TDD. Partons d'un test qui exige une exception quand le fichier n'existe pas :

it("lève une StorageException si le fichier est invalide", async () => {
	await expect(sectionStore.retrieveSection("fichier - invalide"))
		.rejects.toThrow(StorageException);
});

Le test échoue d'abord (rien n'est levé). On écrit alors une implémentation qui tente d'accéder au fichier, puis on resserre le type capturé une fois la portée fixée :

async function retrieveSection(name: string): Promise<RecordedGrip[]> {
	try {
		const file = await readFile(name);
		// La logique réelle viendra ici, en supposant que tout va bien.
		return parseGrips(file);
	} catch (cause) {
		throw new StorageException("Erreur de lecture de section", {
			cause,
		});
	}
}

La portée transactionnelle est en place. On peut désormais construire le reste de la logique entre l'ouverture et la fin du try, en faisant comme si rien ne pouvait mal tourner — le catch s'occupe du pire.

Astuce

Écrivez d'abord un test qui force l'exception, puis le gestionnaire qui le satisfait. Vous bâtissez ainsi la portée transactionnelle en premier, et vous préservez sa nature de « transaction ».

Le coût caché des exceptions déclarées

En Java, le débat sur les checked exceptions — ces exceptions que chaque méthode doit déclarer dans sa signature — a fait rage des années. Sur le papier, l'idée séduit : la signature liste tout ce qui peut être levé, et le compilateur vous force à y faire face. En pratique, le prix est lourd : c'est une violation du principe ouvert/fermé.

Si une fonction de bas niveau se met à lever une nouvelle exception déclarée, et que le catch se trouve trois niveaux plus haut, alors chaque fonction intermédiaire doit modifier sa signature pour propager la déclaration. Une modification au plus profond du système provoque une cascade de changements jusqu'au sommet. L'encapsulation est brisée : toutes les fonctions sur le chemin du throw doivent connaître un détail de bas niveau qui ne les concerne pas.

Note

TypeScript (comme C#, Python ou Ruby) n'a pas de checked exceptions : throw n'apparaît pas dans la signature. On échappe donc au piège de Java. Mais le principe sous-jacent demeure : ne forcez pas vos appelants à connaître les exceptions internes des couches profondes. Laissez l'erreur remonter jusqu'au gestionnaire qui sait quoi en faire.

Quand on souhaite tout de même rendre l'échec visible dans le type — sans la rigidité de Java — TypeScript offre une alternative idiomatique : le type résultat (Result<T>), une union qui matérialise le succès ou l'échec sans throw.

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

function parsePrice(input: string): Result<number> {
	const value = Number(input);
	if (Number.isNaN(value) || value < 0) {
		return { ok: false, error: new Error(`Prix invalide : ${input}`) };
	}
	return { ok: true, value };
}

const result = parsePrice(rawInput);
if (!result.ok) {
	logger.log(result.error.message);
	return;
}
applyPrice(result.value); // value est ici de type number, garanti

Le compilateur oblige l'appelant à traiter le cas d'échec avant d'accéder à la valeur. C'est l'esprit des checked exceptions, sans la cascade de signatures. Réservez ce style aux erreurs attendues et locales (validation, parsing) ; gardez les throw pour les conditions vraiment exceptionnelles.

Fournir du contexte avec les exceptions

Une exception doit fournir assez de contexte pour déterminer la source et la nature de l'erreur. La pile d'appels (stack trace) indique l'on a échoué, jamais pourquoi ni avec quelle intention.

Créez des messages d'erreur informatifs : mentionnez l'opération qui a échoué et le type d'échec. Si vous journalisez, transmettez de quoi consigner utilement l'erreur dans le catch.

// ❌ Avant : sans contexte
throw new Error("échec");

// ✅ Après : opération + type d'échec + cause d'origine
throw new PaymentError(
	`Débit refusé pour la commande ${orderId} : fonds insuffisants`,
	{ cause: gatewayError },
);

Astuce

L'option { cause } du constructeur Error (standard en JavaScript moderne) préserve l'erreur d'origine tout en habillant le message. Vous gardez la trace complète sans perdre l'intention métier.

Définir les classes d'exception selon les besoins de l'appelant

On peut classer les erreurs de mille façons : par source, par type (panne réseau, panne matérielle, erreur de programmation…). Mais lorsqu'on définit des classes d'exception dans une application, la question la plus importante est : comment vont-elles être attrapées ?

Voici un exemple de mauvaise classification. On appelle une bibliothèque tierce qui peut lever trois exceptions distinctes :

// ❌ Avant : on duplique le même traitement trois fois
const port = new ACMEPort(12);
try {
	port.open();
} catch (error) {
	if (error instanceof DeviceResponseException) {
		reportPortError(error);
		logger.log("Exception de réponse du périphérique", error);
	} else if (error instanceof ATM1212UnlockedException) {
		reportPortError(error);
		logger.log("Exception de déverrouillage", error);
	} else if (error instanceof GMXError) {
		reportPortError(error);
		logger.log("Erreur GMX", error);
	}
}

Ce code regorge de duplication, et c'est normal : dans la plupart des cas, le travail de gestion est identique quelle que soit la cause — consigner l'erreur et s'assurer qu'on peut continuer. Puisque le traitement est le même, on peut envelopper l'API tierce pour qu'elle ne renvoie qu'un seul type d'exception :

// ✅ Après : un seul type d'exception, un seul traitement
const port = new LocalPort(12);
try {
	port.open();
} catch (error) {
	reportError(error);
	logger.log((error as Error).message, error);
}

LocalPort est un simple wrapper qui attrape et traduit les exceptions de la bibliothèque :

class LocalPort {
	private readonly innerPort: ACMEPort;

	constructor(portNumber: number) {
		this.innerPort = new ACMEPort(portNumber);
	}

	open(): void {
		try {
			this.innerPort.open();
		} catch (cause) {
			throw new PortDeviceFailure("Échec d'ouverture du port", {
				cause,
			});
		}
	}
}

Envelopper les API tierces est une bonne pratique aux multiples bénéfices :

Sans wrapperAvec wrapper
Dépendance forte au fournisseurCouplage minimal, changement de lib facile
Mocking difficile en testFrontière nette, facile à simuler
On subit le design des exceptions du vendeurOn définit une API avec laquelle on est à l'aise
Chaque appelant connaît N types d'erreurUn seul type d'erreur par zone d'appel

À retenir

Une seule classe d'exception suffit souvent pour une zone de code donnée ; l'information transmise dans le message distingue les cas. N'introduisez plusieurs classes que si vous avez besoin d'attraper l'une tout en laissant passer l'autre.

Définir le flot normal

En suivant les conseils précédents, vous repoussez la détection des erreurs aux frontières du programme : on enveloppe les API externes, on installe un gestionnaire au-dessus de la logique. C'est excellent la plupart du temps. Mais il arrive qu'on ne veuille pas interrompre le flot pour un cas particulier.

Prenons une application de notes de frais. Si des repas sont déclarés, ils s'ajoutent au total ; sinon, l'employé touche une indemnité forfaitaire (per diem). Géré par exception, cela donne :

// ❌ Avant : l'exception encombre la logique métier
try {
	const expenses = expenseReportDAO.getMeals(employee.id);
	total += expenses.getTotal();
} catch (error) {
	if (error instanceof MealExpensesNotFound) {
		total += getMealPerDiem();
	} else {
		throw error;
	}
}

Ici, l'absence de repas n'est pas une erreur : c'est un cas métier parfaitement normal. Le try/catch traite une règle de gestion comme une exception, et la logique s'en trouve alourdie. On aimerait écrire simplement :

// ✅ Après : plus de cas particulier visible
const expenses = expenseReportDAO.getMeals(employee.id);
total += expenses.getTotal();

C'est possible grâce au patron Special Case (Special Case Pattern, Martin Fowler). Le DAO renvoie toujours un objet MealExpenses. En l'absence de repas, il renvoie un objet spécial dont getTotal() retourne l'indemnité forfaitaire :

interface MealExpenses {
	getTotal(): number;
}

class PerDiemMealExpenses implements MealExpenses {
	getTotal(): number {
		return PER_DIEM_DEFAULT; // l'indemnité par défaut
	}
}

Le comportement exceptionnel est encapsulé dans l'objet du cas particulier. Le code client n'a plus à le connaître : il traite tous les cas de façon uniforme.

Ne pas retourner null

Toute discussion sur les erreurs doit mentionner les pratiques qui les invitent. En tête de liste : retourner null. On voit des bases de code où une ligne sur deux est une vérification de nullité :

// ❌ Avant : truffé de gardes anti-null
function registerItem(item: Item): void {
	if (item != null) {
		const registry = persistentStore.getItemRegistry();
		if (registry != null) {
			const existing = registry.getItem(item.id);
			if (existing.getBillingPeriod().hasRetailOwner()) {
				existing.register(item);
			}
		}
	}
}

Ce code paraît peut-être anodin, mais il est mauvais. Pire : il manque déjà une vérification — rien ne garde le retour de registry.getItem(...). Si cet appel renvoie null, on lève une erreur d'accès en pleine profondeur de l'application. Et que faire raisonnablement d'une telle erreur surgie de nulle part ?

Le vrai problème n'est pas qu'il manque une vérification, c'est qu'il y en a trop. Quand vous êtes tenté de retourner null, lancez plutôt une exception ou retournez un objet « cas particulier ». Si c'est une API tierce qui renvoie null, enveloppez-la.

Pour les collections, le remède est immédiat : retournez un tableau vide plutôt que null.

// ❌ Avant : l'appelant doit garder le null
const employees = getEmployees();
if (employees != null) {
	for (const e of employees) {
		totalPay += e.getPay();
	}
}
// ✅ Après : getEmployees ne renvoie jamais null
function getEmployees(): Employee[] {
	if (noEmployeesExist()) {
		return []; // tableau vide, jamais null
	}
	return loadEmployees();
}

const employees = getEmployees();
for (const e of employees) {
	totalPay += e.getPay();
}

La boucle se passe de garde : itérer sur un tableau vide ne fait simplement rien. Moins d'erreurs d'accès, code plus propre.

Astuce

En TypeScript, activez strictNullChecks. Le type Employee[] exclut alors null à la compilation, et quand l'absence est légitime, exprimez-la explicitement avec Employee | undefined — l'intention devient lisible dans la signature.

Ne pas passer null en argument

Retourner null est mauvais ; passer null en argument est pire. Sauf si une API l'attend explicitement, évitez-le systématiquement.

class MetricsCalculator {
	xProjection(p1: Point, p2: Point): number {
		return (p2.x - p1.x) * 1.5;
	}
}

calculator.xProjection(null, new Point(12, 13)); // erreur garantie

Que faire face à un null passé par mégarde ? On pourrait lever une exception dédiée — mais il faudrait alors lui définir un gestionnaire, et que devrait-il faire de bon ? On pourrait poser une assertion — bonne documentation, mais l'erreur d'exécution survient quand même.

Piège courant

Dans la plupart des langages, il n'existe pas de bon moyen de gérer un null passé accidentellement. L'approche rationnelle est donc de l'interdire par défaut. Vous codez alors en sachant qu'un null dans une liste d'arguments est le signe d'un problème — et vous commettez bien moins d'étourderies.

En TypeScript, le système de types fait respecter cette règle gratuitement. Avec strictNullChecks, le compilateur refuse l'appel fautif ci-dessus : null n'est pas assignable à Point. Vous obtenez à la conception ce que Java ne détecte qu'à l'exécution.

// strictNullChecks activé : ces deux lignes ne compilent pas
calculator.xProjection(null, new Point(12, 13));
// Argument of type 'null' is not assignable to parameter of type 'Point'.

À retenir

  • Préférez les exceptions aux codes de retour : la logique cesse de se noyer dans les vérifications, et l'algorithme se lit indépendamment de la gestion d'erreur.
  • Écrivez le try-catch-finally en premier : il définit la portée transactionnelle ; combiné au TDD, il bâtit le bon périmètre dès le départ.
  • Donnez du contexte : message qui nomme l'opération et le type d'échec, plus la cause d'origine via { cause }.
  • Enveloppez les API tierces et exposez un seul type d'exception par zone d'appel : moins de duplication, couplage minimal, tests plus simples.
  • Définissez le flot normal avec le patron Special Case quand un « cas d'erreur » est en réalité une règle métier ordinaire.
  • Ne retournez ni ne passez jamais null : exception, objet spécial ou tableau vide en retour ; arguments non nuls par défaut — et laissez strictNullChecks l'imposer.