Les types template literal dans TypeScript par l'exemple
1. Aperçu
Les types template literal vous permettent d’utiliser la syntaxe backtick directement dans le système de types de TypeScript. Cela vous permet de concaténer, transformer et analyser les types de chaînes au moment de la compilation. Cet article passe en revue chaque fonctionnalité avec des exemples concrets : fusionner les unions, renommer les clés dans les mapped types, extraire les paramètres d’une route et écrire des émetteurs d’événements typés sûrs. Après cette lecture, vous saurez quand utiliser cette technique. Vous saurez également comment la tester et où elle peut ralentir le compilateur ou produire des messages d’erreur difficiles à comprendre.
Tous les exemples de cet article utilisent TypeScript 5.x avec strict: true.
2. Syntaxe de base
Un type template literal ressemble exactement à une template string à l’exécution. La différence est que la partie interpolée ${...} contient un type, pas une valeur :
type OrderId = `ORD-${number}`;
const ok: OrderId = "ORD-1024";
// const bad: OrderId = "INV-1024";
// Error: Type '"INV-1024"' is not assignable to type '`ORD-${number}`'.
Le type OrderId accepte toute chaîne commençant par ORD- suivie d’une chaîne représentant un nombre. Vous pouvez interpoler string, number, bigint, boolean, null, undefined et les types literal correspondants.
Un exemple familier est la valeur CSS avec unité :
type Pixel = `${number}px`;
function setWidth(el: HTMLElement, width: Pixel): void {
el.style.width = width;
}
setWidth(document.body, "320px"); // OK
// setWidth(document.body, "320"); // Error: unité manquante
Le compilateur détecte maintenant l’erreur d’unité manquante. Avant, cette erreur s’affichait uniquement lorsque vous ouviez le navigateur.
3. Combinaison avec les unions : produit cartésien
Quand la partie interpolée est une union, TypeScript distribue le template sur chaque membre. S’il y a plusieurs unions, le résultat est toute combinaison possible :
type Size = "sm" | "md" | "lg";
type Variant = "primary" | "danger";
type ButtonClass = `btn-${Size}-${Variant}`;
Survolez ButtonClass dans l’éditeur, vous verrez :
type ButtonClass = "btn-sm-primary" | "btn-sm-danger" | "btn-md-primary"
| "btn-md-danger" | "btn-lg-primary" | "btn-lg-danger"
Ici, 3 × 2 = 6 membres sont générés automatiquement. Quand vous ajoutez "xl" à Size, ButtonClass se met à jour automatiquement. Vous n’avez rien d’autre à modifier.
Note : Le nombre de combinaisons croît exponentiellement. Si l’union résultante dépasse 100 000 membres, TypeScript signale l’erreur « Expression produces a union type that is too complex to represent ». N’associez pas plusieurs grandes unions, par exemple une union de 1 000 codes de produits avec une union de 200 codes de pays.
4. Les types de transformation de chaîne prédéfinis
TypeScript fournit quatre types intrinsèques pour convertir la casse :
type Event = "orderCreated";
type A = Uppercase<Event>; // "ORDERCREATED"
type B = Lowercase<Event>; // "ordercreated"
type C = Capitalize<Event>; // "OrderCreated"
type D = Uncapitalize<"OrderCreated">; // "orderCreated"
Ils sont intégrés au compilateur, il n’est pas nécessaire de les écrire en TypeScript. Ces quatre types accompagnent généralement les template literals pour créer des noms selon une convention, par exemple on${Capitalize<K>} ou ${Uppercase<K>}_ENV.
type ConfigKey = "databaseUrl" | "apiToken";
type EnvName = `APP_${Uppercase<ConfigKey>}`;
// "APP_DATABASEURL" | "APP_APITOKEN"
5. Renommer les clés dans les mapped types
C’est là où les types template literal brillent le plus. La clause as dans les mapped types vous permet de renommer chaque clé lors de la création d’un nouveau type.
5.1. Générer des getters à partir d’une interface
interface Customer {
name: string;
email: string;
loyaltyPoints: number;
}
type Getters<T> = {
[K in keyof T & string as `get${Capitalize<K>}`]: () => T[K];
};
type CustomerGetters = Getters<Customer>;
Résultat :
type CustomerGetters = {
getName: () => string;
getEmail: () => string;
getLoyaltyPoints: () => number;
}
Explication de chaque partie :
keyof T & stringfiltre les clés de typenumberetsymbol. La raison est queCapitalizen’accepte questring. Si vous omettez& string, vous obtenez l’erreur « Type ‘K’ does not satisfy the constraint ‘string’ ».as \get${Capitalize}` convertitnameengetName`.() => T[K]préserve le type de retour correct de la propriété d’origine.
Une classe qui implémente ce type sera immédiatement interceptée par le compilateur si un getter manque ou si le type de retour est incorrect :
class CustomerView implements CustomerGetters {
constructor(private readonly data: Customer) {}
getName = () => this.data.name;
getEmail = () => this.data.email;
getLoyaltyPoints = () => this.data.loyaltyPoints;
}
5.2. Filtrer les clés en retournant never
Si l’expression dans as produit never, cette clé est exclue du résultat. Vous pouvez combiner cela avec les template literals pour sélectionner les clés selon un préfixe :
interface PaymentService {
chargeCard(amount: number): Promise<void>;
chargeWallet(amount: number): Promise<void>;
refund(paymentId: string): Promise<void>;
}
type ChargeMethods<T> = {
[K in keyof T as K extends `charge${string}` ? K : never]: T[K];
};
type OnlyCharge = ChargeMethods<PaymentService>;
// { chargeCard: ...; chargeWallet: ... }
refund ne correspond pas au motif charge${string} donc il est exclu.
6. Analyser les chaînes avec infer
Les template literals ne servent pas seulement à concaténer des chaînes. Vous pouvez aussi les utiliser pour extraire des chaînes dans les types conditionnels, en combinaison avec infer.
6.1. Extraire une partie simple
type StripPrefix<S extends string> =
S extends `ORD-${infer Rest}` ? Rest : never;
type X = StripPrefix<"ORD-1024">; // "1024"
type Y = StripPrefix<"INV-1024">; // never
Lors de la correspondance du motif, TypeScript assigne la partie restante à Rest.
6.2. Extraire les paramètres d’une route
C’est l’exemple pratique le plus courant. À partir de la chaîne "/users/:userId/orders/:orderId", nous voulons le type "userId" | "orderId" :
type ExtractParams<Path extends string> =
Path extends `${string}:${infer Param}/${infer Rest}`
? Param | ExtractParams<`/${Rest}`>
: Path extends `${string}:${infer Param}`
? Param
: never;
type P = ExtractParams<"/users/:userId/orders/:orderId">;
// "userId" | "orderId"
Comment cela fonctionne :
- La première branche correspond à
"/users/" + ":" + "userId" + "/" + "orders/:orderId". Chaque placeholder correspond à la première occurrence du caractère littéral qui le suit. DoncParamest"userId". - L’appel récursif avec
"/orders/:orderId". Cette fois, il n’y a pas de/après le paramètre, donc la première branche échoue. - La deuxième branche correspond et retourne
"orderId". - Si la chaîne n’a pas de
:, le résultat estnever.
Maintenant, utilisez-le pour une fonction de construction d’URL :
type RouteParams<Path extends string> = {
[K in ExtractParams<Path>]: string;
};
function buildUrl<Path extends string>(
path: Path,
params: RouteParams<Path>,
): string {
const values = params as Record<string, string>;
return path.replace(/:(\w+)/g, (_, key: string) =>
encodeURIComponent(values[key]),
);
}
const url = buildUrl("/users/:userId/orders/:orderId", {
userId: "42",
orderId: "ORD-7",
});
console.log(url);
// buildUrl("/users/:userId", { id: "42" });
// Error: Object literal may only specify known properties, and 'id' does not exist...
Sortie :
/users/42/orders/ORD-7
Le générique Path est déduit en tant que type littéral à partir du premier argument. De cette façon, l’objet params est vérifié précisément : clé manquante, clé supplémentaire ou nom mal orthographié génèrent tous une erreur. Le cast vers Record<string, string> dans le corps de la fonction est nécessaire. TypeScript ne peut pas prouver qu’une key extraite d’une expression régulière à l’exécution appartient à ExtractParams<Path>.
7. Exemple complet : émetteur d’événements typés sûr
En combinant les techniques ci-dessus, nous avons un émetteur pour le domaine des commandes :
type OrderEvents = {
created: { orderId: string; total: number };
shipped: { orderId: string; carrier: string };
cancelled: { orderId: string; reason: string };
};
type EventHandlers<E> = {
[K in keyof E & string as `on${Capitalize<K>}`]?: (payload: E[K]) => void;
};
class OrderEmitter {
constructor(private readonly handlers: EventHandlers<OrderEvents>) {}
emit<K extends keyof OrderEvents>(event: K, payload: OrderEvents[K]): void {
const name = `on${event[0].toUpperCase()}${event.slice(1)}` as
`on${Capitalize<K>}`;
const handler = this.handlers[name] as
((p: OrderEvents[K]) => void) | undefined;
handler?.(payload);
}
}
const emitter = new OrderEmitter({
onCreated: (p) => console.log(`Created ${p.orderId}: ${p.total}`),
onShipped: (p) => console.log(`Shipped via ${p.carrier}`),
});
emitter.emit("created", { orderId: "ORD-1", total: 250_000 });
emitter.emit("shipped", { orderId: "ORD-1", carrier: "GHN" });
// emitter.emit("shipped", { orderId: "ORD-1", reason: "x" }); // Error
Sortie :
Created ORD-1: 250000
Shipped via GHN
Ce que vous obtenez :
- Les noms des handlers
onCreated,onShipped,onCancelledsont générés automatiquement à partir deOrderEvents. - Le paramètre
pdans chaque handler est déduit au type de payload correct, sans déclaration explicite. emit("shipped", ...)exige que le payload aitcarrier.
Les deux as dans emit sont où nous disons au compilateur ce qu’il ne peut pas déduire. La chaîne construite à l’exécution ne porte pas d’information de type. Gardez ces casts dans une portée réduite, dans la partie implémentation. L’API publique reste entièrement sûre.
8. Tester les types avec Vitest
La logique au niveau des types nécessite également des tests. Sinon, une refonte pourrait transformer ExtractParams en string sans que personne ne le remarque. Installez Vitest :
npm install -D vitest typescript
Écrivez des tests pour le comportement des types et le comportement à l’exécution :
// route.test.ts
import { describe, it, expect, expectTypeOf } from "vitest";
import { buildUrl, type ExtractParams } from "./route";
describe("ExtractParams", () => {
it("extrait les bons noms de paramètres", () => {
expectTypeOf<ExtractParams<"/users/:userId/orders/:orderId">>()
.toEqualTypeOf<"userId" | "orderId">();
});
it("retourne never quand il n'y a pas de paramètre", () => {
expectTypeOf<ExtractParams<"/health">>().toBeNever();
});
});
describe("buildUrl", () => {
it("remplace les paramètres et encode les valeurs", () => {
const url = buildUrl("/customers/:customerId", { customerId: "a b" });
expect(url).toBe("/customers/a%20b");
});
it("refuse les clés incorrectes", () => {
// @ts-expect-error - 'id' n'est pas un paramètre de la route
buildUrl("/customers/:customerId", { id: "1" });
});
});
Exécutez :
npx tsc --noEmit && npx vitest run
expectTypeOf est une no-op à l’exécution. Ses erreurs n’apparaissent que lors de la vérification des types, donc l’étape tsc --noEmit (ou vitest --typecheck) est obligatoire. La directive @ts-expect-error est aussi une assertion. Si la ligne suivante n’a pas d’erreur de type, tsc signale « Unused ‘@ts-expect-error’ directive ».
9. Erreurs courantes et quand ne pas l’utiliser
Note : Les types template literal n’existent que lors de la compilation. Les données provenant d’une API, d’un formulaire ou de
process.envne sont pas vérifiées du tout. Avec les données externes, vous devez toujours valider à l’exécution avec une regex ou une bibliothèque de schéma.
Quelques pièges spécifiques :
${number}est plus large que vous le pensez."1e3px"ou"0.5px"sont tous deux valides avec`${number}px`. Si vous avez besoin d’un « entier positif », les template literals ne peuvent pas l’exprimer. Validez à l’exécution.- Explosion d’union. Comme dans la section 3, combiner plusieurs grandes unions ralentit considérablement le compilateur et peut produire une erreur de complexité excessive.
- Récursion profonde. Les types récursifs comme
ExtractParamsont une limite de profondeur. Avec une chaîne très longue, vous rencontrerez « Type instantiation is excessively deep and possibly infinite ». Les routes normales ne touchent pas à cette limite, mais un analyseur de chaînes général pourrait. - Messages d’erreur difficiles à lire. Avec les types complexes, les erreurs peuvent s’étendre sur des dizaines de lignes. Si vos collègues doivent lire cette erreur quotidiennement, le coût peut dépasser les bénéfices.
- Omission de
& string.keyof Tpeut contenirnumber | symbol, etCapitalizeles rejette. - Abus. Si l’ensemble de valeurs est petit et fixe, écrivez directement l’union littérale. Écrire
type Size = "sm" | "md"est plus lisible que n’importe quel type généré automatiquement.
À utiliser quand il existe une source de vérité (interface, objet d’événement, chaîne de route) et que vous voulez que d’autres types en soient déduits. Ne l’utilisez pas juste pour faire preuve de compétences au niveau des types.
10. Conclusion
Les types template literal élèvent les chaînes au statut de citoyens de première classe du système de types de TypeScript. Cet article a couvert les techniques principales :
- concaténer des chaînes et les distribuer sur les unions ;
- convertir la casse avec
Uppercase,Capitalize, etc. ; - renommer et filtrer les clés dans les mapped types avec
as; - extraire des chaînes avec
infer, y compris récursivement.
Appliquée aux bons endroits, comme les paramètres de route, les noms d’événements ou les getters, vous éliminez toute une classe d’erreurs de typage qui n’auraient été détectées qu’à l’exécution. N’oubliez pas de tester les types avec expectTypeOf et @ts-expect-error. Soyez attentif à la complexité des unions et à la profondeur de la récursion. Et validez toujours les données provenant de l’extérieur du programme.