Zum Inhalt springen

Template Literal Types: Ausdrucksstarke, typsichere APIs

Nutze Template Literal Types, um Event-Namen, Routenmuster und Berechtigungen im Typsystem zu kodieren — ohne Laufzeitprüfungen, ohne String-Drift.

4 Min. Lesezeit
TypeScript-Code, der zeigt, wie Template Literal Types Event-Namen-Unions aus Domänentypen ableiten

Template Literal Types kamen mit TypeScript 4.1 und die meisten Teams nutzen sie für ein, zwei Kniffe, bevor sie zur Tagesordnung übergehen. Damit bleibt der Großteil ihres Potenzials ungenutzt. Systematisch eingesetzt, lassen sich damit Verträge auf Protokollebene direkt ins Typsystem kodieren — Event-Busse, Routen-Register, Berechtigungssysteme, Namenskonventionen für CSS-Tokens. Kein Laufzeit-Overhead, vollständige IDE-Autovervollständigung, Fehler werden schon beim Kompilieren erkannt.

Die Lücke, die Template Literal Types schließen

APIs, die stark auf Strings setzen, sind am schwersten typsicher zu halten. Event-Emitter, Route-Handler, CSS-in-JS-Helfer — sie alle nehmen Strings entgegen, und ohne Template Literal Types sind diese Strings entweder string (nutzlos) oder eine handgeschriebene Union (fragil und umständlich). Die handgeschriebene Union ist das eigentliche Problem: Sie driftet auseinander, sobald ein Domänenbegriff umbenannt wird.

tstypescript
// ❌ Anything compiles — typos become silent runtime bugs
type EventName = string;
emitter.on("user:created", handler);
emitter.on("usr:created", handler); // typo — no error until runtime
 
// ✅ Derived from domain types — only valid events compile
type UserEvents = `user:${"created" | "updated" | "deleted"}`;
type OrderEvents = `order:${"placed" | "fulfilled" | "cancelled"}`;
type AppEvent = UserEvents | OrderEvents;
 
emitter.on("user:created", handler); // ✅
emitter.on("usr:created", handler);  // ❌ Type error — caught immediately

Die Union ist nicht mehr handgeschrieben. Sie wird aus kleineren Bausteinen abgeleitet, sodass jeder typisierte Emitter ein neues User-Event automatisch mitbekommt, sobald du es hinzufügst.

Einen typsicheren Event-Bus bauen

Richtig lohnend wird es, wenn du Template Literal Types mit Generics kombinierst, um jeden Event-Namen an einen bestimmten Payload-Typ zu koppeln.

tstypescript
type EventPayloadMap = {
  "user:created": { userId: string; email: string };
  "user:deleted": { userId: string; deletedAt: Date };
  "order:placed": { orderId: string; total: number };
  "order:cancelled": { orderId: string; reason: string };
};
 
type AppEvent = keyof EventPayloadMap;
 
interface TypedEmitter {
  emit<E extends AppEvent>(event: E, payload: EventPayloadMap[E]): void;
  on<E extends AppEvent>(event: E, handler: (payload: EventPayloadMap[E]) => void): void;
}
 
// The compiler enforces the correct payload shape for each event
emitter.emit("user:created", { userId: "u_1", email: "a@example.com" }); // ✅
emitter.emit("user:created", { userId: "u_1", total: 99 });              // ❌ Wrong shape
emitter.emit("order:shipped", { orderId: "o_1" });                       // ❌ Unknown event

Event-Name und Payload sind auf Typebene gekoppelt. Wird ein Event-Schlüssel in EventPayloadMap umbenannt, entstehen überall dort Fehler, wo der alte Name verwendet wurde — kein mühsames Durchsuchen des Codes, kein Hoffen, wirklich alles gefunden zu haben.

Routenparameter mit infer extrahieren

Template Literal Types lassen sich mit infer in Conditional Types kombinieren, um Struktur aus String-Mustern herauszuziehen. Das ist der Mechanismus hinter jedem typsicheren Router.

tstypescript
// Recursively extract parameter names from "/users/:id/posts/:postId"
type ExtractParams<Path extends string> =
  Path extends `${infer _Prefix}:${infer Param}/${infer Rest}`
    ? Param | ExtractParams<`/${Rest}`>
    : Path extends `${infer _Prefix}:${infer Param}`
      ? Param
      : never;
 
type RouteParams<Path extends string> = {
  [K in ExtractParams<Path>]: string;
};
 
function defineRoute<Path extends string>(
  path: Path,
  handler: (params: RouteParams<Path>, req: Request) => Response,
) {
  return { path, handler };
}
 
defineRoute("/users/:id/posts/:postId", (params) => {
  // params.id ✅, params.postId ✅
  // params.userId ❌ — Property does not exist on type
  return new Response(`Post ${params.postId} by user ${params.id}`);
});

Der rekursive Conditional Type durchläuft den Pfad-String und sammelt dabei die Parameternamen ein. RouteParams wird vollständig aus dem Pfad-Literal abgeleitet — kein separates Schema-Objekt, keine Decorators, kein Codegenerierungsschritt.

Berechtigungssysteme ohne String-Drift

Berechtigungs-Strings sind eine weitere Stelle, an der handgeschriebene Unions ständig von der Realität abweichen. Ein Mapped Type über einer Union aus Template Literal Types löst dieses Problem.

tstypescript
type Resource = "user" | "order" | "product" | "invoice";
type Action = "create" | "read" | "update" | "delete";
type Permission = `${Resource}:${Action}`;
 
type Role = {
  name: string;
  permissions: Permission[];
};
 
const adminRole: Role = {
  name: "admin",
  permissions: ["user:create", "user:delete", "order:read", "invoice:read"],
};
 
const brokenRole: Role = {
  name: "broken",
  permissions: ["user:destroy"],    // ❌ "destroy" is not a valid action
};

Fügst du der Resource-Union eine neue Ressource hinzu, erweitert sich der Typ Permission sofort mit. Jeder fest codierte String, der zu keiner gültigen Kombination passt, wird zu einem Kompilierfehler — auch veraltete Berechtigungen in Rollendefinitionen, die eine Umbenennung überlebt haben.

CSS-Design-Tokens mit begrenzten Skalen

Design-Systeme, die Namen für CSS Custom Properties generieren, profitieren davon, Namenskonventionen schon beim Kompilieren zu erzwingen, statt sich auf Lint-Regeln zu verlassen, die erst im Nachhinein anschlagen.

tstypescript
type Scale = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
type SpacingToken = `spacing-${Scale}`;
type ColorToken = `color-${"primary" | "neutral" | "danger" | "success"}-${Scale}`;
type DesignToken = SpacingToken | ColorToken;
 
function token(name: DesignToken): string {
  return `var(--${name})`;
}
 
token("spacing-4");          // ✅
token("color-primary-3");    // ✅
token("color-brand-3");      // ❌ "brand" is not in the color set
token("spacing-13");         // ❌ 13 exceeds the scale
~

Die Union aus numerischen Literalen 1 | 2 | ... | 12 hält den Typ begrenzt. Ist deine Skala offen, funktioniert ${number}, erlaubt aber auch 1.5 und -3 — validiere daher zur Laufzeit, wenn Präzision wichtig ist.

Wann sich Template Literal Types lohnen

Sie sind nicht umsonst. Rekursive Typen verlangsamen den TypeScript-Compiler, und allzu raffinierte Typ-Mechanik lässt sich sechs Monate später nur noch mühsam debuggen.

AnwendungsfallLohnt es sich?Begründung
Event-Namen im Event-Bus✅ JaWird aus Domänentypen abgeleitet, erkennt Tippfehler direkt an der Quelle
Extraktion von Routenparametern✅ JaErspart manuell gepflegte Params-Interfaces
Berechtigungs-Strings✅ JaVerhindert, dass veraltete Strings Umbenennungen überleben
Namen von Design-Tokens✅ JaErzwingt Konventionen ohne eigene Lint-Regeln
Tiefes rekursives String-Parsing⚠️ VielleichtMit --diagnostics messen — kann tsc spürbar verlangsamen
Laufzeitvalidierung ersetzen❌ NeinTypen werden zur Laufzeit gelöscht; nutze Zod/Valibot an den I/O-Grenzen

Die letzte Zeile ist entscheidend. Template Literal Types wirken ausschließlich zur Kompilierzeit. Ein Berechtigungs-String aus einem JWT, ein Routenmuster aus einer Datenbankzeile, ein Konfigurationswert aus der Umgebung — keiner davon profitiert direkt vom Typsystem. Laufzeitvalidierung bleibt an jeder Systemgrenze notwendig; Template Literal Types und Laufzeitvalidatoren ergänzen sich, statt zu konkurrieren.

i

Führe tsc --diagnostics regelmäßig aus, wenn die Komplexität deiner Typen wächst. Die Kennzahl Instantiation count zeigt dir, ob rekursive Typen langsam teuer werden. Steigt sie über einige Millionen, solltest du die Rekursion vereinfachen oder die Union aufteilen.

Die wichtigsten Erkenntnisse

  1. Unions ableiten statt von Hand schreiben — setze Event-Namen, Berechtigungen und Tokens aus kleineren Domänentypen zusammen, damit die Union bei jeder Änderung automatisch synchron bleibt.
  2. infer ermöglicht strukturelles Parsen — rekursive Conditional Types können Parameternamen aus Pfad-Strings und ähnlichen Grammatiken in präzise typisierte Objekte extrahieren.
  3. Mit Mapped Types zu vollständigen Interfaces kombinieren — eine Union aus Template Literal Types in ein Interface zu verwandeln, erspart ganze Kategorien manueller Synchronisation.
  4. Die Compiler-Performance im Blick behalten — tief rekursive Template Literal Types verursachen messbare Mehrkosten bei tsc; nutze --diagnostics, um Regressionen frühzeitig zu erkennen.
  5. Typen werden zur Laufzeit gelöscht — validiere externe Strings an den I/O-Grenzen mit einer Laufzeit-Schema-Bibliothek; Sicherheit auf Typebene und Sicherheit zur Laufzeit decken unterschiedliche Risikoflächen ab.
Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX