Aller au contenu
·22 min de lecture

Types avancés en TypeScript : la magie des types conditionnels, mappés et template literal

Plongée en profondeur dans les fonctionnalités les plus puissantes du système de types TypeScript — types conditionnels, types mappés, mot-clé infer et template literal types. Des patterns concrets que tu utiliseras vraiment.

Partager:X / TwitterLinkedIn

La plupart des développeurs TypeScript plafonnent au même endroit. Ils connaissent les interfaces, les génériques, les types union. Ils savent typer un composant React. Ils arrivent à faire disparaître les soulignements rouges. Mais ils traitent le système de types comme un mal nécessaire — quelque chose qui existe pour prévenir les bugs, pas quelque chose qui les aide activement à concevoir de meilleurs logiciels.

Je suis resté à ce plateau pendant environ deux ans. Ce qui m'en a sorti, c'est de réaliser que le système de types de TypeScript est un langage de programmation en soi. Il a des conditions, des boucles, du pattern matching, de la récursion et de la manipulation de chaînes. Une fois que tu as intériorisé ça, tout change. Tu arrêtes de te battre contre le compilateur et tu commences à collaborer avec lui.

Cet article couvre les fonctionnalités de typage que j'utilise constamment en production. Pas des exercices académiques — de vrais patterns qui m'ont sauvé de vrais bugs.

L'état d'esprit de la programmation au niveau des types#

Avant de plonger dans la syntaxe, je veux recadrer ta façon de penser les types.

En programmation classique, tu écris des fonctions qui transforment les données au runtime :

typescript
function extractIds(users: User[]): number[] {
  return users.map(u => u.id);
}

En programmation au niveau des types, tu écris des types qui transforment d'autres types à la compilation :

typescript
type ExtractIds<T extends { id: unknown }[]> = T[number]["id"];

Le modèle mental est le même — entrée, transformation, sortie. La différence, c'est que le code au niveau des types s'exécute pendant la compilation, pas au runtime. Il ne produit aucun JavaScript. Son seul objectif est de rendre les états impossibles irreprésentables.

Si tu as déjà écrit une fonction où trois des quatre paramètres n'ont de sens que dans certaines combinaisons, ou dont le type de retour dépend de ce que tu passes en entrée, tu as déjà eu besoin de la programmation au niveau des types. Tu ne savais peut-être juste pas que TypeScript pouvait l'exprimer.

Les types conditionnels#

Les types conditionnels sont le if/else du système de types. La syntaxe ressemble à un ternaire :

typescript
type IsString<T> = T extends string ? true : false;
 
type A = IsString<"hello">; // true
type B = IsString<42>;      // false
type C = IsString<string>;  // true

Le mot-clé extends ici ne signifie pas héritage. Il signifie « est assignable à ». Pense à ça comme « est-ce que T rentre dans la forme de string ? »

Construire tes propres types utilitaires#

Reconstruisons certains types utilitaires intégrés à TypeScript pour comprendre comment ils fonctionnent.

NonNullable — supprime null et undefined d'une union :

typescript
type MyNonNullable<T> = T extends null | undefined ? never : T;
 
type Test = MyNonNullable<string | null | undefined>;
// Result: string

Le type never est l'« ensemble vide » — il supprime quelque chose d'une union. Quand une branche d'un type conditionnel se résout en never, ce membre de l'union disparaît simplement.

Extract — ne conserve que les membres de l'union qui correspondent à une contrainte :

typescript
type MyExtract<T, U> = T extends U ? T : never;
 
type Numbers = Extract<string | number | boolean, number | boolean>;
// Result: number | boolean

Exclude — l'opposé, supprime les membres correspondants :

typescript
type MyExclude<T, U> = T extends U ? never : T;
 
type WithoutStrings = Exclude<string | number | boolean, string>;
// Result: number | boolean

Types conditionnels distributifs#

C'est là que les choses deviennent intéressantes, et là où la plupart des gens se perdent. Quand tu passes un type union à un type conditionnel, TypeScript distribue la condition sur chaque membre de l'union individuellement.

typescript
type ToArray<T> = T extends unknown ? T[] : never;
 
type Result = ToArray<string | number>;
// Tu pourrais attendre : (string | number)[]
// Résultat réel :    string[] | number[]

TypeScript n'évalue pas (string | number) extends unknown. Il évalue string extends unknown et number extends unknown séparément, puis fait l'union des résultats.

C'est pour ça que Extract et Exclude fonctionnent comme ils le font. La distribution se fait automatiquement quand T est un paramètre de type « nu » (pas enveloppé dans quoi que ce soit).

Si tu veux empêcher la distribution, enveloppe les deux côtés dans un tuple :

typescript
type ToArrayNonDist<T> = [T] extends [unknown] ? T[] : never;
 
type Result = ToArrayNonDist<string | number>;
// Result: (string | number)[]

J'ai été piégé par ça plus de fois que je ne voudrais l'admettre. Si ton type conditionnel donne des résultats inattendus avec des entrées union, la distribution en est presque toujours la raison.

Exemple pratique : gestionnaire de réponses API#

Voici un pattern que j'utilise pour gérer les réponses API qui peuvent réussir ou échouer :

typescript
type ApiResponse<T> =
  | { status: "success"; data: T; error: never }
  | { status: "error"; data: never; error: string };
 
type ExtractData<R> = R extends { status: "success"; data: infer D } ? D : never;
type ExtractError<R> = R extends { status: "error"; error: infer E } ? E : never;
 
type UserResponse = ApiResponse<{ id: number; name: string }>;
 
type UserData = ExtractData<UserResponse>;
// Result: { id: number; name: string }
 
type UserError = ExtractError<UserResponse>;
// Result: string

C'est plus propre que d'écrire des type guards manuels pour chaque forme de réponse.

Le mot-clé infer#

infer est le pattern matching des types. Il te permet de déclarer une variable de type à l'intérieur d'un type conditionnel que TypeScript va déterminer pour toi.

Pense à ça comme : « Je sais que ce type a une certaine forme. Extrais la partie qui m'intéresse. »

Extraire les types de retour#

typescript
type MyReturnType<T> = T extends (...args: unknown[]) => infer R ? R : never;
 
type Fn = (x: number, y: string) => boolean;
type Result = MyReturnType<Fn>; // boolean

TypeScript voit le pattern (...args) => something, fait correspondre ta fonction à ce pattern, et assigne le type de retour à R.

Extraire les types de paramètres#

typescript
type MyParameters<T> = T extends (...args: infer P) => unknown ? P : never;
 
type Fn = (x: number, y: string) => void;
type Params = MyParameters<Fn>; // [x: number, y: string]

Déballer les Promises#

Celui-ci, je l'utilise tout le temps quand je travaille avec des fonctions asynchrones :

typescript
type UnpackPromise<T> = T extends Promise<infer U> ? U : T;
 
type A = UnpackPromise<Promise<string>>;  // string
type B = UnpackPromise<Promise<number[]>>; // number[]
type C = UnpackPromise<string>;            // string (passthrough)

Pour les Promises imbriquées en profondeur :

typescript
type DeepUnpackPromise<T> = T extends Promise<infer U>
  ? DeepUnpackPromise<U>
  : T;
 
type Deep = DeepUnpackPromise<Promise<Promise<Promise<number>>>>;
// Result: number

Extraire les types d'éléments de tableau#

typescript
type ElementOf<T> = T extends (infer E)[] ? E : never;
 
type Items = ElementOf<string[]>;         // string
type Mixed = ElementOf<(string | number)[]>; // string | number

Multiples infer dans un seul type#

Tu peux utiliser infer plusieurs fois pour extraire différentes parties :

typescript
type FirstAndLast<T extends unknown[]> =
  T extends [infer First, ...unknown[], infer Last]
    ? { first: First; last: Last }
    : never;
 
type Result = FirstAndLast<[1, 2, 3, 4]>;
// Result: { first: 1; last: 4 }

Cas réel : extraction d'événements type-safe#

Voici un pattern que j'utilise dans les systèmes événementiels :

typescript
type EventMap = {
  "user:login": { userId: string; timestamp: number };
  "user:logout": { userId: string };
  "order:created": { orderId: string; total: number };
  "order:shipped": { orderId: string; trackingId: string };
};
 
type EventName = keyof EventMap;
type EventPayload<E extends EventName> = EventMap[E];
 
// Extract all user-related events
type UserEvents = Extract<EventName, `user:${string}`>;
// Result: "user:login" | "user:logout"
 
// Get the payload for user events only
type UserEventPayloads = EventPayload<UserEvents>;
// Result: { userId: string; timestamp: number } | { userId: string }

Les types mappés#

Les types mappés te permettent de créer de nouveaux types objets en transformant chaque propriété d'un type existant. Ce sont le map() du système de types.

Les bases#

typescript
type Readonly<T> = {
  readonly [K in keyof T]: T[K];
};
 
type Partial<T> = {
  [K in keyof T]?: T[K];
};

keyof T te donne une union de toutes les clés de propriétés. [K in ...] itère sur chacune. T[K] est le type de cette propriété.

Construire des types utilitaires à partir de zéro#

Required — rendre toutes les propriétés obligatoires :

typescript
type MyRequired<T> = {
  [K in keyof T]-?: T[K];
};

La syntaxe -? supprime le modificateur optionnel. De même, -readonly supprime readonly.

Pick — sélectionner des propriétés spécifiques :

typescript
type MyPick<T, K extends keyof T> = {
  [P in K]: T[P];
};
 
interface User {
  id: number;
  name: string;
  email: string;
  password: string;
}
 
type PublicUser = MyPick<User, "id" | "name" | "email">;
// { id: number; name: string; email: string }

Omit — supprimer des propriétés spécifiques :

typescript
type MyOmit<T, K extends keyof T> = {
  [P in Exclude<keyof T, K>]: T[P];
};
 
type SafeUser = MyOmit<User, "password">;
// { id: number; name: string; email: string }

Record — créer un type objet avec des clés et des types de valeurs spécifiques :

typescript
type MyRecord<K extends string | number | symbol, V> = {
  [P in K]: V;
};
 
type StatusMap = MyRecord<"active" | "inactive" | "banned", boolean>;
// { active: boolean; inactive: boolean; banned: boolean }

Remappage de clés avec as#

TypeScript 4.1 a introduit le remappage de clés, qui te permet de transformer les noms de propriétés :

typescript
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
 
interface Person {
  name: string;
  age: number;
  email: string;
}
 
type PersonGetters = Getters<Person>;
// {
//   getName: () => string;
//   getAge: () => number;
//   getEmail: () => string;
// }

Tu peux aussi filtrer les propriétés en remappant vers never :

typescript
type OnlyStrings<T> = {
  [K in keyof T as T[K] extends string ? K : never]: T[K];
};
 
type StringProps = OnlyStrings<Person>;
// { name: string; email: string }

C'est incroyablement puissant. Tu itères simultanément sur les propriétés, tu les filtres et tu transformes leurs noms — le tout au niveau des types.

Exemple pratique : types de validation de formulaire#

J'ai utilisé ce pattern dans plusieurs bibliothèques de formulaires :

typescript
interface FormFields {
  username: string;
  email: string;
  age: number;
  bio: string;
}
 
type ValidationErrors<T> = {
  [K in keyof T]?: string[];
};
 
type TouchedFields<T> = {
  [K in keyof T]?: boolean;
};
 
type DirtyFields<T> = {
  [K in keyof T]?: boolean;
};
 
type FormState<T> = {
  values: T;
  errors: ValidationErrors<T>;
  touched: TouchedFields<T>;
  dirty: DirtyFields<T>;
  isValid: boolean;
  isSubmitting: boolean;
};
 
// Now your form state is fully typed based on the fields
type MyFormState = FormState<FormFields>;

Ajoute un champ à FormFields, et chaque type associé se met à jour automatiquement. Supprime un champ, et le compilateur attrape chaque référence. C'est le genre de chose qui prévient des catégories entières de bugs.

Les template literal types#

Les template literal types te permettent de manipuler les chaînes au niveau des types. Ils utilisent la même syntaxe de backtick que les template literals JavaScript, mais pour les types.

Manipulation basique de chaînes#

typescript
type Greeting = `Hello, ${string}`;
 
const a: Greeting = "Hello, world";    // OK
const b: Greeting = "Hello, TypeScript"; // OK
// const c: Greeting = "Hi, world";    // Error!

TypeScript fournit aussi des types de manipulation de chaînes intégrés :

typescript
type Upper = Uppercase<"hello">;     // "HELLO"
type Lower = Lowercase<"HELLO">;     // "hello"
type Cap = Capitalize<"hello">;      // "Hello"
type Uncap = Uncapitalize<"Hello">;  // "hello"

Patterns de noms d'événements#

C'est là que les template literal types brillent vraiment. Voici un pattern que j'utilise pour des systèmes d'événements type-safe :

typescript
type EventNames<T extends string> = `${T}Changed` | `${T}Deleted` | `${T}Created`;
 
type ModelEvents = EventNames<"user" | "order" | "product">;
// "userChanged" | "userDeleted" | "userCreated"
// | "orderChanged" | "orderDeleted" | "orderCreated"
// | "productChanged" | "productDeleted" | "productCreated"

Neuf événements à partir de deux paramètres de type. Et ils sont tous type-safe.

Patterns CSS-in-TypeScript#

Les template literal types peuvent imposer des patterns CSS au niveau des types :

typescript
type CSSUnit = "px" | "rem" | "em" | "vh" | "vw" | "%";
type CSSValue = `${number}${CSSUnit}`;
 
function setWidth(value: CSSValue): void {
  // implementation
}
 
setWidth("100px");   // OK
setWidth("2.5rem");  // OK
setWidth("50%");     // OK
// setWidth("100");  // Error: not a valid CSS value
// setWidth("big");  // Error

Parser des chaînes avec les template literals#

Tu peux utiliser les template literal types avec infer pour parser des chaînes :

typescript
type ParseRoute<T extends string> =
  T extends `${infer _Start}:${infer Param}/${infer Rest}`
    ? Param | ParseRoute<`/${Rest}`>
    : T extends `${infer _Start}:${infer Param}`
      ? Param
      : never;
 
type RouteParams = ParseRoute<"/users/:userId/posts/:postId">;
// Result: "userId" | "postId"

C'est le fondement du fonctionnement des bibliothèques de routage type-safe comme tRPC. La chaîne /users/:userId/posts/:postId est parsée au niveau des types pour extraire les noms de paramètres.

Combiner les template literals avec les types mappés#

C'est là que les choses deviennent vraiment puissantes :

typescript
type EventHandlers<T extends Record<string, unknown>> = {
  [K in keyof T as `on${Capitalize<string & K>}Change`]: (
    newValue: T[K],
    oldValue: T[K]
  ) => void;
};
 
interface ThemeConfig {
  color: string;
  fontSize: number;
  darkMode: boolean;
}
 
type ThemeHandlers = EventHandlers<ThemeConfig>;
// {
//   onColorChange: (newValue: string, oldValue: string) => void;
//   onFontSizeChange: (newValue: number, oldValue: number) => void;
//   onDarkModeChange: (newValue: boolean, oldValue: boolean) => void;
// }

À partir d'une simple interface de configuration, tu obtiens des gestionnaires d'événements entièrement typés avec les bons types de paramètres. C'est le genre de code au niveau des types qui fait vraiment gagner du temps de développement.

Avancé : types de chemins en dot-notation#

Voici un pattern plus avancé — générer tous les chemins possibles en dot-notation à travers un objet imbriqué :

typescript
type DotPaths<T, Prefix extends string = ""> = T extends object
  ? {
      [K in keyof T & string]: T[K] extends object
        ? `${Prefix}${K}` | DotPaths<T[K], `${Prefix}${K}.`>
        : `${Prefix}${K}`;
    }[keyof T & string]
  : never;
 
interface Config {
  database: {
    host: string;
    port: number;
    credentials: {
      username: string;
      password: string;
    };
  };
  server: {
    port: number;
  };
}
 
type ConfigPaths = DotPaths<Config>;
// "database" | "database.host" | "database.port"
// | "database.credentials" | "database.credentials.username"
// | "database.credentials.password" | "server" | "server.port"

Maintenant tu peux écrire une fonction getConfig qui auto-complète chaque chemin possible et retourne le type correct pour chacun :

typescript
type GetValueByPath<T, P extends string> =
  P extends `${infer Key}.${infer Rest}`
    ? Key extends keyof T
      ? GetValueByPath<T[Key], Rest>
      : never
    : P extends keyof T
      ? T[P]
      : never;
 
function getConfig<P extends ConfigPaths>(path: P): GetValueByPath<Config, P> {
  // implementation
  const keys = path.split(".");
  let result: unknown = config;
  for (const key of keys) {
    result = (result as Record<string, unknown>)[key];
  }
  return result as GetValueByPath<Config, P>;
}
 
const host = getConfig("database.host");         // type: string
const port = getConfig("database.port");         // type: number
const username = getConfig("database.credentials.username"); // type: string

Auto-complétion complète, sécurité de types complète, zéro surcoût au runtime côté types.

Les types récursifs#

TypeScript supporte les définitions de types récursifs, ce qui te permet de gérer des structures imbriquées à profondeur arbitraire.

Deep Partial#

Le Partial<T> intégré ne rend optionnelles que les propriétés de premier niveau. Pour les objets imbriqués, tu as besoin de récursion :

typescript
type DeepPartial<T> = {
  [K in keyof T]?: T[K] extends object
    ? T[K] extends Array<infer U>
      ? Array<DeepPartial<U>>
      : DeepPartial<T[K]>
    : T[K];
};
 
interface Settings {
  theme: {
    colors: {
      primary: string;
      secondary: string;
    };
    fonts: {
      heading: string;
      body: string;
    };
  };
  notifications: {
    email: boolean;
    push: boolean;
  };
}
 
type PartialSettings = DeepPartial<Settings>;
// Every nested property is now optional

Note la vérification des tableaux : sans elle, les tableaux seraient traités comme des objets et leurs index numériques deviendraient optionnels, ce qui n'est pas ce que tu veux.

Deep Readonly#

Même pattern, modificateur différent :

typescript
type DeepReadonly<T> = {
  readonly [K in keyof T]: T[K] extends object
    ? T[K] extends Function
      ? T[K]
      : T[K] extends Array<infer U>
        ? ReadonlyArray<DeepReadonly<U>>
        : DeepReadonly<T[K]>
    : T[K];
};

La vérification de Function est importante — sans elle, les propriétés de type fonction auraient readonly appliqué à leur structure interne, ce qui n'a pas de sens.

Le type JSON#

Voici un type récursif classique — représenter toute valeur JSON valide :

typescript
type JsonPrimitive = string | number | boolean | null;
type JsonArray = JsonValue[];
type JsonObject = { [key: string]: JsonValue };
type JsonValue = JsonPrimitive | JsonArray | JsonObject;
 
function parseJson(input: string): JsonValue {
  return JSON.parse(input);
}
 
function isJsonObject(value: JsonValue): value is JsonObject {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

C'est bien mieux que d'utiliser any pour le parsing JSON. Ça dit au consommateur « tu vas recevoir quelque chose, mais tu dois le narrower avant de l'utiliser ». Ce qui est exactement la vérité.

Type récursif pour les menus imbriqués#

Un exemple pratique — typer une structure de menu de navigation :

typescript
interface MenuItem {
  label: string;
  href: string;
  icon?: string;
  children?: MenuItem[];
}
 
type Menu = MenuItem[];
 
const navigation: Menu = [
  {
    label: "Products",
    href: "/products",
    children: [
      {
        label: "Software",
        href: "/products/software",
        children: [
          { label: "IDE", href: "/products/software/ide" },
          { label: "CLI Tools", href: "/products/software/cli" },
        ],
      },
      { label: "Hardware", href: "/products/hardware" },
    ],
  },
  { label: "About", href: "/about" },
];

La propriété récursive children te donne un imbriquement infini avec une sécurité de types complète.

Patterns du monde réel#

Laisse-moi partager quelques patterns que j'ai réellement utilisés dans des systèmes de production.

Le pattern Builder avec les types#

Le pattern Builder devient nettement plus utile quand le système de types suit ce qui a été défini :

typescript
type RequiredKeys = "host" | "port" | "database";
 
type BuilderState = {
  [K in RequiredKeys]?: true;
};
 
class DatabaseConfigBuilder<State extends BuilderState = {}> {
  private config: Partial<{
    host: string;
    port: number;
    database: string;
    ssl: boolean;
    poolSize: number;
  }> = {};
 
  setHost(host: string): DatabaseConfigBuilder<State & { host: true }> {
    this.config.host = host;
    return this as unknown as DatabaseConfigBuilder<State & { host: true }>;
  }
 
  setPort(port: number): DatabaseConfigBuilder<State & { port: true }> {
    this.config.port = port;
    return this as unknown as DatabaseConfigBuilder<State & { port: true }>;
  }
 
  setDatabase(db: string): DatabaseConfigBuilder<State & { database: true }> {
    this.config.database = db;
    return this as unknown as DatabaseConfigBuilder<State & { database: true }>;
  }
 
  setSsl(ssl: boolean): DatabaseConfigBuilder<State> {
    this.config.ssl = ssl;
    return this as unknown as DatabaseConfigBuilder<State>;
  }
 
  setPoolSize(size: number): DatabaseConfigBuilder<State> {
    this.config.poolSize = size;
    return this as unknown as DatabaseConfigBuilder<State>;
  }
 
  build(
    this: DatabaseConfigBuilder<{ host: true; port: true; database: true }>
  ): Required<Pick<typeof this.config, "host" | "port" | "database">> &
    Partial<Pick<typeof this.config, "ssl" | "poolSize">> {
    return this.config as ReturnType<typeof this.build>;
  }
}
 
// This compiles:
const config = new DatabaseConfigBuilder()
  .setHost("localhost")
  .setPort(5432)
  .setDatabase("myapp")
  .setSsl(true)
  .build();
 
// This fails at compile time — missing .setDatabase():
// const bad = new DatabaseConfigBuilder()
//   .setHost("localhost")
//   .setPort(5432)
//   .build(); // Error: 'database' is missing

La méthode build() ne devient appelable que quand tous les champs obligatoires ont été définis. C'est détecté à la compilation, pas au runtime.

EventEmitter type-safe#

Voici un event emitter où les types de payload sont imposés :

typescript
type EventMap = Record<string, unknown>;
 
class TypedEventEmitter<Events extends EventMap> {
  private listeners: {
    [K in keyof Events]?: Array<(payload: Events[K]) => void>;
  } = {};
 
  on<K extends keyof Events>(
    event: K,
    listener: (payload: Events[K]) => void
  ): void {
    if (!this.listeners[event]) {
      this.listeners[event] = [];
    }
    this.listeners[event]!.push(listener);
  }
 
  emit<K extends keyof Events>(event: K, payload: Events[K]): void {
    this.listeners[event]?.forEach((listener) => listener(payload));
  }
 
  off<K extends keyof Events>(
    event: K,
    listener: (payload: Events[K]) => void
  ): void {
    const handlers = this.listeners[event];
    if (handlers) {
      this.listeners[event] = handlers.filter((h) => h !== listener) as typeof handlers;
    }
  }
}
 
// Usage
interface AppEvents {
  "user:login": { userId: string; timestamp: number };
  "user:logout": { userId: string };
  "notification:new": { message: string; level: "info" | "warning" | "error" };
}
 
const emitter = new TypedEventEmitter<AppEvents>();
 
// Fully typed — IDE autocompletes event names and payload shapes
emitter.on("user:login", (payload) => {
  console.log(payload.userId);     // string
  console.log(payload.timestamp);  // number
});
 
// Type error: payload doesn't match
// emitter.emit("user:login", { userId: "123" });
// Error: missing 'timestamp'
 
// Type error: event doesn't exist
// emitter.on("user:signup", () => {});
// Error: "user:signup" is not in AppEvents

J'ai utilisé ce pattern dans trois projets de production différents. Il attrape des catégories entières de bugs liés aux événements à la compilation.

Unions discriminées avec vérification exhaustive#

Les unions discriminées sont probablement le pattern le plus utile de TypeScript. Combinées avec la vérification exhaustive, elles garantissent que tu gères chaque cas :

typescript
type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "rectangle"; width: number; height: number }
  | { kind: "triangle"; base: number; height: number };
 
function area(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:
      // This line ensures exhaustive checking
      const _exhaustive: never = shape;
      return _exhaustive;
  }
}

Si quelqu'un ajoute un nouveau variant de forme (disons "pentagon"), cette fonction ne compilera plus car never ne peut pas recevoir de valeur. Le compilateur te force à gérer chaque cas.

Je vais plus loin avec une fonction helper :

typescript
function assertNever(value: never, message?: string): never {
  throw new Error(message ?? `Unexpected value: ${JSON.stringify(value)}`);
}
 
function area(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:
      assertNever(shape, `Unknown shape kind: ${(shape as Shape).kind}`);
  }
}

Machines à états type-safe#

Les unions discriminées modélisent aussi les machines à états de manière élégante :

typescript
type ConnectionState =
  | { status: "disconnected" }
  | { status: "connecting"; attempt: number }
  | { status: "connected"; socket: WebSocket; connectedAt: Date }
  | { status: "error"; error: Error; lastAttempt: Date };
 
type ConnectionAction =
  | { type: "CONNECT" }
  | { type: "CONNECTED"; socket: WebSocket }
  | { type: "DISCONNECT" }
  | { type: "ERROR"; error: Error }
  | { type: "RETRY" };
 
function connectionReducer(
  state: ConnectionState,
  action: ConnectionAction
): ConnectionState {
  switch (state.status) {
    case "disconnected":
      if (action.type === "CONNECT") {
        return { status: "connecting", attempt: 1 };
      }
      return state;
 
    case "connecting":
      if (action.type === "CONNECTED") {
        return {
          status: "connected",
          socket: action.socket,
          connectedAt: new Date(),
        };
      }
      if (action.type === "ERROR") {
        return {
          status: "error",
          error: action.error,
          lastAttempt: new Date(),
        };
      }
      return state;
 
    case "connected":
      if (action.type === "DISCONNECT") {
        state.socket.close();
        return { status: "disconnected" };
      }
      if (action.type === "ERROR") {
        return {
          status: "error",
          error: action.error,
          lastAttempt: new Date(),
        };
      }
      return state;
 
    case "error":
      if (action.type === "RETRY") {
        return { status: "connecting", attempt: 1 };
      }
      if (action.type === "DISCONNECT") {
        return { status: "disconnected" };
      }
      return state;
 
    default:
      assertNever(state, "Unknown connection state");
  }
}

Chaque état n'a que les propriétés qui ont du sens pour cet état. Tu ne peux pas accéder à socket quand tu es déconnecté. Tu ne peux pas accéder à error quand tu es connecté. Le système de types impose les contraintes de la machine à états.

Types brandés pour la sécurité du domaine#

Un dernier pattern que je trouve essentiel — utiliser les types brandés pour empêcher de mélanger des valeurs qui partagent le même type sous-jacent :

typescript
type Brand<T, B extends string> = T & { readonly __brand: B };
 
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
type Email = Brand<string, "Email">;
 
function createUserId(id: string): UserId {
  return id as UserId;
}
 
function createOrderId(id: string): OrderId {
  return id as OrderId;
}
 
function getUser(id: UserId): Promise<User> {
  // ...
}
 
function getOrder(id: OrderId): Promise<Order> {
  // ...
}
 
const userId = createUserId("user_123");
const orderId = createOrderId("order_456");
 
getUser(userId);   // OK
// getUser(orderId); // Error! OrderId is not assignable to UserId

UserId et OrderId sont tous les deux des strings au runtime. Mais à la compilation, ce sont des types distincts. Tu ne peux littéralement pas passer un ID de commande là où un ID d'utilisateur est attendu. Ça a attrapé de vrais bugs dans chaque projet où je l'ai utilisé.

Les pièges courants#

Les types avancés sont puissants, mais ils viennent avec des pièges. Voici ce que j'ai appris à la dure.

Limites de références circulaires#

TypeScript a une limite de profondeur de récursion pour les types (actuellement environ 50 niveaux, bien que ça varie). Si tu vas trop profond, tu obtiendras la redoutée erreur « Type instantiation is excessively deep and possibly infinite ».

typescript
// This will hit the recursion limit for deeply nested objects
type DeepPartial<T> = {
  [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};
 
// Fix: Add a depth counter
type DeepPartialSafe<T, Depth extends number[] = []> =
  Depth["length"] extends 10
    ? T
    : {
        [K in keyof T]?: T[K] extends object
          ? DeepPartialSafe<T[K], [...Depth, 0]>
          : T[K];
      };

L'astuce du compteur de profondeur utilise un tuple qui grandit à chaque étape récursive. Quand il atteint ta limite, la récursion s'arrête.

Implications sur les performances#

Les types complexes peuvent ralentir significativement ton éditeur et tes temps de build. J'ai vu des projets où un seul type trop malin ajoutait 3 secondes à la boucle de retour de chaque frappe.

Signes d'alerte :

  • Ton IDE met plus de 2 secondes à afficher l'auto-complétion
  • tsc --noEmit prend notablement plus de temps après l'ajout d'un type
  • Tu vois des erreurs « Type instantiation is excessively deep »
typescript
// This is too clever — it generates a combinatorial explosion
type AllCombinations<T extends string[]> = T extends [
  infer First extends string,
  ...infer Rest extends string[]
]
  ? `${First}${AllCombinations<Rest>}` | AllCombinations<Rest>
  : "";
 
// Don't do this with more than 5-6 elements

Quand NE PAS utiliser les types avancés#

C'est peut-être la section la plus importante. Les types avancés devraient être utilisés quand ils préviennent des bugs ou améliorent l'expérience développeur. Ils ne devraient pas être utilisés pour frimer.

Ne les utilise pas quand :

  • Un simple Record<string, unknown> suffirait
  • Le type n'est utilisé qu'à un seul endroit et un type concret serait plus clair
  • Tu passes plus de temps à debugger le type qu'il n'en ferait gagner
  • Ton équipe ne peut pas le lire ou le maintenir
  • Une vérification au runtime serait plus appropriée

Utilise-les quand :

  • Tu as un pattern qui se répète dans de nombreux types (types mappés)
  • Le type de retour d'une fonction dépend de son entrée (types conditionnels)
  • Tu construis une API de bibliothèque qui doit être auto-documentée
  • Tu veux rendre les états illégaux irreprésentables
  • Tu en as marre de la même catégorie de bug qui apparaît en code review
typescript
// Over-engineered — don't do this for a simple config
type Config = DeepReadonly<
  DeepRequired<
    Merge<DefaultConfig, Partial<UserConfig>>
  >
>;
 
// Just do this
interface Config {
  readonly host: string;
  readonly port: number;
  readonly debug: boolean;
}

Ma règle d'or : si expliquer le type prend plus de temps qu'expliquer le bug qu'il prévient, simplifie-le.

Debugger les types complexes#

Quand un type complexe ne fonctionne pas, j'utilise ce helper pour « voir » ce que TypeScript a résolu :

typescript
// Expands a type for inspection in IDE hover tooltips
type Prettify<T> = {
  [K in keyof T]: T[K];
} & {};
 
// Use it to debug
type Problematic = SomeComplexType<Input>;
type Debug = Prettify<Problematic>;
// Now hover over Debug in your IDE to see the resolved type

L'astuce & {} force TypeScript à évaluer le type de manière eagere au lieu de te montrer l'alias de type. Ça m'a fait gagner des heures de confusion.

Autre technique — isoler et tester de manière incrémentale :

typescript
// Instead of debugging this all at once:
type Final = StepThree<StepTwo<StepOne<Input>>>;
 
// Break it apart:
type AfterStep1 = StepOne<Input>;       // hover to check
type AfterStep2 = StepTwo<AfterStep1>;  // hover to check
type AfterStep3 = StepThree<AfterStep2>; // hover to check

En résumé#

  • Les types conditionnels (T extends U ? X : Y) sont le if/else des types. Attention au comportement distributif avec les unions.
  • infer est le pattern matching — utilise-le pour extraire des types depuis des structures comme les signatures de fonctions, les Promises et les tableaux.
  • Les types mappés ({ [K in keyof T]: ... }) itèrent sur les propriétés. Combine-les avec as pour le remappage et le filtrage de clés.
  • Les template literal types manipulent les chaînes au niveau des types. Combinés avec les types mappés, ils sont incroyablement puissants pour la conception d'API.
  • Les types récursifs gèrent les structures imbriquées mais ont besoin de limites de profondeur pour éviter les explosions du compilateur.
  • Les unions discriminées avec vérification exhaustive sont le pattern à plus haute valeur ajoutée de TypeScript. Utilise-les partout.
  • Les types brandés empêchent de mélanger des valeurs qui partagent le même type sous-jacent. Simples à implémenter, fort impact.
  • Ne sur-ingénierie pas les types. Si le type est plus dur à comprendre que le bug qu'il prévient, simplifie-le. L'objectif est de rendre ta codebase plus sûre, pas de gagner une compétition de type golf.

Le système de types de TypeScript est Turing-complet, ce qui signifie que tu peux faire presque n'importe quoi avec. L'art, c'est de savoir quand tu devrais.

Articles similaires