Six techniques TypeScript qui transforment les types en véritables outils de prévention des erreurs
Découvrez comment les unions étiquetées, les types dérivés et les identifiants marqués permettent à TypeScript de détecter des erreurs réelles en temps de compilation, plutôt qu’en production.
Sources: TypeScript Can Already Do This. Almost Nobody Uses It.
Un projet peut indiquer TypeScript dans package.json, déclarer des interfaces avec une douzaine de champs optionnels, et pourtant planter avec l’erreur « cannot read properties of undefined ». Avoir le compilateur installé ne signifie pas pour autant qu’il fonctionnera automatiquement, et ajouter : string à chaque variable ne suffit pas à lui seul. Les six techniques présentées ci-dessous sont celles que les équipes expérimentées utilisent pour donner au vérificateur de types une réelle importance, chacune accompagnée d’un extrait de code et des raisons qui le justifient.
Vérifier les objets avec satisfies plutôt qu’avec des annotations
La méthode habituelle pour s’assurer qu’un objet de configuration a la bonne structure consiste à l’annoter :
const config: Record<string, number> = {
timeout: 3000,
retries: 3,
};
config.timeout.toFixed(2); // fine
Cela se compile, mais l’annotation remplace tout ce que TypeScript aurait inféré. Le type de la variable est désormais exactement Record<string, number>, de sorte que le compilateur ne sait plus quels sont réellement les clés de l’objet. config.timeout n’est rien d’autre qu’« un nombre associé à une clé de type string », une faute de frappe comme config.timout est acceptée sans problème, et avec noUncheckedIndexedAccess activé, toute propriété lue devient number | undefined.
L’opérateur satisfies, disponible depuis TypeScript 4.9, vérifie que l’objet correspond au type sans remplacer le type inféré :
const config = {
timeout: 3000,
retries: 3,
} satisfies Record<string, number>;
config.timeout; // TypeScript still knows this is literally 3000
On obtient une validation (une valeur de chaîne serait rejetée) tout en conservant la structure précise : TypeScript sait que l’objet contient exactement timeout et retries, et les clés mal orthographiées génèrent des erreurs. Une correction à une affirmation courante, y compris le commentaire dans l’extrait : satisfies seul ne conserve pas timeout en tant que type littéral 3000. Les propriétés d’un objet mutable sont toujours élargies au type number. Si vous avez également besoin des valeurs littérales, combinez les deux opérateurs sous la forme as const satisfies Record<string, number>, ce qui rend l’objet en lecture seule tout en préservant chaque valeur littérale. De nombreux développeurs continuent d’utiliser cette annotation simplement parce qu’elle a été la seule option pendant des années.
Les variantes de modèle en tant qu’unions étiquetées, et non des champs optionnels
Lorsqu’une valeur peut prendre plusieurs formes, il est tentant de rendre chaque champ optionnel :
interface ApiResponse {
data?: User[];
error?: string;
loading?: boolean;
}
Le type permet désormais toutes les combinaisons, y compris data et error ensemble, ou aucun des deux avec loading à la valeur false. La règle selon laquelle un seul état peut s’appliquer à la fois réside dans l’esprit des utilisateurs, et le compilateur ne peut pas la faire respecter.
status ainsi que seuls les champs qui lui appartiennent :
type ApiResponse =
| { status: 'loading' }
| { status: 'success'; data: User[] }
| { status: 'error'; error: string };
function render(response: ApiResponse) {
switch (response.status) {
case 'loading':
return <Spinner />;
case 'success':
return <UserList users={response.data} />; // data exists, guaranteed
case 'error':
return <ErrorBanner message={response.error} />;
}
}
Dans chaque case, TypeScript restreint la valeur de response à la variante correspondante. response.data n’est accessible que dans le branchement success, car les autres variantes ne possèdent pas du tout de propriété data. Toute une catégorie d’erreurs du type « est-ce défini ici ? » disparaît avant même que le code ne s’exécute, et ce même principe s’applique aux actions de réducteur, aux états de formulaire et aux résultats API.
Faire échouer la compilation en cas d’absence de cases avec never
Les unions étiquetées présentent encore un défaut. Supposons qu’au bout de quelques mois quelqu’un ajoute un statut 'forbidden'. Rien ne force à mettre à jour chaque switch concernant ApiResponse, de sorte que la nouvelle case est ignorée silencieusement et se manifeste ensuite comme une erreur en production.
Vérification d’exhaustivité pour combler cette lacune. La branche default attribue la valeur affinée à une variable de type never:
function render(response: ApiResponse) {
switch (response.status) {
case 'loading':
return <Spinner />;
case 'success':
return <UserList users={response.data} />;
case 'error':
return <ErrorBanner message={response.error} />;
default:
const exhaustiveCheck: never = response;
throw new Error(`Unhandled status: ${exhaustiveCheck}`);
}
}
Lorsque chaque variant a été traitée, il ne reste rien pour que response se trouve dans la branche default ; par conséquent, son type est never et l’attribution compile. Si l’on ajoute 'forbidden' sans une case correspondante, response devient { status: 'forbidden' }, ce qui ne peut pas être assigné à never, ce qui provoque l’échec de la compilation et met en évidence la branche oubliée. Le throw vous protège également en temps de exécution en cas d’arrivée inattendue de données. En enveloppant le corps de la branche default entre des accolades, on satisfait les outils de vérification qui signalent les déclarations à l’intérieur des clauses case.
Utilisez unknown aux limites de votre application
any désactive la vérification de type. unknown la maintient activée et vous oblige à prouver ce qu’est une valeur avant de l’utiliser. C’est précisément le comportement souhaité lorsque des données non fiables entrent en jeu : réponses API, résultats de JSON.parse, charges utiles de webhook.
Ici, l’outil fetch renvoie délibérément Promise<unknown>. Lire .name directement provoque une erreur de compilation, et cela n’est autorisé qu’à l’intérieur d’un type guard :
async function fetchUser(id: string): Promise<unknown> {
const res = await fetch(`/api/user/${id}`);
return res.json();
}
const raw = await fetchUser('123');
raw.name; // compile error, unknown has no properties
if (isUser(raw)) {
raw.name; // fine, narrowed to User inside the guard
}
isUser est un garde de type défini par l’utilisateur dont la signature est function isUser(x: unknown): x is User ; son corps doit effectivement vérifier la structure des données en temps de exécution. Avec unknown, le compilateur refuse l’accès aux propriétés tant que les données n’ont pas été vérifiées. Avec any, la validation est optionnelle, et les vérifications facultatives sont souvent omises jusqu’à ce qu’une réponse mal formatée parvienne à un composant qui supposait qu’un champ existait toujours. Pour des charges de travail plus importantes, une bibliothèque de schémas comme Zod peut générer à la fois la vérification en temps de exécution et le type correspondant. D’autres modèles pour ce cas sont abordés dans six alternatives sûres du point de vue du type pour remplacer any.
Dériver les types à partir du code plutôt que de conserver des copies
Si une fonction génère déjà une valeur, vous n’avez pas besoin d’une interface écrite manuellement pour son résultat. TypeScript peut la récupérer :
function createOrderPayload(order: Order) {
return {
id: order.id,
total: order.total,
lineItems: order.items.map(i => ({ sku: i.sku, qty: i.qty })),
};
}
type OrderPayload = ReturnType<typeof createOrderPayload>;
ReturnType<typeof createOrderPayload> lit le type de retour déduit de la fonction. Lorsque vous modifiez la fonction, OrderPayload s’adapte partout où il est utilisé. En revanche, une interface gérée manuellement évolue de manière imprévisible : après quelques refacturations, elle indique qu’un champ existe alors que la fonction cesse de le retourner depuis longtemps, et le compilateur fait confiance à cette interface obsolète.
La même logique s’applique aux composants provenant de bibliothèques que vous ne possédez pas. React.ComponentProps<typeof Button> renvoie exactement les props que le Button d’un tiers accepte, tirés de ses propres définitions de type ; il n’y a donc pas de copie cachée à mettre à jour lorsque la bibliothèque ajoute un prop. Des outils similaires tels que Parameters, Awaited ainsi que les types d’accès indexés (OrderPayload['lineItems'][number]) développent cette approche ; consultez les types utilitaires intégrés de TypeScript pour une vue d’ensemble.
Types primitifs spécifiques aux marques afin d’empêcher le remplacement des IDs
Par défaut, un UserId et un OrderId sont tous deux de simples chaînes de caractères, ce qui permet de passer un ID d’ordre à une fonction attendant un ID d’utilisateur sans problème de compilation. La requête ne renvoie alors rien, et quelqu’un doit perdre du temps à comprendre pourquoi.
Le branding croise l’élément primitif avec une propriété fantôme, ce qui rend les deux incompatibles :
type UserId = string & { readonly __brand: 'UserId' };
type OrderId = string & { readonly __brand: 'OrderId' };
function getUser(id: UserId) { /* ... */ }
const orderId = 'ord_123' as OrderId;
getUser(orderId); // compile error: OrderId is not assignable to UserId
La propriété __brand n’existe jamais en temps de exécution ; elle n’existe que dans le système de types, ce qui suffit à TypeScript pour considérer les deux types comme distincts. Les valeurs sont créées grâce à un petit constructeur ou à une fonction de validation (le cast as OrderId dans l’exemple en est un exemple), et dès lors, toute confusion accidentelle provoque une erreur de compilation sans aucun coût en temps de exécution. Conservez les casts à l’intérieur de ces quelques constructeurs afin que le terme « brand » signifie réellement « cette valeur a été vérifiée ».
En résumé
Rien de tout cela ne nécessite un nouveau framework ou une nouvelle bibliothèque ; il s’agit du TypeScript que vous possédez déjà, utilisé de manière intentionnelle :
satisfiesvalide sans rejeter la forme inférée ; ajoutezas constlorsque les littéraux sont importants.
never : un cas oublié se transforme en erreur de compilation.unknown aux frontières oblige à une validation avant utilisation.Choisissez une technique et appliquez-la dans votre prochaine demande de fusion. Chacune déplace une catégorie d’erreurs du temps de exécution au temps de compilation, ce qui constitue la différence pratique entre avoir TypeScript installé et le voir protéger votre code.