Maîtrisez les Mapped Types et Template Literal Types TypeScript : syntaxe avancée, modificateurs readonly/optionnel, remappage de clés avec as, EventMap, getters typés et cas d'usage Angular réels.
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"
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>
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.