Accueil / Articles

Dix modèles TypeScript qui transforment les bugs en temps de exécution en erreurs de compilation

Apprenez dix techniques TypeScript, allant des unions discriminées et de la fonction satisfies aux types marqués et à l’outil infer, qui permettent au compilateur de rejeter les états invalides avant que votre code ne soit déployé.

Éditeur : REACTAPP.TOP12 min de lecture2744 mots

Sources: Top 10 TypeScript Techniques Senior Developers Secretly Use

De nombreuses équipes adoptent TypeScript en ajoutant des annotations au JavaScript existant et s’arrêtent là. Cela permet de détecter les fautes d’orthographe, mais laisse la majeure partie du système de types inutilisée. L’avantage majeur réside dans l’encodage de vos règles en types, ce qui fait que des catégories entières d’erreurs empêchent la compilation sans nécessiter de vérification en temps de exécution. Voici dix techniques permettant de le faire, accompagnées de notes sur les cas où chacune est particulièrement utile.

Modéliser explicitement les états avec des unions discriminées

Une habitude courante consiste à décrire une valeur comme un ensemble de champs optionnels et à compter sur les utilisateurs pour vérifier la combinaison correcte. Rien ci-dessus ne empêche que data et error soient tous deux définis.

// nothing stops you from having both data and error at once
type ApiResponse = {
  data?: User
  error?: string
  loading?: boolean
}

// consumer has no idea what to check
function handleResponse(res: ApiResponse) {
  if (res.data) { /* maybe fine */ }
  // what if data AND error are both set?
}

Une union discriminée énumère plutôt les formes possibles. Chaque variant partage un champ littéral (status), et TypeScript affine le type en fonction de celui-ci : dans la branche success, res.data est garanti, tandis que dans la branche error, c’est res.message.

type ApiResponse =
  | { status: 'loading' }
  | { status: 'success'; data: User }
  | { status: 'error'; message: string }

function handleResponse(res: ApiResponse) {
  switch (res.status) {
    case 'loading':
      return showSpinner()
    case 'success':
      // TypeScript KNOWS res.data exists here
      return renderUser(res.data)
    case 'error':
      // TypeScript KNOWS res.message exists here
      return showError(res.message)
  }
}

Lorsque quelqu’un ajoute ultérieurement un quatrième état, tout switch qui l’ignore peut devenir une erreur de compilation, surtout avec la vérification d’épuisement présentée plus loin.

Vérifier les objets avec satisfies sans les élargir

Déjà disponible depuis TypeScript 4.9, satisfies corrige un problème subtil lié aux annotations. const config: Config = ... vérifie l’objet, mais le type de la variable devient exactement Config, ce qui fait que config1.theme représente l’union complète plutôt que 'dark'. Avec satisfies, l’objet est toujours vérifié (les fautes d’orthographe dans les clés entraînent des erreurs), mais le type inféré avec précision est conservé : config2.theme reste 'dark' et secondary demeure une clé reconnue.

type Config = {
  theme: 'light' | 'dark' | 'system'
  colors: Record<string, string>
}
// with a normal annotation you lose the literal type
const config1: Config = {
  theme: 'dark',
  colors: { primary: '#3d6fe8' }
}
// config1.theme is now 'light' | 'dark' | 'system', not 'dark'

// with satisfies, the shape is validated but types stay narrow
const config2 = {
  theme: 'dark',
  colors: { primary: '#3d6fe8', secondary: '#7c3aed' }
} satisfies Config// config2.theme is still 'dark' — the literal type is preserved
// config2.colors.primary is still 'string' — shape is validated
// if you typo a field name, TypeScript still catches it

Utilisez des annotations lorsque vous souhaitez un type plus large, par exemple pour une valeur que vous allez réaffecter ; utilisez satisfies lorsque vous voulez à la fois une vérification et une inférence précise.

Générer des unions de chaînes avec les types de littéraux de template

TypeScript peut créer des types de chaînes de caractères à l’aide de la syntaxe aux backticks. Une union dans un placeholder se décompose en toutes les combinaisons possibles, ce qui convient aux noms de gestionnaires d’événements, aux propriétés personnalisées CSS et aux endpoints METHOD /path.

type EventName<T extends string> = `on${Capitalize<T>}`
// produces: 'onClick' | 'onChange' | 'onSubmit'

type DomEvents = EventName<'click' | 'change' | 'submit'>
// type safe CSS properties

type CSSVar<T extends string> = `--${T}`
type AppVars = CSSVar<'primary' | 'secondary' | 'bg'>
// produces: '--primary' | '--secondary' | '--bg'
// type safe API routes

type HttpMethod = 'GET' | 'POST' | 'DELETE'
type Route = '/users' | '/orders' | '/products'
type ApiEndpoint = `${HttpMethod} ${Route}`
// produces: 'GET /users' | 'GET /orders' | 'POST /users' | ...

function callApi(endpoint: ApiEndpoint) { /* ... */ }
callApi('GET /users')     // valid
callApi('PATCH /users')   // TypeScript error: not in the type

callApi('PATCH /users') est rejeté car PATCH n’est pas présent dans HttpMethod. Les combinaisons se multiplient rapidement, il faut donc garder les unions d’entrée petites pour éviter de ralentir le compilateur.

Séparer les valeurs similaires à l’aide de types spécifiques

TypeScript compare les types en fonction de leur structure, donc type UserId = string n’est rien d’autre que string, et un ID d’utilisateur peut être transmis sans problème à une fonction qui attend un ID de commande. L’ajout d’une propriété fictive liée à une marque crée une incompatibilité, car cette propriété n’existe jamais en temps de exécution. Les fonctions constructeurs deviennent alors le seul moyen de créer de telles valeurs et un endroit naturel pour la validation.

// without branding — TypeScript cannot tell these apart
type UserId = string
type OrderId = string

function deleteOrder(id: OrderId) { /* ... */ }
const userId: UserId = 'user-42'
deleteOrder(userId) // no error - catastrophic bug

// with branded types - structurally different, enforced
type Brand<T, B> = T & { readonly _brand: B }
type UserId  = Brand<string, 'UserId'>
type OrderId = Brand<string, 'OrderId'>
// constructor functions act as the only way to create these types
const UserId  = (id: string) => id as UserId
const OrderId = (id: string) => id as OrderId
const userId  = UserId('user-42')
const orderId = OrderId('order-99')
deleteOrder(userId)  // TypeScript error: UserId is not assignable to OrderId
deleteOrder(orderId) // works fine

De bons candidats sont des identifiants (UserId, OrderId, ProductId), des valeurs vérifiées telles que EmailAddress, HashedPassword ou ValidatedInput, ainsi que des unités facilement confondues comme les millisecondes par rapport aux secondes ou les dollars par rapport aux centimes. Le fragment déclare chaque alias deux fois à des fins de comparaison ; dans le code réel, conservez uniquement la version officielle.

Extraire des types à partir d’autres types avec infer

Dans un type conditionnel, infer définit une case que TypeScript remplit par correspondance de motifs. C’est ainsi que ReturnType, Parameters et Awaited sont créés, et cela vous permet d’écrire vos propres extracteurs.

// extract the return type of any function
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never

function getUser() { return { id: 1, name: 'Alice' } }
type User = ReturnType<typeof getUser>
// User = { id: number, name: string }
// extract the resolved value of a Promise
type UnwrapPromise<T> = T extends Promise<infer V> ? V : T
type Data = UnwrapPromise<Promise<User>>
// Data = User
// extract the element type of an array
type ArrayElement<T> = T extends (infer E)[] ? E : never
type Names = ArrayElement<string[]>
// Names = string
// extract first argument of any function
type FirstArg<T> = T extends (first: infer A, ...rest: any[]) => any ? A : never
type Arg = FirstArg<typeof getUser>
// Arg = never (no args), try a fn with args:
type A2 = FirstArg<(id: string) => void>
// A2 = string

Lisez chaque définition comme suit : « Si T correspond à cette forme, qu’y a-t-il dans la case infer ? » Si le motif échoue, la branche fausse l’emporte, c’est pourquoi le FirstArg d’une fonction sans argument vaut never. Donnez des noms distincts à vos propres outils plutôt que d’occulter le ReturnType global.

Faire échouer les structures switch incomplètes avec never

Associez des unions à un outil qui ne accepte que never. Une fois que chaque variant dispose d’une case, le type restant dans default est never et l’appel se compile. Si vous ajoutez hexagon sans le gérer, le type restant empêche la compilation immédiatement.

type Shape =
  | { kind: 'circle';    radius: number }
  | { kind: 'rectangle'; width: number; height: number }
  | { kind: 'triangle';  base: number;  height: number }

// this helper makes the default case scream at compile time
function assertNever(value: never): never {
  throw new Error(`Unhandled case: ${JSON.stringify(value)}`)
}
function getArea(shape: Shape): number {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2
    case 'rectangle':
      return shape.width * shape.height
    case 'triangle':
      return 0.5 * shape.base * shape.height
    default:
      return assertNever(shape)
      // if you add 'hexagon' to Shape and forget to handle it,
      // TypeScript throws an error RIGHT HERE at compile time
  }
}

Puisque l’outil lance également une exception, il protège contre des valeurs inattendues provenant de l’extérieur du système de types, comme les réponses d’une API.

Redessiner les types avec des types mappés et le remapping de clés

Les types mappés itèrent sur les clés d’un type pour en créer un nouveau, et une clause as permet de renommer les clés ou de les supprimer en les mappant vers never. Les exemples montrent comment créer des signatures de getters partiellement récursives ainsi qu’un filtre qui ne conserve que les méthodes.

// make all properties optional but keep their types
type DeepPartial<T> = {
  [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K]
}

// prefix all keys with 'get'
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
}
type User = { id: string; name: string; email: string }
type UserGetters = Getters<User>
// { getId: () => string, getName: () => string, getEmail: () => string }
// pick only function properties from a type
type FunctionKeys<T> = {
  [K in keyof T as T[K] extends Function ? K : never]: T[K]
}
type Service = { id: string; save(): void; delete(): void; name: string }
type ServiceMethods = FunctionKeys<Service>
// { save: () => void, delete: () => void }

string & K est nécessaire car les clés peuvent également être des nombres ou des symboles, que Capitalize rejette. Ce simple DeepPartial récursif fonctionne également avec les fonctions et les tableaux, ce qui est acceptable pour des données simples mais peut nécessiter des améliorations.

Apprendre au compilateur à effectuer vos propres vérifications avec des gardes de type

TypeScript restreint le type grâce à typeof, instanceof et aux comparaisons littérales, mais pas à l’intérieur de vos vérifications personnalisées. Un prédicat de type (animal est Cat) indique ce que prouve une fonction booléenne ; une fonction d’assertion (asserts val est NonNullable) lance une erreur en cas d’échec et restreint tous les types après l’appel.

type Cat = { kind: 'cat'; meows: boolean }
type Dog = { kind: 'dog'; barks: boolean }
type Animal = Cat | Dog

// type predicate - return type tells TypeScript what the guard proves
function isCat(animal: Animal): animal is Cat {
  return animal.kind === 'cat'
}
function speak(animal: Animal) {
  if (isCat(animal)) {
    console.log(animal.meows) // TypeScript knows animal is Cat here
  } else {
    console.log(animal.barks) // TypeScript knows animal is Dog here
  }
}
// assertion function - throws if condition not met, narrows after the call
function assertDefined<T>(val: T): asserts val is NonNullable<T> {
  if (val === null || val === undefined) {
    throw new Error('Value is null or undefined')
  }
}
const user = getUser() // returns User | null
assertDefined(user)    // throws at runtime if null
console.log(user.name) // TypeScript knows user is User here, not User | null

Le compilateur fait confiance à ces signatures sans les vérifier, ce qui peut induire en erreur tout appelant en cas d’erreur de conception. Gardez-les simples et testées.

Assurez-vous que les valeurs littérales soient précises en utilisant as const

Par défaut, TypeScript élargit les littéraux : 'production' devient string. L’instruction as const verrouille une expression dans ses types les plus restreints et la rend readonly. Le véritable avantage réside dans la possibilité de dériver des unions à partir de données grâce à typeof et à l’accès par index, ce qui fait de l’array ou de l’objet la seule source de vérité.

// without as const — types are widened
const config = {
  env: 'production',     // inferred as string, not 'production'
  port: 3000,             // inferred as number, not 3000
  features: ['auth', 'search'] // inferred as string[]
}

// with as const - every value is exactly as written
const config = {
  env: 'production',
  port: 3000,
  features: ['auth', 'search']
} as const
// config.env is 'production', not string
// config.port is 3000, not number
// config.features is readonly ['auth', 'search']
// the real power: derive union types from the values
const ROLES = ['admin', 'editor', 'viewer'] as const
type Role = typeof ROLES[number]
// Role = 'admin' | 'editor' | 'viewer'
// add a new role to the array and the type updates automatically
const ROUTES = {
  home:    '/',
  users:   '/users',
  profile: '/profile'
} as const
type AppRoute = typeof ROUTES[keyof typeof ROUTES]
// AppRoute = '/' | '/users' | '/profile'

Le fragment déclare config deux fois à des fins de comparaison. Avec ce schéma, l’ajout d’un rôle ou d’une route met à jour tous les types dépendants sans duplication.

Variation des formes de retour et d’entrée avec les types conditionnels

Les types conditionnels permettent à une fonction générique de retourner des structures différentes en fonction de ses arguments, c’est ainsi que Prisma définit les types de ses résultats include et select. Ici, includePosts: true génère un type contenant posts, tandis que le deuxième exemple rend publishedAt obligatoire uniquement en mode publish.

// function returns different shapes based on a flag
type UserResponse<T extends boolean> =
  T extends true
    ? { user: User; posts: Post[] }   // with posts
    : { user: User }                   // without posts

function fetchUser<T extends boolean>(
  id: string,
  includePosts: T
): Promise<UserResponse<T>> {
  /* implementation */
}
// TypeScript knows exactly what comes back
const result1 = await fetchUser('123', true)
result1.posts   // valid - TypeScript knows posts exists
const result2 = await fetchUser('123', false)
result2.posts   // TypeScript error - posts does not exist on this return type
// another real world example: optional fields based on mode
type CreateMode = 'draft' | 'publish'
type ArticleInput<M extends CreateMode> =
  M extends 'publish'
    ? { title: string; content: string; publishedAt: Date }
    : { title: string; content?: string }
function createArticle<M extends CreateMode>(
  mode: M,
  input: ArticleInput<M>
) { /* ... */ }
// publishedAt is REQUIRED when mode is 'publish'
createArticle('publish', { title: 'Hey', content: '...', publishedAt: new Date() })
// publishedAt is NOT required when mode is 'draft'
createArticle('draft',   { title: 'Hey' })

Dans l’implémentation, TypeScript ne peut généralement pas prouver que la valeur retournée correspond à un type conditionnel non résolu, il faut donc s’attendre à un cast. Pour deux ou trois variantes, les surcharges sont souvent plus claires.

Comment les éléments s’assemblent

Ce ne sont pas de simples astuces isolées. Les unions définissent des états, never garantit que leur gestion soit exhaustive, as const et les littéraux de template génèrent ces unions, tandis que les types mappés et conditionnels en déduisent le reste. Les types deviennent alors une couche de logique plutôt que de la documentation. Le résumé ci-dessous sert de liste de contrôle ; il part du principe que des types tels que Config, User et Animal existent.

// 01. Discriminated unions — model every valid state explicitly
type State = | { status: 'idle' } | { status: 'ok'; data: T } | { status: 'err'; msg: string }
// 02. satisfies - validate shape without widening literal types
const x = { mode: 'dark' } satisfies Config
// 03. Template literal types - build string unions programmatically
type Event<T extends string> = `on${Capitalize<T>}`
// 04. Branded types - prevent mixing structurally identical primitives
type UserId = string & { readonly _brand: 'UserId' }
// 05. infer - extract types from inside other types
type Unwrap<T> = T extends Promise<infer V> ? V : T
// 06. assertNever - exhaustiveness check at compile time
function assertNever(x: never): never { throw new Error('Unhandled case') }
// 07. Mapped types - transform type shapes programmatically
type Getters<T> = { [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K] }
// 08. Type guards - teach TypeScript how to narrow your types
function isCat(a: Animal): a is Cat { return a.kind === 'cat' }
// 09. as const - lock values to literals, derive union types from data
const ROLES = ['admin', 'editor'] as const
type Role = typeof ROLES[number]
// 10. Conditional types - return different shapes based on input
type Res<T extends boolean> = T extends true ? { data: User; posts: Post[] } : { data: User }

Points clés

  • Préférer des structures précises aux champs optionnels ; les unions combinées à assertNever transforment les cas manquants en échecs de compilation.
  • Utiliser satisfies et as const pour valider les valeurs tout en conservant les types littéraux.
  • Donner un nom significatif aux primitives afin que les IDs et les unités ne puissent pas être confondus.
  • Calculer des types à l’aide de infer, de types mappés et conditionnels, mais en veillant à ce qu’ils restent lisible.
  • Considérez les gardes et les conversions personnalisés comme des limites de confiance.
  • Pour une modélisation plus large des règles métier, consultez modéliser des domaines en TypeScript.

    Lectures complémentaires