Mapped types et conditional types en TypeScript
1. Vue d’ensemble
Dans un projet TypeScript, vous devez souvent écrire de nombreux types similaires : Order, puis ReadonlyOrder, OrderUpdate, OrderGetters… Écrire manuellement chaque type est à la fois fastidieux et facile à désynchroniser quand Order change.
Les mapped types et conditional types résolvent exactement ce problème. Ils vous permettent de générer de nouveaux types à partir des types existants. Après avoir lu cet article, vous saurez écrire vous-même des types utilitaires comme Partial, PickByType, UnwrapPromise. Vous saurez aussi comment les tester et savoir quand vous arrêter.
2. Préparation
Vous devez TypeScript 5.x avec strict activé. Cet article utilise Vitest pour tester les types :
npm install -D typescript vitest
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noEmit": true
}
}
strict: true active également strictNullChecks. Certains résultats dans cet article, par exemple si undefined correspond à string ou non, dépendent de ce flag.
Les exemples utilisent ce type partagé :
export interface Order {
id: string;
customerId: string;
total: number;
paid: boolean;
note?: string;
}
3. Mapped types
3.1. Syntaxe de base
Un mapped type parcourt un ensemble de clés et crée une propriété pour chaque clé :
type ReadonlyOrder = {
readonly [K in keyof Order]: Order[K];
};
const order: ReadonlyOrder = {
id: "o-1",
customerId: "c-1",
total: 250_000,
paid: false,
};
order.paid = true; // Erreur de compilation
Explication :
keyof Orderest l’union"id" | "customerId" | "total" | "paid" | "note".[K in ...]itère sur chaque clé dans cette union.Order[K]est un indexed access type. Il obtient le type de la propriétéK.- Un mapped type de la forme
[K in keyof T]est appelé homomorphic. Cette forme préserve les modificateurs d’origine, doncnotereste optionnel.
La dernière ligne génère une erreur :
error TS2540: Cannot assign to 'paid' because it is a read-only property.
L’exemple ci-dessus est toujours codé en dur pour Order. Pour le réutiliser pour tout type, vous ajoutez un paramètre générique :
type MyReadonly<T> = { readonly [K in keyof T]: T[K] };
type MyPartial<T> = { [K in keyof T]?: T[K] };
type OrderUpdate = MyPartial<Order>;
// { id?: string; customerId?: string; total?: number; paid?: boolean; note?: string }
C’est ainsi que la bibliothèque standard définit Readonly<T> et Partial<T>.
3.2. Ajouter et retirer les modificateurs
Vous pouvez placer + ou - avant readonly et ?. Le signe + est par défaut et souvent omis. Le signe - est utilisé pour supprimer un modificateur :
type Mutable<T> = { -readonly [K in keyof T]: T[K] };
type Concrete<T> = { [K in keyof T]-?: T[K] };
type CompleteOrder = Concrete<Order>;
const draft: CompleteOrder = {
id: "o-2",
customerId: "c-9",
total: 99_000,
paid: true,
note: "Livraison pendant les heures de bureau",
};
Avec Concrete<Order>, la propriété note devient obligatoire. Son type est string, plus string | undefined. La bibliothèque standard a déjà Required<T> qui fait exactement cela.
3.3. Renommer les clés avec as
Depuis TypeScript 4.1, vous pouvez renommer les clés avec une clause as. C’est souvent utilisé avec les template literal types :
interface Customer {
id: string;
name: string;
email: string;
}
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type CustomerGetters = Getters<Customer>;
// {
// getId: () => string;
// getName: () => string;
// getEmail: () => string;
// }
La partie string & K est obligatoire. keyof T peut contenir number et symbol, alors que Capitalize accepte seulement string. Cette intersection filtre les clés qui ne sont pas des chaînes.
Vous pouvez aussi utiliser as pour filtrer les clés. Si l’expression retourne never, cette clé est exclue du résultat :
type WithoutId<T> = {
[K in keyof T as Exclude<K, "id">]: T[K];
};
type NewOrder = WithoutId<Order>;
// { customerId: string; total: number; paid: boolean; note?: string }
Exclude ici est un conditional type. C’est le sujet de la prochaine section.
4. Conditional types
4.1. Syntaxe extends ? :
Un conditional type agit comme l’opérateur ternaire, mais au niveau du type :
type IsString<T> = T extends string ? true : false;
type A = IsString<"hello">; // true
type B = IsString<42>; // false
T extends string signifie « T peut-il être assigné à string ? ». Ce n’est pas une relation d’héritage de classe.
4.2. Distributivité
Quand T est un paramètre générique nu (naked) et que vous transmettez une union, TypeScript applique la condition à chaque membre de l’union :
type ToArray<T> = T extends unknown ? T[] : never;
type ToArrayNonDist<T> = [T] extends [unknown] ? T[] : never;
type C = ToArray<string | number>; // string[] | number[]
type D = ToArrayNonDist<string | number>; // (string | number)[]
Les deux résultats sont complètement différents. Envelopper T dans un tuple [T] désactive la distributivité.
La distributivité est à la base de Exclude et Extract :
type MyExclude<T, U> = T extends U ? never : T;
type Status = "pending" | "paid" | "cancelled";
type ActiveStatus = MyExclude<Status, "cancelled">; // "pending" | "paid"
TypeScript examine chaque membre. "cancelled" devient never, et never disparaît automatiquement de l’union.
Note : Un conditional type distributif qui reçoit
neverretournenever, carneverest une union vide. Par exemple,ToArray<never>estnever, pasnever[]. Si vous devez détecternever, utilisez la forme avec tuple :[T] extends [never] ? ... : ....
4.3. Extraire le type avec infer
infer vous permet de “capturer” une partie du type dans la branche de condition :
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type ElementOf<T> = T extends readonly (infer E)[] ? E : never;
async function fetchOrder(id: string): Promise<Order> {
return { id, customerId: "c-1", total: 0, paid: false };
}
type FetchResult = ReturnType<typeof fetchOrder>; // Promise<Order>
type FetchedOrder = UnwrapPromise<FetchResult>; // Order
type Item = ElementOf<Order[]>; // Order
Explication de chaque partie :
Promise<infer U>déclare une variable de typeUet laisse TypeScript en déduire la valeur.- Si cela correspond, la branche
truepeut utiliserU. Sinon,UnwrapPromiseretourneTtel quel. ReturnTypeetParametersde la bibliothèque standard sont aussi écris avecinfer.
En pratique, vous devez utiliser Awaited<T> fourni. Il gère même les Promises imbriquées comme Promise<Promise<Order>>.
5. Combiner les deux techniques
5.1. Sélectionner les propriétés par type de valeur
En plaçant un conditional type dans la clause as, vous pouvez filtrer les clés par le type de la valeur :
type PickByType<T, V> = {
[K in keyof T as T[K] extends V ? K : never]: T[K];
};
type OrderNumbers = PickByType<Order, number>; // { total: number }
type OrderStrings = PickByType<Order, string>; // { id: string; customerId: string }
Notez que note ne figure pas dans OrderStrings. Le type de Order["note"] est string | undefined, et ce type ne peut pas être assigné à string avec strictNullChecks activé. Ici, T[K] n’est pas un paramètre nu, donc la condition n’est pas distributive. L’union est examinée comme un bloc.
5.2. Handlers pour événements typés de manière stricte
Voici un exemple plus proche du code réel. Vous avez un tableau d’événements pour les commandes et vous voulez générer le type pour les handlers :
type OrderEvents = {
created: { orderId: string; total: number };
paid: { orderId: string; paidAt: Date };
cancelled: { orderId: string; reason: string };
};
type EventHandlers<E> = {
[K in keyof E as `on${Capitalize<string & K>}`]: (payload: E[K]) => void;
};
const handlers: EventHandlers<OrderEvents> = {
onCreated: (p) => console.log(`Commande créée ${p.orderId}: ${p.total}`),
onPaid: (p) => console.log(`Paiement à ${p.paidAt.toISOString()}`),
onCancelled: (p) => console.log(`Annulée pour: ${p.reason}`),
};
handlers.onCreated({ orderId: "o-1", total: 250_000 });
Commande créée o-1: 250000
Le paramètre p dans chaque handler est déduit du type correct, donc vous n’avez pas besoin de le déclarer à nouveau. Quand vous ajoutez l’événement refunded à OrderEvents, TypeScript signale immédiatement une erreur car handlers manque de onRefunded.
5.3. Implémentation runtime pour le type généré
Les mapped types n’existent qu’à la compilation. Pour avoir un objet réel, vous devez toujours écrire la logique :
export function toGetters<T extends object>(obj: T): Getters<T> {
const result: Record<string, () => unknown> = {};
for (const key of Object.keys(obj) as (keyof T & string)[]) {
const name = `get${key.charAt(0).toUpperCase()}${key.slice(1)}`;
result[name] = () => obj[key];
}
return result as unknown as Getters<T>;
}
Pourquoi avons-nous besoin d’une conversion de type ? TypeScript ne peut pas prouver que la boucle runtime produit la forme correcte de Getters<T>. Le transtypage as unknown as est votre engagement. Par conséquent, ce code doit absolument être testé.
6. Tester les types avec Vitest
Vitest dispose de expectTypeOf pour vérifier les types, ainsi que des assertions runtime ordinaires :
import { describe, it, expect, expectTypeOf } from "vitest";
import type { Order } from "./order";
import { toGetters } from "./getters";
describe("utility types", () => {
it("PickByType garde seulement les propriétés avec un type correspondant", () => {
expectTypeOf<PickByType<Order, number>>().toEqualTypeOf<{ total: number }>();
expectTypeOf<PickByType<Order, string>>()
.toEqualTypeOf<{ id: string; customerId: string }>();
});
it("UnwrapPromise retire une couche de Promise", () => {
expectTypeOf<UnwrapPromise<Promise<Order>>>().toEqualTypeOf<Order>();
expectTypeOf<UnwrapPromise<number>>().toEqualTypeOf<number>();
});
it("toGetters retourne les getters avec la bonne valeur", () => {
const getters = toGetters({ id: "c-1", name: "An", email: "an@example.com" });
expectTypeOf(getters.getName).returns.toEqualTypeOf<string>();
expect(getters.getName()).toBe("An");
expect(getters.getEmail()).toBe("an@example.com");
// @ts-expect-error le getter n'existe pas, doit signaler une erreur de compilation
getters.getPhone;
});
});
Exécutez les tests avec vérification de type :
npx vitest run --typecheck
Notez les points suivants :
- À l’exécution,
expectTypeOfne fait rien. La vérification réelle se produit quand le compilateur analyse le fichier. Vous avez donc besoin du flag--typecheck, ou exécuternpx tsc --noEmitdans CI. // @ts-expect-errorest utilisé pour les tests négatifs. Si la ligne ci-dessous n’a plus d’erreur, par exemple si quelqu’un rend le type plus loose, cette directive elle-même causera une erreur de compilation.- Le test runtime pour
toGettersvous aide à détecter les cas où la logique runtime ne correspond pas au type promis.
7. Erreurs courantes et quand ne pas utiliser
Oublier string & K quand on utilise template literal. Écrire `get${Capitalize<K>}` causera une erreur, car K pourrait être number | symbol.
Ne pas prêter attention à la distributivité. Vous écrivez IsString<string | number> et espérez recevoir false, mais le résultat est boolean, c’est-à-dire true | false. Si vous voulez traiter l’union entière comme un bloc, enveloppez-la dans un tuple.
Confusion quand on utilise extends avec any. any fait que la condition retourne l’union des deux branches. Par exemple, IsString<any> est boolean.
Perdre les modificateurs lors du mapping sur des clés non homomorphes. Un mapped type [K in "id" | "total"]: Order[K] ne garde pas readonly ou ? de Order. Seule la forme [K in keyof T] préserve les modificateurs. Si vous devez sélectionner certaines clés tout en gardant les modificateurs, utilisez Pick.
Récursion trop profonde. Un type comme DeepPartial<T> appliquant la récursion sur des objets imbriqués peut causer l’erreur Type instantiation is excessively deep and possibly infinite. Cela ralentit aussi visiblement l’éditeur. De plus, Date, Map et les tableaux sont aussi object, donc la condition T[K] extends object les capture souvent par erreur.
Note : N’utilisez pas les mapped types et conditional types juste pour « avoir l’air intelligent ». Si un type n’est utilisé qu’à un seul endroit et change rarement, une interface écrite à la main sera beaucoup plus facile à lire. Les messages d’erreur des types imbriqués 3–4 niveaux sont très difficiles à comprendre pour vos collègues. Préférez les utilitaires existants (
Partial,Pick,Omit,Record,Exclude,ReturnType,Awaited) avant d’écrire les vôtres.
Croire que le type protège l’exécution. Un mapped type ne valide pas les données de l’API. Si le JSON retourné manque un champ, Getters<Customer> est toujours « correct » à la compilation mais erroné à l’exécution. Pour les données externes, vous devez ajouter une étape de validation à l’exécution.
8. Conclusion
Cet article a présenté deux outils essentiels pour générer des types en TypeScript :
- Mapped types, avec
[K in keyof T], permettent de parcourir les clés, ajouter ou retirerreadonly/?, renommer et filtrer les clés avecas. - Conditional types, avec
T extends U ? X : Y, permettent de brancher au niveau du type. Ils se distribuent sur les unions et extraient les sous-types avecinfer.
Quand vous combinez les deux techniques, vous pouvez écrire des types comme PickByType ou EventHandlers. Ces types se mettent à jour automatiquement quand le type source change. Testez toujours vos types avec expectTypeOf et @ts-expect-error. Exécutez --typecheck dans CI. Et gardez ces types assez simples pour que vos collègues puissent les comprendre.