TypeScript : Mapped Types et Template Literal Types

🏷️ Front-end 📅 17/04/2026 18:00:00 👤 Mezgani said
Typescript Mapped Types Template Literal Types Types Avancés Typescript Avancé Type System
TypeScript : Mapped Types et Template Literal Types

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.
Prérequis : cet article suppose que vous connaissez déjà les génériques TypeScript, 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é
Astuce : le préfixe + 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
A retenir : retourner 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'
Note : les Template Literal Types n'existent qu'à la compilation. Ils n'ont aucune présence dans le code JavaScript généré. Leur seul rôle est d'informer le compilateur TypeScript des contraintes de nommage des strings.

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; }
}
A retenir : la combinaison élimine la duplication entre état, getters et setters. Si on ajoute une propriété à 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
Note : comprendre ces implémentations permet de créer ses propres Utility Types adaptés aux besoins métier de chaque projet — par exemple un 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
A retenir : ces trois patterns partagent la même philosophie. On définit la vérité métier une seule fois dans une interface, puis TypeScript dérive automatiquement les types des couches techniques. Tout changement dans le modèle se propage et déclenche les erreurs aux bons endroits, au moment de la 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-label sur les tableaux générés dynamiquement pour que les lecteurs d'écran annoncent leur rôle et leur contenu.
  • Toujours associer un <label> ou aria-labelledby aux champs de formulaires créés avec FormControls<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.

A retenir : commencez par reproduire 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.