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 & string filtre les clés de type number et symbol. La raison est que Capitalize n’accepte que string. 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 :

  1. La première branche correspond à "/users/" + ":" + "userId" + "/" + "orders/:orderId". Chaque placeholder correspond à la première occurrence du caractère littéral qui le suit. Donc Param est "userId".
  2. 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.
  3. La deuxième branche correspond et retourne "orderId".
  4. Si la chaîne n’a pas de :, le résultat est never.

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, onCancelled sont générés automatiquement à partir de OrderEvents.
  • Le paramètre p dans chaque handler est déduit au type de payload correct, sans déclaration explicite.
  • emit("shipped", ...) exige que le payload ait carrier.

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.env ne 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 ExtractParams ont 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 T peut contenir number | symbol, et Capitalize les 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.

typescripttemplate literal typestype systemgenericsvitest