Débloquez un code robuste et typé en JavaScript et TypeScript avec les gardes de type par filtrage par motif, les unions discriminées et la vérification d'exhaustivité. Prévenez les erreurs d'exécution.
Garde de Type par Filtrage par Motif en JavaScript : Un Guide pour le Filtrage par Motif Sécurisé
Dans le monde du dĂ©veloppement logiciel moderne, la gestion de structures de donnĂ©es complexes est un dĂ©fi quotidien. Que vous traitiez des rĂ©ponses d'API, gĂ©riez l'Ă©tat de l'application ou traitiez des Ă©vĂ©nements utilisateur, vous manipulez souvent des donnĂ©es qui peuvent prendre l'une de plusieurs formes distinctes. L'approche traditionnelle utilisant des instructions if-else imbriquĂ©es ou des switch basiques est souvent verbeuse, sujette aux erreurs et un terreau fertile pour les erreurs d'exĂ©cution. Et si le compilateur pouvait ĂȘtre votre filet de sĂ©curitĂ©, garantissant que vous avez gĂ©rĂ© tous les scĂ©narios possibles ?
C'est lĂ que la puissance du filtrage par motif typĂ© entre en jeu. En empruntant des concepts de langages de programmation fonctionnelle comme F#, OCaml et Rust, et en tirant parti du puissant systĂšme de types de TypeScript, nous pouvons Ă©crire du code qui est non seulement plus expressif et lisible, mais aussi fondamentalement plus sĂ»r. Cet article est une plongĂ©e en profondeur dans la maniĂšre d'obtenir un filtrage par motif robuste et typĂ© dans vos projets JavaScript et TypeScript, Ă©liminant ainsi toute une classe de bogues avant mĂȘme que votre code ne soit exĂ©cutĂ©.
Qu'est-ce que le Filtrage par Motif Exactement ?
à la base, le filtrage par motif est un mécanisme permettant de vérifier une valeur par rapport à une série de motifs. C'est comme une instruction switch surpuissante. Au lieu de simplement vérifier l'égalité avec des valeurs simples (comme des chaßnes de caractÚres ou des nombres), le filtrage par motif vous permet de vérifier la structure ou la forme de vos données.
Imaginez que vous triez du courrier physique. Vous ne vérifiez pas seulement si l'enveloppe est pour "John Doe". Vous pourriez trier en fonction de différents motifs :
- Est-ce une petite enveloppe rectangulaire avec un timbre ? C'est probablement une lettre.
- Est-ce une grande enveloppe matelassée ? C'est probablement un colis.
- A-t-elle une fenĂȘtre en plastique transparent ? C'est presque certainement une facture ou une correspondance officielle.
Le filtrage par motif dans le code fait la mĂȘme chose. Il vous permet d'Ă©crire une logique qui dit : "Si mes donnĂ©es ressemblent Ă ceci, fais cela. Si elles ont cette forme, fais autre chose." Ce style dĂ©claratif rend votre intention beaucoup plus claire qu'un rĂ©seau complexe de vĂ©rifications impĂ©ratives.
Le ProblÚme Classique : L'Instruction `switch` Non Sécurisée
Commençons par un scénario courant en JavaScript. Nous construisons une application graphique et devons calculer l'aire de différentes formes. Chaque forme est un objet avec une propriété `kind` pour nous dire de quoi il s'agit.
// Nos objets de forme
const circle = { kind: 'circle', radius: 5 };
const square = { kind: 'square', sideLength: 10 };
const rectangle = { kind: 'rectangle', width: 4, height: 8 };
function getArea(shape) {
switch (shape.kind) {
case 'circle':
// PROBLĂME : Rien ne nous empĂȘche d'accĂ©der Ă shape.sideLength ici
// et d'obtenir `undefined`. Cela entraĂźnerait NaN.
return Math.PI * shape.radius ** 2;
case 'square':
return shape.sideLength ** 2;
case 'rectangle':
return shape.width * shape.height;
}
}
Ce code JavaScript pur fonctionne, mais il est fragile. Il souffre de deux problĂšmes majeurs :
- Absence de Sécurité des Types : à l'intérieur du cas `'circle'`, l'environnement d'exécution JavaScript n'a aucune idée que l'objet
shapeest garanti d'avoir une propriĂ©tĂ©radiuset non unesideLength. Une simple faute de frappe commeshape.raduisou une supposition incorrecte comme l'accĂšs Ăshape.widthentraĂźneraitundefinedet conduirait Ă des erreurs d'exĂ©cution (commeNaNouTypeError). - Absence de VĂ©rification d'ExhaustivitĂ© : Que se passe-t-il si un nouveau dĂ©veloppeur ajoute une forme
Triangle? S'il oublie de mettre à jour la fonctiongetArea, elle retournera simplementundefinedpour les triangles, et ce bogue pourrait passer inaperçu jusqu'à ce qu'il cause des problÚmes dans une partie complÚtement différente de l'application. C'est un échec silencieux, le type de bogue le plus dangereux.
Solution Partie 1 : La Fondation avec les Unions Discriminées de TypeScript
Pour rĂ©soudre ces problĂšmes, nous avons d'abord besoin d'un moyen de dĂ©crire nos "donnĂ©es qui peuvent ĂȘtre l'une de plusieurs choses" au systĂšme de types. Les Unions DiscriminĂ©es de TypeScript (Ă©galement connues sous le nom d'unions Ă©tiquetĂ©es ou de types de donnĂ©es algĂ©briques) sont l'outil parfait pour cela.
Une union discriminée a trois composantes :
- Un ensemble d'interfaces ou de types distincts qui représentent chaque variante possible.
- Une propriété littérale commune (le discriminant) qui est présente dans toutes les variantes, comme `kind: 'circle'`.
- Un type union qui combine toutes les variantes possibles.
Construire une Union Discriminée `Shape`
Modélisons nos formes en utilisant ce motif :
// 1. Définir les interfaces pour chaque variante
interface Circle {
kind: 'circle'; // Le discriminant
radius: number;
}
interface Square {
kind: 'square'; // Le discriminant
sideLength: number;
}
interface Rectangle {
kind: 'rectangle'; // Le discriminant
width: number;
height: number;
}
// 2. Créer le type union
type Shape = Circle | Square | Rectangle;
Avec ce type `Shape`, nous avons dit Ă TypeScript qu'une variable de type `Shape` doit ĂȘtre un `Circle`, un `Square`, ou un `Rectangle`. Elle ne peut ĂȘtre rien d'autre. Cette structure est le fondement du filtrage par motif typĂ©.
Solution Partie 2 : Gardes de Type et Exhaustivité Pilotée par le Compilateur
Maintenant que nous avons notre union discriminée, l'analyse du flux de contrÎle de TypeScript peut faire sa magie. Lorsque nous utilisons une instruction switch sur la propriété discriminante (`kind`), TypeScript est assez intelligent pour affiner le type à l'intérieur de chaque bloc case. Cela agit comme une garde de type puissante et automatique.
function getArea(shape: Shape): number {
switch (shape.kind) {
case 'circle':
// TypeScript sait que `shape` est un `Circle` ici !
// Accéder à shape.sideLength serait une erreur de compilation.
return Math.PI * shape.radius ** 2;
case 'square':
// TypeScript sait que `shape` est un `Square` ici !
return shape.sideLength ** 2;
case 'rectangle':
// TypeScript sait que `shape` est un `Rectangle` ici !
return shape.width * shape.height;
}
}
Remarquez l'amélioration immédiate : à l'intérieur de `case 'circle'`, le type de `shape` est affiné de `Shape` à `Circle`. Si vous essayez d'accéder à `shape.sideLength`, votre éditeur de code et le compilateur TypeScript le signaleront immédiatement comme une erreur. Vous avez éliminé toute la catégorie des erreurs d'exécution causées par l'accÚs à des propriétés incorrectes !
Atteindre une Vraie Sécurité avec la Vérification d'Exhaustivité
Nous avons résolu le problÚme de la sécurité des types, mais qu'en est-il de l'échec silencieux lorsque nous ajoutons une nouvelle forme ? C'est là que nous appliquons la vérification d'exhaustivité. Nous disons au compilateur : "Tu dois t'assurer que j'ai géré chaque variante possible du type `Shape`."
Nous pouvons y parvenir avec une astuce intelligente utilisant le type `never`. Le type `never` représente une valeur qui ne devrait jamais se produire. Nous ajoutons un cas `default` à notre instruction `switch` qui tente d'assigner la `shape` à une variable de type `never`.
Créons une petite fonction d'assistance pour cela :
function assertNever(value: never): never {
throw new Error(`Unhandled discriminated union member: ${JSON.stringify(value)}`);
}
Maintenant, mettons Ă jour notre fonction `getArea` :
function getArea(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'square':
return shape.sideLength ** 2;
case 'rectangle':
return shape.width * shape.height;
default:
// Si nous avons traité tous les cas, `shape` sera de type `never` ici.
// Sinon, ce sera le type non traité, provoquant une erreur de compilation.
return assertNever(shape);
}
}
Ă ce stade, le code compile parfaitement. Mais voyons maintenant ce qui se passe lorsque nous introduisons une nouvelle forme `Triangle` :
interface Triangle {
kind: 'triangle';
base: number;
height: number;
}
// Ajouter la nouvelle forme Ă l'union
type Shape = Circle | Square | Rectangle | Triangle;
Instantanément, notre fonction `getArea` affichera une erreur de compilation dans le cas `default` :
L'argument de type 'Triangle' n'est pas assignable au paramĂštre de type 'never'.
C'est révolutionnaire ! Le compilateur agit maintenant comme notre filet de sécurité. Il nous force à mettre à jour la fonction `getArea` pour gérer le cas `Triangle`. Le bogue d'exécution silencieux est devenu une erreur de compilation claire et nette. En corrigeant l'erreur, nous garantissons que notre logique est complÚte.
function getArea(shape: Shape): number { // Maintenant avec la correction
switch (shape.kind) {
// ... autres cas
case 'rectangle':
return shape.width * shape.height;
case 'triangle': // Ajouter le nouveau cas
return 0.5 * shape.base * shape.height;
default:
return assertNever(shape);
}
}
Une fois que nous ajoutons le `case 'triangle'`, le cas `default` devient inatteignable pour toute `Shape` valide, le type de `shape` Ă ce point devient `never`, l'erreur disparaĂźt, et notre code est Ă nouveau complet et correct.
Aller au-delà de `switch` : Le Filtrage par Motif Déclaratif avec des BibliothÚques
Bien que l'instruction `switch` avec vérification d'exhaustivité soit incroyablement puissante, sa syntaxe peut encore sembler un peu verbeuse. Le monde de la programmation fonctionnelle a longtemps favorisé une approche plus déclarative et basée sur les expressions pour le filtrage par motif. Heureusement, l'écosystÚme JavaScript offre d'excellentes bibliothÚques qui apportent cette syntaxe élégante à TypeScript, avec une sécurité de type et une exhaustivité complÚtes.
L'une des bibliothĂšques les plus populaires et puissantes pour cela est `ts-pattern`.
Refactorisation avec `ts-pattern`
Voyons à quoi ressemble notre fonction `getArea` réécrite avec `ts-pattern` :
import { match, P } from 'ts-pattern';
function getAreaWithTsPattern(shape: Shape): number {
return match(shape)
.with({ kind: 'circle' }, (c) => Math.PI * c.radius ** 2)
.with({ kind: 'square' }, (s) => s.sideLength ** 2)
.with({ kind: 'rectangle' }, (r) => r.width * r.height)
.with({ kind: 'triangle' }, (t) => 0.5 * t.base * t.height)
.exhaustive(); // Garantit que tous les cas sont traités, tout comme notre vérification `never` !
}
Cette approche offre plusieurs avantages :
- Déclaratif et Expressif : Le code se lit comme une série de rÚgles, indiquant clairement "lorsque l'entrée correspond à ce motif, exécute cette fonction."
- Callbacks Typés : Remarquez que dans `.with({ kind: 'circle' }, (c) => ...)`, le type de `c` est automatiquement et correctement inféré comme `Circle`. Vous bénéficiez d'une sécurité de type complÚte et de l'autocomplétion au sein du callback.
- ExhaustivitĂ© IntĂ©grĂ©e : La mĂ©thode `.exhaustive()` a le mĂȘme objectif que notre assistant `assertNever`. Si vous ajoutez une nouvelle variante Ă l'union `Shape` mais oubliez d'ajouter une clause `.with()` pour celle-ci, `ts-pattern` produira une erreur de compilation.
- C'est une Expression : Le bloc `match` entier est une expression qui retourne une valeur, vous permettant de l'utiliser directement dans des instructions `return` ou des assignations de variables, ce qui peut rendre le code plus propre.
Capacités Avancées de `ts-pattern`
`ts-pattern` va bien au-delĂ du simple filtrage sur le discriminant. Il permet des motifs incroyablement puissants et complexes.
- Filtrage par Prédicat avec `.when()`: Vous pouvez filtrer en fonction d'une condition.
- Filtrage par Joker avec `P.any` et `P.string` etc : Filtrer sur la forme d'un objet sans discriminant.
- Cas par Défaut avec `.otherwise()`: Fournit un moyen propre de gérer tous les cas non explicitement traités, en alternative à `.exhaustive()`.
// Gérer les grands carrés différemment
.with({ kind: 'square' }, (s) => s.sideLength ** 2)
// Devient :
.with({ kind: 'square' }, s => s.sideLength > 100, (s) => /* logique spéciale pour les grands carrés */)
.with({ kind: 'square' }, (s) => s.sideLength ** 2)
// Filtrer tout objet ayant une propriété `radius` numérique
.with({ radius: P.number }, (obj) => `Found a circle-like object with radius ${obj.radius}`)
.with({ kind: 'circle' }, (c) => /* ... */)
.otherwise((shape) => `Unsupported shape: ${shape.kind}`)
Cas d'Usage Pratiques pour un Public Mondial
Ce motif n'est pas seulement pour les formes géométriques. Il est incroyablement utile dans de nombreux scénarios de programmation du monde réel auxquels les développeurs du monde entier sont confrontés quotidiennement.
1. GĂ©rer les Ătats des RequĂȘtes API
Une tĂąche courante est de rĂ©cupĂ©rer des donnĂ©es d'une API. L'Ă©tat de cette requĂȘte peut gĂ©nĂ©ralement ĂȘtre l'une de plusieurs possibilitĂ©s : initial, en chargement, succĂšs, ou erreur. Une union discriminĂ©e est parfaite pour modĂ©liser cela.
interface StateInitial {
status: 'initial';
}
interface StateLoading {
status: 'loading';
}
interface StateSuccess {
status: 'success';
data: T;
}
interface StateError {
status: 'error';
error: Error;
}
type RequestState = StateInitial | StateLoading | StateSuccess | StateError;
// Dans votre composant UI (ex: React, Vue, Svelte, Angular)
function renderComponent(state: RequestState) {
return match(state)
.with({ status: 'initial' }, () => Bienvenue ! Cliquez sur un bouton pour charger votre profil.
)
.with({ status: 'loading' }, () => )
.with({ status: 'success' }, (s) => )
.with({ status: 'error' }, (e) => )
.exhaustive();
}
Avec ce motif, il est impossible d'afficher accidentellement un profil utilisateur lorsque l'état est encore en chargement, ou d'essayer d'accéder à `state.data` lorsque le statut est `error`. Le compilateur garantit la cohérence logique de votre interface utilisateur.
2. Gestion d'Ătat (ex: Redux, Zustand)
Dans la gestion d'état, vous envoyez des actions pour mettre à jour l'état de l'application. Ces actions sont un cas d'utilisation classique pour les unions discriminées.
type CartAction =
| { type: 'ADD_ITEM'; payload: { itemId: string; quantity: number } }
| { type: 'REMOVE_ITEM'; payload: { itemId: string } }
| { type: 'SET_SHIPPING_METHOD'; payload: { method: 'standard' | 'express' } }
| { type: 'APPLY_DISCOUNT_CODE'; payload: { code: string } };
function cartReducer(state: CartState, action: CartAction): CartState {
switch (action.type) {
case 'ADD_ITEM':
// `action.payload` est correctement typé ici !
// ... logique pour ajouter l'article
return { ...state, /* articles mis Ă jour */ };
case 'REMOVE_ITEM':
// ... logique pour supprimer l'article
return { ...state, /* articles mis Ă jour */ };
// ... et ainsi de suite
default:
return assertNever(action);
}
}
Lorsqu'un nouveau type d'action est ajoutĂ© Ă l'union `CartAction`, le `cartReducer` ne compilera pas tant que la nouvelle action ne sera pas gĂ©rĂ©e, vous empĂȘchant d'oublier d'implĂ©menter sa logique.
3. Traitement des ĂvĂ©nements
Qu'il s'agisse de gérer des événements WebSocket provenant d'un serveur ou des événements d'interaction utilisateur dans une application complexe, le filtrage par motif offre un moyen propre et évolutif de router les événements vers les gestionnaires corrects.
type SystemEvent =
| { event: 'userLoggedIn'; userId: string; timestamp: number }
| { event: 'userLoggedOut'; userId: string; timestamp: number }
| { event: 'paymentReceived'; amount: number; currency: string; transactionId: string };
function processEvent(event: SystemEvent) {
match(event)
.with({ event: 'userLoggedIn' }, (e) => console.log(`Utilisateur ${e.userId} connecté.`))
.with({ event: 'paymentReceived', currency: 'USD' }, (e) => handleUsdPayment(e.amount))
.otherwise((e) => console.log(`ĂvĂ©nement non traitĂ© : ${e.event}`));
}
Résumé des Avantages
- SĂ©curitĂ© des Types Ă Toute Ăpreuve : Vous Ă©liminez toute une classe d'erreurs d'exĂ©cution liĂ©es Ă des formes de donnĂ©es incorrectes (ex:
Cannot read properties of undefined). - Clarté et Lisibilité : La nature déclarative du filtrage par motif rend l'intention du programmeur évidente, menant à un code plus facile à lire et à comprendre.
- Complétude Garantie : La vérification d'exhaustivité transforme le compilateur en un partenaire vigilant qui s'assure que vous avez géré chaque variante de données possible.
- Refactorisation sans Effort : L'ajout de nouvelles variantes Ă vos modĂšles de donnĂ©es devient un processus sĂ»r et guidĂ©. Le compilateur pointera chaque endroit de votre base de code qui doit ĂȘtre mis Ă jour.
- Réduction du Code Répétitif : Des bibliothÚques comme `ts-pattern` fournissent une syntaxe concise, puissante et élégante qui est souvent beaucoup plus propre que les instructions de contrÎle de flux traditionnelles.
Conclusion : Adoptez la Confiance Ă la Compilation
Passer des structures de contrĂŽle de flux traditionnelles et non sĂ©curisĂ©es au filtrage par motif typĂ© est un changement de paradigme. Il s'agit de dĂ©placer les vĂ©rifications du temps d'exĂ©cution, oĂč elles se manifestent comme des bogues pour vos utilisateurs, au temps de compilation, oĂč elles apparaissent comme des erreurs utiles pour vous, le dĂ©veloppeur. En combinant les unions discriminĂ©es de TypeScript avec la puissance de la vĂ©rification d'exhaustivitĂ© â que ce soit par une assertion manuelle `never` ou une bibliothĂšque comme `ts-pattern` â vous pouvez construire des applications plus robustes, maintenables et rĂ©silientes au changement.
La prochaine fois que vous vous surprendrez à écrire une longue chaßne de `if-else if-else` ou une instruction `switch` sur une propriété de type chaßne, prenez un moment pour voir si vous pouvez modéliser vos données comme une union discriminée. Faites l'investissement dans la sécurité des types. Votre futur vous, et votre base d'utilisateurs mondiale, vous remercieront pour la stabilité et la fiabilité que cela apporte à votre logiciel.