Conventions TypeScript qui assurent à la fois rigueur et flexibilité au code
Opinions pratiques sur TypeScript : mode strict, unknown over any, assertions sparses, unions de chaînes de caractères, type vs interface, propriétés JSDoc, utilitaires et unions discriminées.
Un bon TypeScript devrait réduire les risques d’envoi de code incorrect sans transformer chaque fichier en une procédure rigide. Les conventions présentées ci-dessous privilégient des structures plus simples, plus sûres et plus faciles à maintenir, tout en conservant la flexibilité qui rend TypeScript utile au quotidien.
En résumé
- Préciser les contrats aux limites des modules et des composants.
- Permettre au vérificateur d’inférer les aides privées et les variables locales.
- Rester modeste dans l’utilisation des suppressions d’erreurs et les faire de manière intentionnelle.
- Emprunter des alias existants ou des outils préexistants avant d’en inventer de nouveaux.
- Privilégier les unions littérales et les variantes étiquetées lorsque les états sont alternatifs.
Activer le mode strict
Activez toujours la vérification stricte dans tsconfig.json (manuel) :
{
"compilerOptions": {
"strict": true
}
}
Les options strictes permettent de détecter plus tôt les erreurs courantes et maintiennent un niveau élevé de correction dans une base de code en expansion.
Éviter le type any
Lorsque le type d’une valeur est incertain, préférez unknown à any, car son utilisation exige d’abord un affinage :
// bad
function processValue(value: any) {
value.toLowerCase(); // No error, but might crash at runtime
}
// good
function processValue(value: unknown) {
if (typeof value === 'string') {
value.toLowerCase(); // OK, we've verified it’s a string
}
}
Ignorer les vérifications de type
Lorsqu’un détour est vraiment nécessaire, maintenez cette solution de secours aussi limitée que possible.
Utiliser as any sur une seule expression est souvent préférable à @ts-ignore, car le cast n’affecte que cette expression, tandis que @ts-ignore peut masquer toute la ligne suivante.
// third-party library with incomplete type definitions
import { someExternalLib } from 'external-lib';
const result = (someExternalLib.complexMethod() as any).undocumentedProperty;
Dans du code propriétaire, privilégiez la correction des types, l’ajout de vérifications ou la validation des données externes (par exemple avec Zod) plutôt que de masquer les vérifications du compilateur.
Utilisez @ts-expect-error lorsque vous connaissez des informations que le compilateur ne peut pas démontrer :
- Contrairement à
@ts-ignore, la compilation échoue si l’erreur disparaît de manière inattendue. - Cela permet de garder honnêtes les suppressions intentionnelles lors des revues.
<div
style={{
// @ts-expect-error — vars are valid CSS properties
'--size': `${dimensions.size}px`,
}}
/>
Utilisez les assertions de type avec modération
as permet d’ignorer le vérificateur. Préférez la confirmation en temps de exécution lorsque c’est possible :
// bad
const myCanvas = document.getElementById('canvas') as HTMLCanvasElement;
// good
const myCanvas = document.getElementById('canvas');
if (myCanvas instanceof HTMLCanvasElement) {
const ctx = myCanvas.getContext('2d');
}
Préférez les gardes de type aux assertions
Les gardes ne restreignent les possibilités qu’après un contrôle réussi en temps de exécution — ce qui offre une sécurité accrue par rapport à un simple as :
type User = {
id: string;
name: string;
};
function isUser(value: unknown): value is User {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
typeof value.id === 'string' &&
'name' in value &&
typeof value.name === 'string'
);
}
function greet(value: unknown) {
if (isUser(value)) {
// value is User here - no assertion needed
console.log(`Hello, ${value.name}`);
}
}
Utilisez une assertion uniquement lorsque un contrôle en temps de exécution est impossible ou inutile — généralement lorsque des propriétés provenant de tiers ne correspondent pas au schéma global du formulaire :
<OTPInput
// Our form pattern expects a traditional change handler but this third-party
// component provides the text value as its only callback argument. We need
// to manually create an event-like object so validation works as expected
// for consumers.
onChange={(value) => {
props.onChange?.({
target: { value },
} as ChangeEvent<HTMLInputElement>);
}}
/>
Préférez les unions de littéraux de chaîne aux enums
Pour le code d’application, les unions de littéraux de chaîne sont généralement préférables aux enums : pas d’objet en temps de exécution, des émissions plus simples, et une meilleure compatibilité avec la sérialisation :
// bad
enum Environments {
DEV = 'dev',
TESTING = 'test',
STAGING = 'staging',
PRODUCTION = 'prod',
}
// good
type Environments = 'dev' | 'test' | 'staging' | 'prod';
Conservez les enums lorsque l’existence d’un objet en temps de exécution est requise, que une API existante les utilise déjà, ou que les normes de l’équipe l’exigent.
Déduisez les unions à partir des valeurs
Lorsque des valeurs existent déjà en temps de exécution, dérivez l’union à partir d’une source unique de vérité :
const ENVIRONMENT_META = {
dev: {},
test: {},
staging: {},
prod: {},
};
type Environments = keyof typeof ENVIRONMENT_META;
// ^? 'dev' | 'test' | 'staging' | 'prod'
Forme tableau :
const ENVIRONMENTS = ['dev', 'test', 'staging', 'prod'] as const;
type Environments = (typeof ENVIRONMENTS)[number];
// ^? 'dev' | 'test' | 'staging' | 'prod'
Une assertion const est requise afin que le tableau reste une suite de littéraux plutôt qu’un string[].
Préférez type à interface
Pour la plupart des codes d’application, type couvre les structures d’objets ainsi que les unions, intersections, types mappés et conditionnels. De plus, il ne peut pas être rouvert pour une fusion accidentelle :
// when required
interface Person {
name: string;
email: string;
}
// preferred
type Person = {
name: string;
email: string;
};
En optant pour une forme unique, on réduit les changements inutiles entre fonctionnalités superposées.
Utilisez l’interface uniquement lorsque ses fonctionnalités sont importantes
Les bibliothèques publiques peuvent exporter délibérément des interface afin que les utilisateurs puissent y ajouter du contenu :
// library
export interface Theme {
colors: Record<string, string>;
}
// consumer: extend the interface without modifying the original source
declare module 'my-library' {
interface Theme {
spacing: Record<string, number>;
}
}
Dokumentez les propriétés des composants
Utilisez JSDoc (/** ... */) pour les propriétés publiques ou peu évidentes — leur fonction, leur comportement, leurs contraintes — tant que le contexte est frais :
type SearchFieldProps = {
/**
* A unique identifier for the field. Consider [useId](https://react.dev/reference/react/useId) to generate a unique ID.
*/
id?: string;
/**
* The label of the field. For "visually hidden" labels, use the `aria-label`
* attribute.
*/
label?: string;
/**
* @deprecated Use `label` instead.
*/
title?: string;
/**
* Handler that is called when the field is submitted.
*/
onSubmit?: (value: string) => void;
/**
* Handler that is called when the clear button, or <Escape> key, is pressed.
*/
onClear?: () => void;
} & AriaLabellingProps;
Exceptions à la documentation
Omettez les documents détaillés dans les cas suivants :
- Le composant est utilisé une seule fois et il est peu probable qu’il soit réutilisé
- C’est un enfant privé d’un parent documenté dont le contrat explique déjà son comportement
Préférez la clarté en cas d’incertitude, et restez cohérent.
Réutiliser des types existants
Rendez les API publiques explicites ; laissez l’inférence gérer les aspects internes. Avant d’inventer une nouvelle abstraction, cherchez un type, une propriété ou une utilité existante à réutiliser afin que le code associé reste cohérent.
Accès aux propriétés
L’accès par index récupère le type d’une propriété à partir d’un alias existant :
import type { ThingProps } from '../thing';
type SpecialThingProps = {
...
label?: ThingProps['label'];
type?: ThingProps['type'];
};
function SpecialThing(props: SpecialThingProps) {}
Préférez les intersections pour la composition
Combinez des types sans dupliquer les champs (et leurs documents) :
type Person = { name: string; email: string };
type User = Person & { id: string };
// ^? { name: string; email: string; id: string }
import type { ThingProps } from '../thing';
type SpecialThingProps = {
...
} & Pick<ThingProps, 'label' | 'type'>;
function SpecialThing(props: SpecialThingProps) {}
Détails de mise en œuvre d’Infer
L’inference maintient les types internes synchronisés avec les valeurs :
function initContext() {
return {
name: 'Jane Doe',
email: 'jane.doe@example.com',
};
}
type Context = ReturnType<typeof initContext>;
// ^? { name: string; email: string }
ComponentProps dérive les props d’un composant React lorsque la bibliothèque ne les exporte pas :
import type { ComponentProps } from 'react';
import { RouterProvider } from 'react-aria-components';
type RouterProviderProps = ComponentProps<typeof RouterProvider>;
// {
// navigate: (path: Href, routerOptions: RouterOptions | undefined) => void;
// useHref?: (href: Href) => string;
// children: ReactNode;
// }
Parameters génère une tuple d’arguments que vous pouvez indexer :
// library code
declare function someLibFn(
value: number,
options: {
minimumFractionDigits: number;
maximumFractionDigits: number;
}
): void;
// consumer code
import { someLibFn } from 'some-lib';
type LibFnValue = Parameters<typeof someLibFn>[0];
// ^? number
type LibFnOptions = Parameters<typeof someLibFn>[1];
// ^? { minimumFractionDigits: number, maximumFractionDigits: number }
Utiliser les types utilitaires intégrés
Les types utilitaires couvrent des transformations courantes et restent faciles à comprendre pour les nouveaux membres de l’équipe :
type User = {
id: string;
name: string;
email: string;
};
type UserPatch = Partial<User>;
type UserDetails = Omit<User, 'id'>;
type UserIdentity = Pick<User, 'id' | 'email'>;
Écrivez un type utilitaire personnalisé uniquement lorsque la transformation correspond à un concept réel du domaine, et non simplement pour économiser des touches.
Utiliser les unions discriminées
Les unions étiquetées modélisent des alternatives afin que la vérification d’exhaustivité fonctionne :
type Actions =
| { type: 'login'; username: string }
| { type: 'logout'; reason: 'session-timeout' | undefined }
| { type: 'update'; id: string; data: Record<string, unknown> }
Un champ littéral partagé (type ou kind) permet à TypeScript de restreindre chaque branche en toute sécurité.
Résumé
La cohérence l’emporte sur toute règle isolée. Convenez des conventions, documentez les exceptions et optimisez pour le lecteur suivant :
- Contrats de bord clairs
- Éléments internes déduits
- Suppressions minimales
- Alias et outils partagés
- Unions étiquetées où les combinaisons illégales doivent disparaître
Lorsqu’elles sont appliquées ensemble, ces habitudes permettent à TypeScript de rester un outil de conception plutôt qu’un fardeau : les APIs publiques restent transparentes, les éléments internes restent DRY, et les conversions any ou as restent rares, constituant des exceptions à examiner plutôt qu’une solution par défaut.
Ces références se trouvent dans le manuel officiel de TypeScript ainsi que dans les documents JSDoc pour ceux qui souhaitent la formulation canonique.
Considérez cette liste comme un accord vivant au sein de l’équipe plutôt que comme une doctrine figée à jamais. D’accord.