Développement Web

Utility et mapped types TypeScript : dériver sans dupliquer

12 دقائق للقراءة

📍 Guide principal de la série : TypeScript moderne — du JS au système de types industriel

Ce tutoriel parcourt la bibliothèque standard des utility types puis apprend à construire ses propres mapped types pour transformer un type en un autre.

Introduction

Plutôt que de dupliquer trois fois la même structure pour en exposer une version « complète », une version « brouillon » et une version « lecture seule », TypeScript fournit une bibliothèque d’utility types qui dérivent automatiquement ces variations. Comprendre cette bibliothèque, c’est la première marche. Apprendre à écrire ses propres mapped types, c’est la seconde — celle qui ouvre la porte à des transformations de types impossibles à obtenir avec les seuls utilitaires standards. Ce tutoriel parcourt l’ensemble, en s’appuyant sur la documentation officielle pour les signatures et avec des exemples qui compilent sur TypeScript 6.0.

Prérequis

  • Connaître les bases des génériques.
  • Projet TypeScript fonctionnel avec strict activé.
  • TypeScript 5.4 ou supérieur (les exemples couvrent jusqu’à 6.0).
  • Temps estimé : 50 minutes.

Étape 1 — Les utility types qui agissent sur les propriétés

La première famille d’utility types transforme les propriétés d’un objet. On y trouve Partial, Required, Readonly, Pick et Omit. Chacun produit un nouveau type à partir d’un type existant sans modifier la déclaration d’origine.

interface Todo {
  id: number;
  title: string;
  description: string;
  completed: boolean;
}

// Toutes optionnelles
type TodoDraft = Partial<Todo>;
// { id?: number; title?: string; description?: string; completed?: boolean }

// Toutes requises (inverse de Partial)
type TodoStrict = Required<Partial<Todo>>;
// { id: number; title: string; description: string; completed: boolean }

// Toutes en lecture seule
type TodoFrozen = Readonly<Todo>;

// Garder seulement un sous-ensemble de clés
type TodoView = Pick<Todo, "id" | "title">;
// { id: number; title: string }

// Retirer un sous-ensemble de clés
type TodoUpdate = Omit<Todo, "id">;
// { title: string; description: string; completed: boolean }

Ces utility types ne sont pas magiques : ils sont implémentés en pur TypeScript dans lib.es5.d.ts à partir des mapped types qu’on verra plus bas. Connaître leur signature aide à les composer sans surprise. Une combinaison classique : Readonly<Pick<User, "id" | "email">> produit un type qui ne contient que les clés id et email, et qui les marque toutes en readonly.

Étape 2 — Utility types sur les unions

Quand on manipule une union de littéraux, on a souvent besoin d’exclure ou d’extraire certains membres. Exclude retire ce qui correspond à un motif, Extract garde ce qui correspond, NonNullable retire null et undefined.

type Status = "idle" | "loading" | "success" | "error";

type TerminalStatus = Exclude<Status, "idle" | "loading">;
// "success" | "error"

type ActiveStatus = Extract<Status, "loading" | "success">;
// "loading" | "success"

type MaybeUser = User | null | undefined;
type DefinitelyUser = NonNullable<MaybeUser>;
// User

Ces utility types reposent sur la distributivité des conditional types vue dans le tutoriel sur les génériques. Exclude<U, X> est défini comme U extends X ? never : U, ce qui retire les membres correspondants en les remplaçant par never avant que le compilateur ne les élimine de l’union finale.

Étape 3 — Utility types sur les fonctions

Les fonctions exposent plusieurs types intéressants à extraire : leurs paramètres, leur retour, leur type d’instance dans le cas d’un constructeur. La bibliothèque standard fournit les utilitaires correspondants.

function createUser(id: string, email: string, isAdmin = false) {
  return { id, email, isAdmin, createdAt: new Date() };
}

type CreateUserArgs   = Parameters<typeof createUser>;
// [id: string, email: string, isAdmin?: boolean]

type CreateUserResult = ReturnType<typeof createUser>;
// { id: string; email: string; isAdmin: boolean; createdAt: Date }

class HttpError extends Error {
  constructor(public status: number, message: string) {
    super(message);
  }
}

type HttpErrorArgs = ConstructorParameters<typeof HttpError>;
// [status: number, message: string]

type HttpErrorInstance = InstanceType<typeof HttpError>;
// HttpError

Le pattern typeof fonction récupère le type d’une valeur, ce qui permet ensuite de l’introspecter sans répéter la signature. Cette mécanique évite des dizaines de duplications quand on construit une couche middleware ou une décoration de méthodes typées.

Étape 4 — Awaited et le déballage des promesses

Quand on chaîne des promesses ou qu’on utilise async/await, on a souvent besoin de récupérer le type de la valeur résolue. Awaited<T>, introduit en TypeScript 4.5, fait ce travail récursivement, même si T est une promesse de promesse.

async function fetchUser(id: string): Promise<{ id: string; name: string }> {
  /* ... */
}

type FetchedUser = Awaited<ReturnType<typeof fetchUser>>;
// { id: string; name: string }

type Nested = Awaited<Promise<Promise<number>>>;
// number

Cette utility est devenue le pivot de toute API qui consomme des fonctions async sans en connaître le retour exact, par exemple un router HTTP qui dérive ses types de réponse à partir des handlers déclarés.

Étape 5 — Manipulation de chaînes au niveau type

TypeScript expose des utility types qui transforment les types littéraux de chaîne : Uppercase, Lowercase, Capitalize, Uncapitalize. Ils sont intégrés au compilateur, ce qui les rend plus performants que des équivalents écrits manuellement.

type Up    = Uppercase<"hello">;     // "HELLO"
type Low   = Lowercase<"HELLO">;     // "hello"
type Cap   = Capitalize<"hello">;    // "Hello"
type Unc   = Uncapitalize<"Hello">;  // "hello"

Combinés aux template literal types (`get${Capitalize<K>}`), ils permettent de générer des noms de propriétés dérivés du type d’entrée, comme on le verra en étape 8.

Étape 6 — NoInfer côté utility

NoInfer<T>, apparu en TypeScript 5.4, bloque l’inférence de T à partir d’une position particulière. Ce n’est pas une transformation de type au sens classique, mais une instruction donnée au compilateur sur la stratégie d’inférence.

function pickWithDefault<T, K extends keyof T>(
  obj: T,
  key: K,
  defaultValue: NoInfer<T[K]>
): T[K] {
  return obj[key] ?? defaultValue;
}

const u = { id: "u1", name: "Aïssatou", role: "admin" as const };
pickWithDefault(u, "id", "anonymous"); // OK : defaultValue doit être un string
pickWithDefault(u, "id", 42);          // ✗ Erreur : 42 n'est pas un string

Ici, K est inféré depuis key, ce qui détermine T[K]. La position NoInfer<T[K]> sur defaultValue indique au compilateur de ne pas se servir de cet argument pour deviner T[K] : la valeur de repli doit être compatible avec le type attendu mais ne peut pas l’élargir. Sans NoInfer, fournir un defaultValue d’un autre type aurait pu silencieusement étendre T[K] à une union plus large que prévu.

Étape 7 — Écrire un mapped type

Un mapped type itère sur les clés d’un type et produit un nouveau type. La syntaxe est { [K in keyof T]: NouvelleValeur }. C’est la mécanique sous-jacente à Partial, Readonly et Pick. La reproduire à la main aide à comprendre le mécanisme.

// Reproduction de Partial
type MyPartial<T> = {
  [K in keyof T]?: T[K];
};

// Reproduction de Readonly
type MyReadonly<T> = {
  readonly [K in keyof T]: T[K];
};

// Tous les champs deviennent booléens (par exemple un filtre)
type FieldMask<T> = {
  [K in keyof T]: boolean;
};

L’expression T[K] est un type indexé : pour chaque clé K, on récupère le type associé dans T. C’est la combinaison du keyof qui produit l’union des clés et du T[K] qui résout chaque clé à son type qui donne au mapped type sa puissance.

Étape 8 — Modificateurs readonly et ?

Sur un mapped type, on peut ajouter ou retirer les modificateurs readonly et ?. Les préfixes + (ajout, défaut) et - (retrait) contrôlent l’opération.

// Retirer readonly
type Mutable<T> = {
  -readonly [K in keyof T]: T[K];
};

type LockedAccount = {
  readonly id: string;
  readonly name: string;
};

type UnlockedAccount = Mutable<LockedAccount>;
// { id: string; name: string }

// Retirer l'optionnalité (équivalent du Required officiel)
type Concrete<T> = {
  [K in keyof T]-?: T[K];
};

type MaybeUser = {
  id: string;
  name?: string;
  age?: number;
};

type User = Concrete<MaybeUser>;
// { id: string; name: string; age: number }

L’inverse fonctionne aussi : +readonly rend toutes les propriétés en lecture seule, +? les rend toutes optionnelles. On préfère omettre le + qui est le défaut, mais sa présence n’est pas une erreur.

Étape 9 — Key remapping avec as

TypeScript 4.1 a introduit la syntaxe [K in keyof T as NouvelleCle] qui permet de transformer la clé en plus de la valeur. Combinée aux template literal types et aux utility types de chaîne, cette mécanique débloque des transformations très utiles, en particulier la génération de getters et setters typés.

type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

interface Person {
  name: string;
  age: number;
  location: string;
}

type LazyPerson = Getters<Person>;
// {
//   getName: () => string;
//   getAge: () => number;
//   getLocation: () => string;
// }

On peut aussi filtrer les clés en remappant vers never les clés qu’on veut exclure. C’est une mécanique alternative à Omit qui permet une logique de filtre arbitrairement complexe.

type WithoutKind<T> = {
  [K in keyof T as K extends "kind" ? never : K]: T[K];
};

interface Shape {
  kind: "circle";
  radius: number;
}

type KindlessShape = WithoutKind<Shape>;
// { radius: number }

Étape 10 — Cas réel : générer un type d’événement

Pour rassembler les concepts, on construit un type d’événements basé sur un objet de descripteurs. Chaque entrée de l’objet donne le nom d’un événement et la forme de son payload. La cible est de produire l’union des événements typés, prête à être consommée par un switch exhaustif.

type EventMap = {
  click:    { x: number; y: number };
  scroll:   { direction: "up" | "down"; distance: number };
  keypress: { key: string; shift: boolean };
};

type Event<M> = {
  [K in keyof M]: { type: K } & M[K];
}[keyof M];

type AppEvent = Event<EventMap>;
// { type: "click"; x: number; y: number }
// | { type: "scroll"; direction: "up" | "down"; distance: number }
// | { type: "keypress"; key: string; shift: boolean }

function handle(e: AppEvent) {
  switch (e.type) {
    case "click":    return `clic en ${e.x},${e.y}`;
    case "scroll":   return `scroll ${e.direction} de ${e.distance}px`;
    case "keypress": return `touche ${e.key}`;
  }
}

Le pattern { [K in keyof M]: ... }[keyof M] est une astuce courante : on construit un mapped type intermédiaire dont chaque valeur est un objet, puis on récupère l’union de toutes les valeurs en indexant par keyof M. C’est ce qui transforme un dictionnaire d’événements en union discriminée.

Étape 11 — Vérification finale

Pour s’assurer que les types se composent correctement, on les vérifie au compilateur avec --noEmit. Quelques tests de type formels protègent contre les régressions.

// src/types/assert.ts
type Equal<A, B>
  = (<T>() => T extends A ? 1 : 2) extends
    (<T>() => T extends B ? 1 : 2)
      ? true : false;

type Expect<T extends true> = T;

// Tests
type T1 = Expect<Equal<Partial<{ a: number }>, { a?: number }>>;
type T2 = Expect<Equal<Omit<{ a: number; b: string }, "b">, { a: number }>>;
type T3 = Expect<Equal<Mutable<{ readonly x: 1 }>, { x: 1 }>>;
npx tsc --noEmit

Si la compilation passe sans erreur, l’ensemble des transformations produit bien les types attendus. Ce style de tests formels au niveau du compilateur est précieux quand on maintient une bibliothèque qui expose des types complexes — il fait office de cahier des charges machine-vérifiable.

Erreurs fréquentes

Symptôme Cause probable Solution
Property 'X' does not exist on type 'Partial<...>' Accès non protégé sur un type rendu optionnel Vérifier la présence ou narrower : if (obj.X) avant utilisation
Le mapped type ne préserve pas la modalité readonly Pas de readonly dans le mapping ou de -readonly involontaire Ajouter readonly [K in keyof T]: T[K] ou ajuster le modifier
Clé jamais générée par un key remapping Remapping vers never involontaire (template literal mal écrit) Vérifier la concaténation `${...}` et utiliser string & K pour étroitiser K à string
Le compilateur ralentit fortement Mapped types récursifs sans condition d’arrêt Plafonner la profondeur ou refactoriser pour éviter la récursion infinie
Exclude ne retire rien Le motif passé en deuxième argument ne se distribue pas sur l’union S’assurer que le second argument est exactement un membre de l’union, pas une approximation structurale

Tutoriels complémentaires

FAQ

Quand utiliser Pick et quand utiliser Omit ?
Pick liste explicitement ce qu’on garde, Omit liste explicitement ce qu’on retire. On choisit celui qui produit la liste la plus courte et la plus stable dans le temps. Sur une interface qui évolue, Pick est plus sûr parce qu’il n’inclut pas automatiquement les nouvelles propriétés.

Peut-on composer plusieurs utility types ?
Oui : Readonly<Pick<User, "id" | "email">>, Partial<Omit<User, "id">>, etc. Chacun produit un type qui devient l’entrée du suivant.

Comment retirer la mutabilité d’un type ?
Aucun utility standard ne le fait. On l’écrit en mapped type avec -readonly. type Mutable<T> = { -readonly [K in keyof T]: T[K] }.

Pourquoi mon mapped type ne capte-t-il pas les méthodes ?
Les méthodes sont des propriétés comme les autres. Si elles semblent ignorées, vérifiez que le type n’a pas été produit par un typeof sur une classe sans InstanceType, et qu’il n’y a pas de signature d’index qui élargit toutes les clés à string.

Quelle est la limite de profondeur ?
TypeScript plafonne l’instantiation à 100 niveaux depuis la version 4.5 (auparavant 50), avec une évaluation tail-récursive qui permet d’aller bien au-delà pour les types conditionnels qui se terminent par eux-mêmes. Si vous tombez sur cette limite, il faut généralement repenser l’approche : un type qui demande autant de récursion est rarement maintenable.

Ressources et références

Service ITSkillsCenter

Site ou application web sur mesure

Conception Pro + Nom de domaine 1 an + Hébergement 1 an + Formation + Support 6 mois. Accès et code livrés. À partir de 350 000 FCFA.

Demander un devis
Publicité