Un guide complet du type 'never'. Apprenez à exploiter la vérification exhaustive pour un code robuste et sans bogues et comprenez sa relation avec la gestion traditionnelle des erreurs.
Le type Never : Passer des erreurs d'exécution aux garanties de compilation
Dans le monde du développement logiciel, nous consacrons beaucoup de temps et d'efforts à prévenir, trouver et corriger les bogues. Certains des bogues les plus insidieux sont ceux qui émergent silencieusement. Ils ne plantent pas l'application immédiatement ; au lieu de cela, ils se cachent dans des cas limites non gérés, attendant qu'une donnée spécifique ou une action de l'utilisateur déclenche un comportement incorrect. Une source courante de tels bogues est un simple oubli : un développeur ajoute une nouvelle option à un ensemble de choix mais oublie de mettre à jour tous les endroits du code qui doivent la gérer.
Considérez une instruction `switch` traitant différents types de notifications utilisateur. Lorsqu'un nouveau type de notification, disons 'POLL_RESULT', est ajouté, que se passe-t-il si nous oublions d'ajouter un bloc `case` correspondant dans notre fonction de rendu de notification ? Dans de nombreux langages, le code va simplement passer à travers, ne rien faire et échouer silencieusement. L'utilisateur ne voit jamais le résultat du sondage, et nous pouvons ne pas découvrir le bogue avant des semaines.
Et si le compilateur pouvait empêcher cela ? Et si nos propres outils pouvaient nous forcer à traiter toutes les possibilités, transformant une erreur de logique d'exécution potentielle en une erreur de type de compilation ? C'est précisément la puissance offerte par le type 'never', un concept que l'on retrouve dans les langages modernes à typage statique. C'est un mécanisme pour appliquer la vérification exhaustive, fournissant une garantie robuste, au moment de la compilation, que tous les cas sont gérés. Cet article explore le type `never`, oppose son rôle à la gestion traditionnelle des erreurs et démontre comment l'utiliser pour construire des systèmes logiciels plus résilients et maintenables.
Qu'est-ce que le type 'Never' exactement ?
À première vue, le type `never` peut sembler ésotérique ou purement académique. Cependant, ses implications pratiques sont profondes. Pour le comprendre, nous devons saisir ses deux principales caractéristiques.
Un type pour l'impossible
Le type `never` représente une valeur qui ne peut jamais se produire. C'est un type qui ne contient aucune valeur possible. Cela semble abstrait, mais il est utilisé pour signifier deux scénarios principaux :
- Une fonction qui ne retourne jamais : Cela ne signifie pas une fonction qui ne retourne rien (c'est `void`). Cela signifie une fonction qui n'atteint jamais son point final. Elle peut lever une erreur, ou elle peut entrer dans une boucle infinie. L'essentiel est que le flux d'exécution normal est interrompu de façon permanente.
- Une variable dans un état impossible : Par déduction logique (un processus appelé rétrécissement de type), le compilateur peut déterminer qu'une variable ne peut pas contenir de valeur dans un bloc de code spécifique. Dans cette situation, le type de la variable est effectivement `never`.
Dans la théorie des types, `never` est connu comme le type bottom (souvent noté par ⊥). Être le type bottom signifie qu'il est un sous-type de tous les autres types. Cela a du sens : puisqu'une valeur de type `never` ne peut jamais exister, elle peut être assignée à une variable de type `string`, `number` ou `User` sans violer la sécurité des types, car cette ligne de code est prouvablement inaccessible.
Distinction cruciale : `never` vs. `void`
Un point de confusion courant est la différence entre `never` et `void`. La distinction est essentielle :
void: Représente l'absence d'une valeur de retour utilisable. La fonction s'exécute jusqu'à la fin et retourne, mais sa valeur de retour n'est pas destinée à être utilisée. Pensez à une fonction qui se contente d'enregistrer dans la console.never: Représente l'impossibilité de retourner. La fonction garantit qu'elle ne terminera pas son chemin d'exécution normalement.
Prenons un exemple en TypeScript :
// Cette fonction retourne 'void'. Elle se termine avec succès.
function logMessage(message: string): void {
console.log(message);
// Retourne implicitement 'undefined'
}
// Cette fonction retourne 'never'. Elle ne se termine jamais.
function throwError(message: string): never {
throw new Error(message);
}
// Cette fonction retourne également 'never' en raison d'une boucle infinie.
function processTasks(): never {
while (true) {
// ... traiter une tâche d'une file d'attente
}
}
Comprendre cette différence est la première étape pour débloquer la puissance pratique de `never`.
Le cas d'utilisation principal : la vérification exhaustive
L'application la plus impactante du type `never` est de faire respecter les vérifications exhaustives au moment de la compilation. Il nous permet de construire un filet de sécurité qui garantit que nous avons géré chaque variante d'un type de données donné.
Le problème : L'instruction `switch` fragile
Modélisons un ensemble de formes géométriques en utilisant une union discriminée. Il s'agit d'un modèle puissant où vous avez une propriété commune (le 'discriminant', comme `kind`) qui vous indique quelle variante du type vous traitez.
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'square'; sideLength: number };
function getArea(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'square':
return shape.sideLength ** 2;
}
// Que se passe-t-il si nous obtenons une forme que nous ne reconnaissons pas ?
// Cette fonction retournerait implicitement 'undefined', un bogue probable !
}
Ce code fonctionne pour l'instant. Mais que se passe-t-il lorsque notre application évolue ? Un collègue ajoute une nouvelle forme :
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'square'; sideLength: number }
| { kind: 'rectangle'; width: number; height: number }; // Nouvelle forme ajoutée !
La fonction `getArea` est maintenant incomplète. Si elle reçoit un `rectangle`, l'instruction `switch` n'aura aucun cas correspondant, la fonction se terminera, et en JavaScript/TypeScript, elle retournera `undefined`. Le code appelant attendait un `number` mais reçoit `undefined`, ce qui conduit à une erreur `NaN` ou à d'autres bogues subtils en aval. Le compilateur ne nous a donné aucun avertissement.
La solution : Le type `never` comme sauvegarde
Nous pouvons corriger cela en utilisant le type `never` dans le cas `default` de notre instruction `switch`. Ce simple ajout transforme le compilateur en notre partenaire vigilant.
function getAreaWithExhaustiveCheck(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'square':
return shape.sideLength ** 2;
// Qu'en est-il de 'rectangle' ? Nous l'avons oublié.
default:
// C'est là que la magie opère.
const _exhaustiveCheck: never = shape;
// La ligne ci-dessus va maintenant provoquer une erreur de compilation !
// Le type 'Rectangle' n'est pas assignable au type 'never'.
return _exhaustiveCheck;
}
}
Décomposons pourquoi cela fonctionne :
- Rétrécissement de type : À l'intérieur de chaque bloc `case`, le compilateur TypeScript est assez intelligent pour rétrécir le type de la variable `shape`. Dans `case 'circle'`, le compilateur sait que `shape` est `{ kind: 'circle'; radius: number }`.
- Le bloc `default` : Lorsque le code atteint le bloc `default`, le compilateur déduit quels types `shape` pourrait possiblement être. Il soustrait tous les cas gérés de l'union `Shape` originale.
- Le scénario d'erreur : Dans notre exemple mis à jour, nous avons géré `'circle'` et `'square'`. Par conséquent, à l'intérieur du bloc `default`, le compilateur sait que `shape` doit être `{ kind: 'rectangle'; ... }`. Notre code essaie ensuite d'assigner cet objet `rectangle` à la variable `_exhaustiveCheck`, qui a le type `never`. Cette assignation échoue avec une erreur de type claire : `Le type 'Rectangle' n'est pas assignable au type 'never'`. Le bogue est détecté avant même que le code ne soit exécuté !
- Le scénario de succès : Si nous ajoutons le `case` pour `'rectangle'`, alors dans le bloc `default`, le compilateur aura épuisé toutes les possibilités. Le type de `shape` sera rétréci à `never` (il ne peut pas être un cercle, un carré ou un rectangle, c'est donc un type impossible). Assigner une valeur de type `never` à une variable de type `never` est parfaitement valide. Le code se compile sans erreur.
Ce modèle, souvent appelé "astuce d'exhaustivité", charge efficacement le compilateur de faire respecter l'exhaustivité. Il transforme une convention d'exécution fragile en une garantie de compilation solide comme le roc.
Vérification exhaustive vs. Gestion traditionnelle des erreurs
Il est tentant de considérer la vérification exhaustive comme un remplacement de la gestion des erreurs, mais c'est une idée fausse. Ce sont des outils complémentaires conçus pour résoudre différentes classes de problèmes. La principale différence réside dans ce qu'ils sont conçus pour gérer : les états prévisibles et connus par rapport aux événements imprévisibles et exceptionnels.
Définir les concepts
-
La gestion des erreurs est une stratégie d'exécution pour gérer les situations exceptionnelles et imprévisibles qui sont souvent hors du contrôle du programme. Elle traite des échecs qui peuvent et se produisent pendant l'exécution.
- Exemples : Échec d'une requête réseau, fichier introuvable sur le disque, entrée utilisateur non valide, expiration de la connexion à la base de données.
- Outils : blocs `try...catch`, `Promise.reject()`, retour de codes d'erreur ou `null`, types `Result` (comme on le voit dans les langages comme Rust).
-
La vérification exhaustive est une stratégie de compilation pour garantir que tous les chemins logiques ou états de données connus et valides sont explicitement gérés dans la logique du programme. Il s'agit de s'assurer que votre code est complet.
- Exemples : Gestion de toutes les variantes d'une enum, traitement de tous les types dans une union discriminée, gestion de tous les états d'une machine à états finis.
- Outils : Le type `never`, l'exhaustivité `switch` ou `match` appliquée par le langage (comme on le voit dans Swift et Rust).
Le principe directeur : Connaissances vs. Inconnues
Une façon simple de décider quelle approche utiliser est de vous interroger sur la nature du problème :
- S'agit-il d'un ensemble de possibilités que j'ai définies et que je contrôle dans mon code ? Utilisez la vérification exhaustive. Ce sont vos "connaissances". Votre union `Shape` est un parfait exemple ; vous définissez toutes les formes possibles.
- S'agit-il d'un événement provenant d'un système externe, d'un utilisateur ou de l'environnement, où l'échec est possible et où l'entrée exacte est imprévisible ? Utilisez la gestion des erreurs. Ce sont vos "inconnues". Vous ne pouvez pas utiliser le système de types pour prouver qu'un réseau sera toujours disponible.
Analyse de scénarios : Quand utiliser quoi
Scénario 1 : Analyse de la réponse de l'API (Gestion des erreurs)
Imaginez que vous récupérez des données utilisateur à partir d'une API tierce. La documentation de l'API indique qu'elle retournera un objet JSON avec un champ `status`. Vous ne pouvez pas faire confiance à cela au moment de la compilation. Le réseau pourrait être en panne, l'API pourrait être obsolète et retourner une erreur 500, ou elle pourrait retourner une chaîne JSON mal formée. C'est le domaine de la gestion des erreurs.
async function fetchUser(userId: string): Promise<User> {
try {
const response = await fetch(`https://api.example.com/users/${userId}`);
if (!response.ok) {
// Gérer les erreurs HTTP (par exemple, 404, 500)
throw new Error(`Erreur API : ${response.status}`);
}
const data = await response.json();
// Ici, vous ajouteriez également une validation d'exécution de la structure de données
return data as User;
} catch (error) {
// Gérer les erreurs réseau, les erreurs d'analyse JSON, etc.
console.error("Échec de la récupération de l'utilisateur :", error);
throw error; // Relancer ou gérer avec élégance
}
}
Utiliser `never` ici serait inapproprié car les possibilités d'échec sont infinies et externes à notre système de types.
Scénario 2 : Rendu d'un état de composant UI (Vérification exhaustive)
Maintenant, disons que votre composant UI peut être dans l'un des plusieurs états bien définis. Vous contrôlez entièrement ces états dans votre code d'application. C'est un candidat parfait pour une union discriminée et une vérification exhaustive.
type ComponentState =
| { status: 'loading' }
| { status: 'success'; data: string[] }
| { status: 'error'; message: string };
function renderComponent(state: ComponentState): string { // Retourne une chaîne HTML
switch (state.status) {
case 'loading':
return `<div>Chargement...</div>`;
case 'success':
return `<ul>${state.data.map(item => `<li>${item}</li>`).join('')}</ul>`;
case 'error':
return `<div class="error">Erreur : ${state.message}</div>`;
default:
// Si nous ajoutons plus tard un statut 'submitting', cette ligne nous protégera !
const _exhaustiveCheck: never = state;
throw new Error(`État non géré : ${_exhaustiveCheck}`);
}
}
Si un développeur ajoute un nouvel état, `{ status: 'idle' }`, le compilateur signalera immédiatement `renderComponent` comme incomplet, empêchant un bogue d'UI où le composant s'affiche comme un espace vide.
La synergie : combiner les deux approches pour des systèmes robustes
Les systèmes les plus résilients ne choisissent pas l'un plutôt que l'autre ; ils utilisent les deux de concert. La gestion des erreurs gère le monde extérieur chaotique, tandis que la vérification exhaustive garantit que la logique interne est saine et complète. La sortie d'une limite de gestion des erreurs devient souvent l'entrée d'un système qui repose sur la vérification exhaustive.
Affinons notre exemple de récupération d'API. La fonction peut gérer les erreurs réseau imprévisibles, mais une fois qu'elle réussit ou échoue de manière contrôlée, elle retourne un résultat prévisible et bien typé que le reste de notre application peut traiter en toute confiance.
// 1. Définir un résultat prévisible et bien typé pour notre logique interne.
type FetchResult<T> =
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
// 2. La fonction utilise maintenant la gestion des erreurs pour produire un résultat qui peut être vérifié de manière exhaustive.
async function fetchUserData(userId: string): Promise<FetchResult<User>> {
try {
const response = await fetch(`https://api.example.com/users/${userId}`);
if (!response.ok) {
throw new Error(`L'API a retourné le statut ${response.status}`);
}
const data = await response.json();
// Ajouter une validation d'exécution ici (par exemple, avec Zod ou io-ts)
return { status: 'success', data: data as User };
} catch (error) {
// Nous attrapons TOUTE erreur potentielle et l'enveloppons dans notre structure connue.
return { status: 'error', error: error instanceof Error ? error : new Error('Une erreur inconnue s'est produite') };
}
}
// 3. Le code appelant peut maintenant utiliser la vérification exhaustive pour une logique propre et sûre.
async function displayUser(userId: string) {
const result = await fetchUserData(userId);
switch (result.status) {
case 'success':
console.log(`Nom d'utilisateur : ${result.data.name}`);
break;
case 'error':
console.error(`Échec de l'affichage de l'utilisateur : ${result.error.message}`);
break;
default:
const _exhaustiveCheck: never = result;
// Cela garantit que si nous ajoutons un statut 'loading' Ă FetchResult,
// ce bloc de code ne pourra pas être compilé tant que nous ne l'aurons pas géré.
return _exhaustiveCheck;
}
}
Ce modèle combiné est incroyablement puissant. La fonction `fetchUserData` agit comme une limite, traduisant le monde imprévisible des requêtes réseau en une union discriminée prévisible. Le reste de l'application peut alors opérer sur cette structure de données propre avec le filet de sécurité complet des vérifications d'exhaustivité au moment de la compilation.
Une perspective globale : `never` dans d'autres langages
Le concept d'un type bottom et d'une exhaustivité au moment de la compilation n'est pas propre à TypeScript. C'est une caractéristique de nombreux langages modernes axés sur la sécurité. Voir comment il est mis en œuvre ailleurs renforce son importance fondamentale dans l'ingénierie logicielle.
- Rust : Rust a un type `!`, appelé le "type never". C'est le type de retour des fonctions qui "divergent", comme la macro `panic!()`, qui termine le thread d'exécution actuel. La puissante expression `match` de Rust (sa version de `switch`) applique l'exhaustivité par défaut. Si vous `match` sur une `enum` et que vous ne couvrez pas toutes les variantes, le code ne sera pas compilé. Vous n'avez pas besoin de l'astuce manuelle `never` car le langage offre cette sécurité dès le départ.
- Swift : Swift a une enum vide appelée `Never`. Elle est utilisée pour indiquer qu'une fonction ou une méthode ne retournera jamais, soit en lançant une erreur, soit en ne se terminant pas. Comme Rust, les instructions `switch` de Swift doivent être exhaustives par défaut, ce qui assure la sécurité au moment de la compilation lorsque vous travaillez avec des enums.
- Kotlin : Kotlin a le type `Nothing`, qui est le type bottom de son système de types. Il est utilisé pour indiquer qu'une fonction ne retourne jamais, comme la fonction `TODO()` de la bibliothèque standard, qui lève toujours une erreur. L'expression `when` de Kotlin (son équivalent `switch`) peut également être utilisée pour les vérifications exhaustives, et le compilateur émettra un avertissement ou une erreur si elle n'est pas exhaustive lorsqu'elle est utilisée comme une expression.
- Python (avec indications de type) : Le module `typing` de Python inclut `NoReturn`, qui peut être utilisé pour annoter les fonctions qui ne retournent jamais. Bien que le système de types de Python soit progressif et pas aussi strict que ceux de Rust ou Swift, ces annotations fournissent des informations précieuses pour les outils d'analyse statique comme Mypy, qui peuvent alors effectuer des vérifications plus approfondies.
Le fil conducteur à travers ces divers écosystèmes est la reconnaissance que rendre les états impossibles non représentables au niveau du type est un moyen puissant d'éliminer des classes entières de bogues.
Informations exploitables et meilleures pratiques
Pour intégrer ce concept puissant dans votre travail quotidien, tenez compte des pratiques suivantes :
- Adoptez les unions discriminées : Modélisez activement vos données avec des unions discriminées (également appelées unions taguées ou types somme) chaque fois que vous avez un type qui peut être l'une des plusieurs variantes distinctes. C'est la base sur laquelle repose la vérification exhaustive. Modélisez les résultats d'API, les états des composants et les événements de cette façon.
- Rendez les états illégaux non représentables : C'est un principe fondamental de la conception basée sur les types. Si un utilisateur ne peut pas être à la fois un administrateur et un invité, votre système de types doit refléter cela. Utilisez des unions (`A | B`) au lieu de plusieurs drapeaux booléens optionnels (`isAdmin?: boolean; isGuest?: boolean;`). Le type `never` est l'outil ultime pour prouver qu'un état est non représentable.
-
Créez une fonction d'assistance réutilisable : Le cas `default` peut être rendu plus propre avec une simple fonction d'assistance. Cela fournit également une erreur plus descriptive si le code est jamais atteint au moment de l'exécution (ce qui devrait être impossible).
function assertNever(value: never): never { throw new Error(`Membre d'union discriminée non géré : ${JSON.stringify(value)}`); } // Utilisation : default: assertNever(shape); // Plus propre et fournit un meilleur message d'erreur d'exécution. - Écoutez votre compilateur : Traitez une erreur d'exhaustivité non pas comme une nuisance, mais comme un cadeau. Le compilateur agit comme un réviseur de code automatisé et diligent qui a trouvé une faille logique dans votre programme. Remerciez-le et corrigez le code.
Conclusion : Le gardien silencieux de votre codebase
Le type `never` est bien plus qu'une curiosité théorique ; c'est un outil pragmatique et puissant pour construire des logiciels robustes, auto-documentés et maintenables. En l'exploitant pour la vérification exhaustive, nous changeons fondamentalement notre façon d'aborder la correction. Nous transférons la charge de garantir la complétude logique de la mémoire humaine faillible et des tests d'exécution au monde infaillible et automatisé de l'analyse des types au moment de la compilation.
Bien que la gestion traditionnelle des erreurs reste essentielle pour gérer la nature imprévisible des systèmes externes, la vérification exhaustive fournit une garantie complémentaire pour la logique interne et connue de nos applications. Ensemble, ils forment une défense multicouche contre les bogues, créant des systèmes qui sont non seulement moins sujets aux défaillances, mais aussi plus faciles à comprendre et plus sûrs à refactoriser.
La prochaine fois que vous vous retrouverez à écrire une instruction `switch` ou une longue chaîne `if-else-if` sur un ensemble de possibilités connues, faites une pause et demandez-vous : le type `never` peut-il servir de gardien silencieux pour ce code ? Ce faisant, vous écrirez du code qui n'est pas seulement correct aujourd'hui, mais qui est également protégé contre les oublis de demain.