TypeScript Mapped Types : keyof, modificateurs, key remapping with as, template literal types, Capitalize, route params typed et patterns Zod inference.
Pourquoi ces deux features changent tout
Le système de types TypeScript ne se résume pas à string, number et interface. Deux fonctionnalités avancées permettent de construire des types à partir d'autres types, de manière programmatique : les Mapped Types et les Template Literal Types.
Ensemble, ils forment un véritable méta-langage au niveau des types : on peut transformer automatiquement la forme d'un objet, générer des noms de propriétés à partir de chaînes de caractères, ou créer des contrats d'API fortement typés sans dupliquer une seule ligne de code.
- Réduire la duplication de types dans les projets complexes.
- Détecter à la compilation des erreurs de nommage (événements, routes, propriétés CSS).
- Rendre les refactorisations plus sûres : changer un nom à un seul endroit propage la correction dans tout le système de types.
- Comprendre comment les Utility Types natifs (
Readonly,Required,Partial…) sont implémentés en interne.
keyof, typeof et les types conditionnels de base. Si ce n'est pas le cas, consultez les articles précédents de la série TypeScript sur AngularForAll.
Mapped Types : syntaxe et modificateurs
Un Mapped Type parcourt les clés d'un type existant et produit un nouveau type en appliquant une transformation sur chaque propriété. La syntaxe de base suit ce schéma : { [K in keyof T]: TransformationType }, où K représente chaque clé de T.
// Syntaxe fondamentale d'un Mapped Type
// [K in keyof T] → itère sur chaque clé K du type T
// T[K] → conserve le type original de la propriété
// Exemple : rendre toutes les propriétés optionnelles (reproduit Partial<T>)
type MonPartial<T> = {
[K in keyof T]?: T[K];
// ^ le '?' rend la propriété optionnelle
};
// Exemple : rendre toutes les propriétés readonly (reproduit Readonly<T>)
type MonReadonly<T> = {
readonly [K in keyof T]: T[K];
// 'readonly' interdit toute réassignation après initialisation
};
// Type source de démonstration
interface Utilisateur {
id: number;
nom: string;
email: string;
}
type UtilisateurOptionnel = MonPartial<Utilisateur>;
// Résultat inféré : { id?: number; nom?: string; email?: string }
type UtilisateurGele = MonReadonly<Utilisateur>;
// Résultat inféré : { readonly id: number; readonly nom: string; readonly email: string }
Modificateurs + et - : ajouter ou supprimer readonly et optionnel
Les modificateurs + et - permettent d'ajouter ou de retirer les qualificatifs readonly et ?. C'est particulièrement utile pour créer l'opposé d'un type existant.
// Supprimer 'readonly' de toutes les propriétés
// Le '-' placé devant 'readonly' supprime ce modificateur
type Mutable<T> = {
-readonly [K in keyof T]: T[K];
};
// Supprimer l'optionnalité (reproduit Required<T>)
// Le '-?' supprime le modificateur '?' et rend chaque propriété obligatoire
type MonRequired<T> = {
[K in keyof T]-?: T[K];
};
// Exemple : type source avec modificateurs mixtes
interface Config {
readonly host: string;
port?: number;
readonly timeout?: number;
}
type ConfigEditable = Mutable<Config>;
// Résultat : { host: string; port?: number; timeout?: number }
// 'readonly' retiré de host et timeout, optionnalité conservée
type ConfigComplete = MonRequired<Config>;
// Résultat : { readonly host: string; port: number; readonly timeout: number }
// Optionnalité retirée de port et timeout, 'readonly' conservé
+ est implicite. Écrire readonly [K in keyof T] est équivalent à +readonly [K in keyof T]. Le + explicite n'est utile que pour la lisibilité dans des types très complexes.
Mapper sur une union de strings
Un Mapped Type n'est pas limité à keyof T. On peut également itérer sur une union littérale de strings pour construire des objets entièrement typés à la demande.
// Itérer sur une union littérale de strings plutôt que keyof T
type CouleurBase = 'rouge' | 'vert' | 'bleu';
// Crée un objet avec exactement ces trois clés, chacune de type string
type PaletteCouleurs = {
[Couleur in CouleurBase]: string;
};
// Résultat : { rouge: string; vert: string; bleu: string }
// Cas pratique : configuration des endpoints d'une API REST
type Ressource = 'utilisateurs' | 'produits' | 'commandes';
type EndpointsAPI = {
[R in Ressource]: string; // chaque ressource → son URL d'endpoint
};
// TypeScript vérifie que les 3 clés sont présentes à l'initialisation
const api: EndpointsAPI = {
utilisateurs: '/api/v1/users',
produits: '/api/v1/products',
commandes: '/api/v1/orders',
};
Remappage de clés avec la clause as
Depuis TypeScript 4.1, la clause as dans un Mapped Type permet de transformer le nom de la clé, pas seulement sa valeur. La syntaxe devient { [K in keyof T as NouvelleClé]: T[K] }. C'est ici que les Mapped Types deviennent vraiment puissants, surtout combinés aux Template Literal Types.
// Clause 'as' : renommer les clés lors du mapping
// [K in keyof T as NouveauNom] → K conserve la valeur originale
// mais la clé du type résultant est renommée en NouveauNom
// Exemple : préfixer toutes les clés d'un objet avec 'get'
type Getters<T> = {
// Capitalize<string & K> → convertit 'nom' en 'Nom' pour obtenir 'getNom'
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
interface Utilisateur {
id: number;
nom: string;
email: string;
}
type UtilisateurGetters = Getters<Utilisateur>;
// Résultat inféré par TypeScript :
// {
// getId: () => number;
// getNom: () => string;
// getEmail: () => string;
// }
Filtrer des clés avec as et never
Retourner never dans la clause as revient à supprimer la clé du type résultant. C'est l'équivalent d'un filter() mais au niveau du système de types.
// Supprimer les propriétés dont la valeur est une fonction
// Si T[K] extends Function → clé supprimée via 'never'
// Sinon → clé conservée sous son nom original K
type SansMethodes<T> = {
[K in keyof T as T[K] extends Function ? never : K]: T[K];
};
interface ServiceUtilisateur {
id: number;
nom: string;
email: string;
sauvegarder(): void; // méthode → sera exclue du résultat
supprimer(): Promise<void>; // méthode → sera exclue du résultat
}
type DonneesUtilisateur = SansMethodes<ServiceUtilisateur>;
// Résultat : { id: number; nom: string; email: string }
// Les deux méthodes ont été filtrées, seules les données restent
never dans une clause as est la technique standard pour filtrer des clés dans un Mapped Type. On peut combiner ce filtre avec n'importe quel type conditionnel pour exclure précisément les propriétés non désirées.
Template Literal Types : construire des types depuis des strings
Les Template Literal Types, introduits en TypeScript 4.1, permettent de construire de nouveaux types string en interpolant d'autres types strings, exactement comme les template literals JavaScript — mais au niveau des types, uniquement à la compilation.
// Syntaxe : backtick + interpolation de type entre ${ }
// Fonctionne comme les template literals JS, mais pour les types
type Salutation = `Bonjour, ${string}`;
// Accepte toute string commençant par "Bonjour, "
const s1: Salutation = 'Bonjour, Alice'; // OK
const s2: Salutation = 'Bonjour, monde'; // OK
// const s3: Salutation = 'Salut !'; // ERREUR de compilation
// Les unions se distribuent automatiquement dans les Template Literal Types
type Direction = 'haut' | 'bas' | 'gauche' | 'droite';
type AnimationDirection = `animation-${Direction}`;
// Résultat : 'animation-haut' | 'animation-bas' | 'animation-gauche' | 'animation-droite'
// Distribution sur deux unions : produit cartésien automatique
type Taille = 'sm' | 'md' | 'lg';
type Couleur = 'primary' | 'danger';
type ClasseBootstrap = `btn-${Couleur}-${Taille}`;
// Résultat : 'btn-primary-sm' | 'btn-primary-md' | 'btn-primary-lg'
// | 'btn-danger-sm' | 'btn-danger-md' | 'btn-danger-lg'
Les quatre helpers de casse intégrés
TypeScript fournit quatre helpers de transformation de casse, utilisables uniquement dans un contexte de types. Ils sont implémentés directement dans le compilateur (non en TypeScript pur) :
| Helper | Transformation | Exemple | Résultat |
|---|---|---|---|
Uppercase<S> |
Tout en majuscules | Uppercase<'hello'> |
'HELLO' |
Lowercase<S> |
Tout en minuscules | Lowercase<'WORLD'> |
'world' |
Capitalize<S> |
Première lettre en majuscule | Capitalize<'nom'> |
'Nom' |
Uncapitalize<S> |
Première lettre en minuscule | Uncapitalize<'Nom'> |
'nom' |
// Utilisation pratique des helpers de casse
// Générer des types de méthodes HTTP à partir d'une union
type MethodeHTTP = 'get' | 'post' | 'put' | 'delete' | 'patch';
// Capitalize : première lettre en majuscule pour les noms de méthodes
type NomMethodeService = `${Capitalize<MethodeHTTP>}Ressource`;
// Résultat : 'GetRessource' | 'PostRessource' | 'PutRessource'
// | 'DeleteRessource' | 'PatchRessource'
// Uppercase : créer les noms de constantes de méthodes HTTP
type ConstanteHTTP = `METHODE_${Uppercase<MethodeHTTP>}`;
// Résultat : 'METHODE_GET' | 'METHODE_POST' | 'METHODE_PUT'
// | 'METHODE_DELETE' | 'METHODE_PATCH'
// Lowercase : normaliser les noms de routes
type NomRoute = 'AccueilPage' | 'ProfilPage' | 'AdminPage';
type SlugRoute = `/${Lowercase<Uncapitalize<NomRoute>>}`;
// Résultat : '/accueilpage' | '/profilpage' | '/adminpage'
Combinaison : EventMap, getters et setters typés
La vraie puissance émerge quand on combine Mapped Types et Template Literal Types. On peut générer automatiquement des types complets à partir d'une seule source de vérité, sans duplication.
Cas 1 : EventMap typé pour un système d'événements
// Source de vérité : les événements possibles et leurs payloads associés
// Tout le système d'événements se déduit de cette seule interface
interface EvenementsApp {
connexion: { userId: number; timestamp: Date };
deconnexion: { userId: number };
achatProduit: { produitId: string; prix: number; devise: string };
erreur: { code: number; message: string };
}
// Mapped Type + Template Literal : génère les handlers on* automatiquement
type HandlersEvenements = {
// K → clé originale ex: 'connexion'
// as `on${Capitalize<string & K>}` → renomme en 'onConnexion'
// payload → reçoit le bon type de données via EvenementsApp[K]
[K in keyof EvenementsApp as `on${Capitalize<string & K>}`]:
(payload: EvenementsApp[K]) => void;
};
// Résultat inféré par TypeScript :
// {
// onConnexion: (payload: { userId: number; timestamp: Date }) => void;
// onDeconnexion: (payload: { userId: number }) => void;
// onAchatProduit: (payload: { produitId: string; prix: number; devise: string }) => void;
// onErreur: (payload: { code: number; message: string }) => void;
// }
// Utilisation : TypeScript vérifie que les bons champs sont accédés
const handlers: Partial<HandlersEvenements> = {
onConnexion: ({ userId, timestamp }) => {
// TypeScript sait que userId est number et timestamp est Date
console.log(`User ${userId} connecté à ${timestamp.toISOString()}`);
},
onErreur: ({ code, message }) => {
// TypeScript sait que code est number et message est string
console.error(`Erreur ${code}: ${message}`);
},
};
Cas 2 : Getters et setters typés générés automatiquement
// Source de vérité : l'état d'un store ou d'un service
interface EtatPanier {
articles: string[];
total: number;
codePromo: string | null;
estChargement: boolean;
}
// Mapped Type : génère les signatures des getters (get + Capitalize de chaque clé)
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
// Mapped Type : génère les signatures des setters (set + Capitalize de chaque clé)
type Setters<T> = {
[K in keyof T as `set${Capitalize<string & K>}`]: (valeur: T[K]) => void;
// 'valeur' reçoit exactement le type de la propriété correspondante
};
// Intersection : combine état, getters et setters en un seul contrat
type StorePanier = EtatPanier & Getters<EtatPanier> & Setters<EtatPanier>;
// TypeScript impose que l'implémentation expose TOUTES ces méthodes :
// getArticles() → string[]
// setArticles(val: string[]) → void
// getTotal() → number
// setTotal(val: number) → void
// getCodePromo() → string | null
// setCodePromo(val: string | null) → void
// getEstChargement() → boolean
// setEstChargement(val: boolean) → void
class PanierService implements StorePanier {
articles: string[] = [];
total: number = 0;
codePromo: string|null = null;
estChargement: boolean = false;
// Le compilateur signale immédiatement toute méthode oubliée
getArticles() { return this.articles; }
setArticles(v: string[]) { this.articles = v; }
getTotal() { return this.total; }
setTotal(v: number) { this.total = v; }
getCodePromo() { return this.codePromo; }
setCodePromo(v: string|null) { this.codePromo = v; }
getEstChargement() { return this.estChargement; }
setEstChargement(v: boolean) { this.estChargement = v; }
}
EtatPanier, TypeScript signale immédiatement les méthodes manquantes dans toute implémentation du contrat — sans écrire la moindre ligne de code supplémentaire pour les types.
Comment les Utility Types utilisent ces patterns
Les Utility Types fournis par TypeScript sont tous implémentés en interne avec des Mapped Types. Comprendre leur implémentation renforce la maîtrise de ces outils et permet de créer ses propres variantes.
// Implémentation interne de Partial<T>
// Rend toutes les propriétés optionnelles via le modificateur '?'
type Partial<T> = {
[P in keyof T]?: T[P];
};
// Implémentation interne de Required<T>
// Retire l'optionnalité de toutes les propriétés via '-?'
type Required<T> = {
[P in keyof T]-?: T[P];
};
// Implémentation interne de Readonly<T>
// Rend toutes les propriétés en lecture seule
type Readonly<T> = {
readonly [P in keyof T]: T[P];
};
// Implémentation interne de Record<K, V>
// K extends keyof any → K peut être string, number ou symbol
type Record<K extends keyof any, T> = {
[P in K]: T;
};
// Implémentation interne de Pick<T, K>
// K extends keyof T → TypeScript garantit que K est une clé valide de T
type Pick<T, K extends keyof T> = {
[P in K]: T[P];
};
// Implémentation interne de Omit<T, K>
// Exclut K de keyof T via Exclude, puis fait un Pick sur le reste
type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;
// Exclude<keyof T, K> → retourne les clés de T qui n'appartiennent pas à K
| Utility Type | Pattern utilisé | Modificateur clé |
|---|---|---|
Partial<T> |
Mapped Type sur keyof T |
? (ajout optionnel) |
Required<T> |
Mapped Type sur keyof T |
-? (suppression optionnel) |
Readonly<T> |
Mapped Type sur keyof T |
readonly (ajout) |
Record<K, V> |
Mapped Type sur union K |
Aucun |
Pick<T, K> |
Mapped Type sur sous-union K |
Aucun (filtre par K) |
Omit<T, K> |
Pick + Exclude | never via Exclude |
DeepReadonly<T> récursif ou un NullableFields<T> qui rend certaines propriétés nullables selon une condition.
Cas d'usage Angular : API, formulaires et routes typés
Ces techniques trouvent des applications immédiates dans les projets Angular. Voici trois cas concrets qui apportent une valeur réelle au quotidien des développeurs.
Cas 1 : Client HTTP fortement typé par ressource
// Définir les ressources et leurs types de réponse dans une seule interface
// C'est la source de vérité pour tout le client HTTP
interface ModeleAPI {
utilisateurs: { id: number; nom: string; email: string }[];
produits: { id: string; libelle: string; prix: number }[];
commandes: { id: string; statut: string; total: number }[];
}
// Générer automatiquement les méthodes GET pour chaque ressource
// 'get' + Capitalize → getUtilisateurs, getProduits, getCommandes
type ClientAPI = {
[K in keyof ModeleAPI as `get${Capitalize<string & K>}`]:
() => Observable<ModeleAPI[K]>;
};
// Implémentation du service Angular : TypeScript valide le contrat complet
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class ApiService implements ClientAPI {
constructor(private http: HttpClient) {}
// Chaque méthode doit retourner Observable du bon type de données
getUtilisateurs(): Observable<ModeleAPI['utilisateurs']> {
return this.http.get<ModeleAPI['utilisateurs']>('/api/utilisateurs');
}
getProduits(): Observable<ModeleAPI['produits']> {
return this.http.get<ModeleAPI['produits']>('/api/produits');
}
getCommandes(): Observable<ModeleAPI['commandes']> {
return this.http.get<ModeleAPI['commandes']>('/api/commandes');
}
// Si on oublie une méthode → erreur de compilation immédiate
}
Cas 2 : Formulaires réactifs type-safe avec FormControls
// Transformer un modèle de données en FormGroup fortement typé
// FormControls<T> remplace chaque propriété de T par un FormControl du bon type
import { FormControl, FormGroup } from '@angular/forms';
type FormControls<T> = {
[K in keyof T]: FormControl<T[K]>;
// Chaque clé K → FormControl typé avec T[K]
};
// Modèle de formulaire d'inscription
interface FormulaireInscription {
nom: string;
email: string;
motDePasse: string;
accepterCGU: boolean;
}
// Type du FormGroup déduit automatiquement depuis FormulaireInscription
type GroupeInscription = FormGroup<FormControls<FormulaireInscription>>;
// Création du formulaire : TypeScript valide le type de chaque FormControl
function creerFormulaire(): GroupeInscription {
return new FormGroup({
nom: new FormControl<string>('', { nonNullable: true }),
email: new FormControl<string>('', { nonNullable: true }),
motDePasse: new FormControl<string>('', { nonNullable: true }),
accepterCGU: new FormControl<boolean>(false, { nonNullable: true }),
// Si on ajoute un champ à FormulaireInscription sans l'ajouter ici
// → TypeScript signale une erreur de type sur le FormGroup
}) as GroupeInscription;
}
Cas 3 : Routes Angular typées sans magic strings
// Définir les paramètres requis par chaque route de l'application
// Fini les magic strings incorrects passés à router.navigate()
interface ParametresRoutes {
'profil': { userId: string };
'produit-detail': { produitId: string; slug: string };
'commande': { commandeId: string };
'accueil': Record<string, never>; // aucun paramètre requis
}
// Mapped Type + Template Literal : génère une fonction de navigation par route
type NavigatriceTypee = {
[K in keyof ParametresRoutes as `aller${Capitalize<string & K>}`]:
(params: ParametresRoutes[K]) => string[];
// Chaque fonction reçoit exactement les bons paramètres pour cette route
};
// Implémentation : fabrique les segments de chemin pour router.navigate()
const navigatrice: NavigatriceTypee = {
allerProfil: ({ userId }) =>
['/profil', userId],
// TypeScript impose { produitId, slug } car défini dans ParametresRoutes
allerProduit_detail: ({ produitId, slug }) =>
['/produit', produitId, slug],
allerCommande: ({ commandeId }) =>
['/commande', commandeId],
allerAccueil: () =>
['/'],
};
// Utilisation dans un composant Angular (injection de Router)
// navigatrice.allerProfil({ userId: '42' }) → ['/profil', '42'] OK
// navigatrice.allerProfil({ typo: '42' }) → ERREUR de compilation
Accessibilité et responsive Bootstrap 4
Les composants qui affichent les données produites par ces patterns typés doivent rester accessibles et responsives. Quelques bonnes pratiques à appliquer systématiquement :
- Ajouter
aria-labelsur les tableaux générés dynamiquement pour que les lecteurs d'écran annoncent leur rôle et leur contenu. - Toujours associer un
<label>ouaria-labelledbyaux champs de formulaires créés avecFormControls<T>. - Envelopper les tableaux comparatifs dans
<div class="table-responsive">pour le scroll horizontal sur mobile. - Conserver un contraste suffisant dans les blocs de code (ratio WCAG AA minimum 4.5:1).
- Tester les listes de données générées depuis des types avec les grilles Bootstrap 4 (
col-md-*) sur mobile avant mise en production.
<!-- Exemple de table accessible générée depuis un type API -->
<div class="table-responsive">
<table
class="table table-bordered table-hover table-md"
aria-label="Liste des utilisateurs chargés depuis l'API"
role="grid"
>
<thead>
<tr>
<!-- scope="col" relie chaque en-tête à sa colonne -->
<th scope="col">ID</th>
<th scope="col">Nom</th>
<th scope="col">Email</th>
</tr>
</thead>
<tbody>
<!-- trackBy évite le re-rendu inutile de toute la liste -->
<tr *ngFor="let u of utilisateurs$ | async; trackBy: trackById">
<td>{{ u.id }}</td>
<td>{{ u.nom }}</td>
<td>{{ u.email }}</td>
</tr>
</tbody>
</table>
</div>
Key remapping avec as — filtrer et renommer les clés
Depuis TypeScript 4.1, la clause as dans un mapped type permet de transformer les noms de clés au passage. Cette feature débloque des patterns auparavant impossibles : générateurs de getters/setters, filtres par type, conversions de naming convention.
Renommage avec template literal
// Génère getName(), getAge(), getEmail() depuis User
type Getters<T> = {
[K in keyof T as `get${Capitalize<K & string>}`]: () => T[K];
};
interface User { name: string; age: number; email: string }
type UserGetters = Getters<User>;
// {
// getName: () => string;
// getAge: () => number;
// getEmail: () => string;
// }
Filtrage de clés avec never
// Garder uniquement les propriétés d'un type donné
type PickByType<T, V> = {
[K in keyof T as T[K] extends V ? K : never]: T[K];
};
interface Form {
name: string;
age: number;
email: string;
isActive: boolean;
tags: string[];
}
type StringFields = PickByType<Form, string>;
// { name: string; email: string }
type BooleanFields = PickByType<Form, boolean>;
// { isActive: boolean }
Snake_case → camelCase automatique
type CamelCase<S extends string> =
S extends `${infer Head}_${infer Tail}`
? `${Head}${Capitalize<CamelCase<Tail>>}`
: S;
type CamelCaseKeys<T> = {
[K in keyof T as CamelCase<K & string>]: T[K];
};
// Conversion automatique d'une réponse API snake_case
interface ApiUser {
user_id: number;
first_name: string;
last_login_date: string;
}
type AppUser = CamelCaseKeys<ApiUser>;
// {
// userId: number;
// firstName: string;
// lastLoginDate: string;
// }
Ce dernier pattern est particulièrement utile pour les apps qui consomment des APIs Python/Ruby/Rails (souvent en snake_case) tout en gardant une convention JavaScript camelCase côté frontend, sans dupliquer les interfaces ni utiliser de transformer runtime.
Template literal types — patterns avancés
Les template literal types combinés à infer permettent de parser des strings au niveau du système de types. C'est ce qui rend possibles les libs comme tRPC (chemins API typés), React Router v7 (params extraits du path), Tailwind avec ses utility classes typées.
Extraire les paramètres d'une route URL
type ExtractParams<Path extends string> =
Path extends `${string}:${infer Param}/${infer Rest}`
? { [K in Param | keyof ExtractParams<Rest>]: string }
: Path extends `${string}:${infer Param}`
? { [K in Param]: string }
: {};
type UserPostParams = ExtractParams<'/users/:userId/posts/:postId'>;
// { userId: string; postId: string }
function navigate<P extends string>(path: P, params: ExtractParams<P>) {
// ... type-safe : params est inféré depuis path
}
navigate('/users/:userId', { userId: '42' }); // ✓
navigate('/users/:userId', { foo: 'bar' }); // ❌ erreur TS
Split string en tuple au niveau types
type Split<S extends string, D extends string> =
S extends `${infer Head}${D}${infer Tail}`
? [Head, ...Split<Tail, D>]
: [S];
type Parts = Split<'a.b.c.d', '.'>;
// ['a', 'b', 'c', 'd']
Type-safe property paths
type Paths<T, Depth extends number = 3> =
[Depth] extends [0]
? never
: T extends object
? { [K in keyof T]:
K extends string
? T[K] extends object
? K | `${K}.${Paths<T[K], MinusOne<Depth>>}`
: K
: never
}[keyof T]
: never;
type MinusOne<N extends number> =
['', 0, 1, 2, 3, 4, 5, 6, 7, 8, 9][N];
interface AppState {
user: { profile: { email: string; address: { city: string } } };
settings: { theme: 'light' | 'dark' };
}
type StatePath = Paths<AppState>;
// 'user' | 'user.profile' | 'user.profile.email' | 'user.profile.address'
// | 'user.profile.address.city' | 'settings' | 'settings.theme'
C'est exactement le pattern utilisé par lodash.get typé (_.get(obj, 'user.profile.email')) ou par les libs i18n modernes (t('errors.auth.invalidPassword')). Le compilateur valide chaque path à la compilation, autocomplete fonctionne, refactorings préservent les références.
Patterns réels en production
Inférence d'API depuis un schéma Zod
import { z } from 'zod';
// Schéma déclaratif
const ApiSchema = {
'GET /users': { response: z.array(z.object({ id: z.number(), name: z.string() })) },
'POST /users': {
body: z.object({ name: z.string(), age: z.number() }),
response: z.object({ id: z.number() }),
},
'GET /users/:id': {
params: z.object({ id: z.string() }),
response: z.object({ id: z.number(), name: z.string() }),
},
} as const;
// Type dérivé automatiquement
type ApiRoutes = keyof typeof ApiSchema;
// 'GET /users' | 'POST /users' | 'GET /users/:id'
type ApiResponse<R extends ApiRoutes> =
z.infer<typeof ApiSchema[R]['response']>;
async function call<R extends ApiRoutes>(route: R): Promise<ApiResponse<R>> {
// implémentation type-safe
throw new Error('TODO');
}
const users = await call('GET /users'); // typé [{ id: number; name: string }]
Form fields typés depuis un modèle
type FormFieldName<T> = keyof T & string;
type FormErrors<T> = Partial<Record<FormFieldName<T>, string>>;
interface RegisterForm {
email: string;
password: string;
confirmPassword: string;
acceptTerms: boolean;
}
const errors: FormErrors<RegisterForm> = {
email: 'Email invalide',
password: 'Trop court',
// ❌ erreur TS : 'unknownField' doesn't exist on RegisterForm
};
Mini-projet appliqué — client API typé avec routes paramétrées
Voici le cas d'usage le plus impressionnant des mapped types + template literal types : un client HTTP entièrement typé qui infère les paramètres de route, le shape du body, et le type de réponse uniquement depuis un schéma déclaratif. C'est le pattern qui fait la force de tRPC, des libs i18n modernes et des wrappers REST type-safe.
1. Schéma déclaratif des endpoints
// Le schéma est la SOURCE DE VÉRITÉ — frontend et backend s'y réfèrent
const apiSchema = {
'GET /users': {
response: {} as { id: string; email: string; fullName: string }[],
},
'GET /users/:userId': {
response: {} as { id: string; email: string; fullName: string },
},
'POST /users': {
body: {} as { email: string; fullName: string; password: string },
response: {} as { id: string; email: string; fullName: string },
},
'PATCH /users/:userId': {
body: {} as Partial<{ email: string; fullName: string }>,
response: {} as { id: string; email: string; fullName: string },
},
'GET /orders/:orderId/items/:itemId': {
response: {} as { id: string; productId: string; quantity: number },
},
} as const;
type ApiSchema = typeof apiSchema;
type ApiRoute = keyof ApiSchema; // 'GET /users' | 'GET /users/:userId' | ...
2. Extraire les paramètres d'URL via template literal types
Pour la mécanique de infer utilisée ici, voir le guide des conditional types avec infer.
// Extrait récursivement les segments :name d'un path
type ExtractPathParams<Path extends string> =
Path extends `${string}:${infer Param}/${infer Rest}`
? Param | ExtractPathParams<`/${Rest}`>
: Path extends `${string}:${infer Param}`
? Param
: never;
// Garde uniquement la partie après le method (GET / POST / PATCH)
type RouteToPath<R extends string> =
R extends `${string} ${infer Path}` ? Path : never;
// Type des params d'une route — Record automatique
type RouteParams<R extends ApiRoute> =
ExtractPathParams<RouteToPath<R>> extends never
? Record<string, never>
: { [K in ExtractPathParams<RouteToPath<R>>]: string };
// Test compile-time
type T1 = RouteParams<'GET /users/:userId'>;
// { userId: string }
type T2 = RouteParams<'GET /orders/:orderId/items/:itemId'>;
// { orderId: string; itemId: string }
type T3 = RouteParams<'GET /users'>;
// Record<string, never> (pas de params)
3. Inférer le shape du body et de la réponse
type HasBody<R extends ApiRoute> = ApiSchema[R] extends { body: infer B } ? B : never;
type ResponseOf<R extends ApiRoute> = ApiSchema[R] extends { response: infer Res } ? Res : never;
// Test compile-time
type B = HasBody<'POST /users'>;
// { email: string; fullName: string; password: string }
type R = ResponseOf<'GET /users/:userId'>;
// { id: string; email: string; fullName: string }
4. Client HTTP final — type-safe end-to-end
// Type union des routes AVEC body
type RoutesWithBody = {
[R in ApiRoute]: ApiSchema[R] extends { body: unknown } ? R : never
}[ApiRoute];
// Type union des routes SANS body
type RoutesWithoutBody = Exclude<ApiRoute, RoutesWithBody>;
// Signature surchargée — TypeScript choisit la bonne selon la route
function callApi<R extends RoutesWithoutBody>(
route: R,
params: RouteParams<R>,
): Promise<ResponseOf<R>>;
function callApi<R extends RoutesWithBody>(
route: R,
params: RouteParams<R>,
body: HasBody<R>,
): Promise<ResponseOf<R>>;
async function callApi(route: string, params: Record<string, string>, body?: unknown) {
const [method, pathTpl] = route.split(' ');
let path = pathTpl;
for (const [k, v] of Object.entries(params)) {
path = path.replace(`:${k}`, encodeURIComponent(v));
}
const res = await fetch(path, {
method,
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) throw new Error(`${route} failed: ${res.status}`);
return res.json();
}
5. Consommation côté composant — autocomplete magique
// Récupérer tous les users
const users = await callApi('GET /users', {});
// users typé : { id: string; email: string; fullName: string }[]
// Récupérer un user par ID
const user = await callApi('GET /users/:userId', { userId: 'u-42' });
// user typé : { id: string; email: string; fullName: string }
// Créer un user
const created = await callApi('POST /users', {}, {
email: 'alice@example.com',
fullName: 'Alice Dupont',
password: 'Sup3r$ecret',
});
// created typé : { id: string; email: string; fullName: string }
// Mettre à jour un user (PATCH)
const updated = await callApi('PATCH /users/:userId',
{ userId: 'u-42' },
{ fullName: 'Alice D.' }
);
// Erreurs de typage attrapées à la compilation :
// callApi('GET /users/:userId', { wrong: 'x' }); ❌ 'wrong' n'existe pas
// callApi('POST /users', {}); ❌ body manquant
// callApi('UNKNOWN_ROUTE', {}); ❌ route inconnue
:userId, oubli d'un param, mauvais type) qui représentaient ~12 % des bugs runtime sur le frontend. Coût initial : ~2 jours pour mettre en place le schéma et le typage. ROI : visible dès la première semaine.
6. Bonus : i18n typée avec template literal
// Le fichier de traduction est la source de vérité
const fr = {
'errors.auth.invalidPassword': 'Mot de passe invalide',
'errors.auth.userNotFound': 'Utilisateur introuvable',
'cart.empty': 'Votre panier est vide',
'cart.total': 'Total : {amount}€',
} as const;
type TranslationKey = keyof typeof fr;
// 'errors.auth.invalidPassword' | 'errors.auth.userNotFound' | 'cart.empty' | 'cart.total'
// Extraire les variables d'interpolation depuis la string littérale
type ExtractInterpolations<S extends string> =
S extends `${string}{${infer V}}${infer Rest}`
? V | ExtractInterpolations<Rest>
: never;
type Variables<K extends TranslationKey> =
ExtractInterpolations<typeof fr[K]> extends never
? Record<string, never>
: { [V in ExtractInterpolations<typeof fr[K]>]: string | number };
function t<K extends TranslationKey>(key: K, vars: Variables<K>): string {
let template = fr[key] as string;
for (const [k, v] of Object.entries(vars)) {
template = template.replace(`{${k}}`, String(v));
}
return template;
}
// Usage type-safe
t('cart.empty', {}); // ✓
t('cart.total', { amount: 42.50 }); // ✓
// t('cart.total', {}); // ❌ 'amount' manquant
// t('unknown.key', {}); // ❌ clé inconnue
Pour approfondir l'écosystème de la métaprogrammation TypeScript, lire également les utility types qui s'appuient sur ces patterns et les nouveautés TypeScript 5.x (satisfies, const type parameters) qui complètent ce toolkit.
Conclusion
Les Mapped Types et les Template Literal Types sont deux des outils les plus puissants du système de types TypeScript. Ils permettent de passer d'une approche déclarative — écrire chaque type à la main — à une approche générative : dériver des types complets depuis une source de vérité unique, sans duplication.
En maîtrisant ces patterns, vous serez capable de créer vos propres Utility Types métier, de typer fortement vos APIs Angular, vos formulaires réactifs et vos routes, et de comprendre en profondeur comment TypeScript lui-même est conçu. La prochaine étape naturelle est d'explorer les types conditionnels récursifs, qui permettent d'aller encore plus loin dans la transformation de types imbriqués.
Partial, Required et Readonly vous-même sans regarder la documentation. C'est le meilleur exercice pour ancrer ces patterns. Ajoutez ensuite un Mapped Type avec clause as sur un modèle existant de votre projet pour mesurer immédiatement le gain en sécurité de type.