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.

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.
// ❌ 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 immediatelyDie 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.
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 eventEvent-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.
// 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.
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.
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 scaleDie 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.
| Anwendungsfall | Lohnt es sich? | Begründung |
|---|---|---|
| Event-Namen im Event-Bus | ✅ Ja | Wird aus Domänentypen abgeleitet, erkennt Tippfehler direkt an der Quelle |
| Extraktion von Routenparametern | ✅ Ja | Erspart manuell gepflegte Params-Interfaces |
| Berechtigungs-Strings | ✅ Ja | Verhindert, dass veraltete Strings Umbenennungen überleben |
| Namen von Design-Tokens | ✅ Ja | Erzwingt Konventionen ohne eigene Lint-Regeln |
| Tiefes rekursives String-Parsing | ⚠️ Vielleicht | Mit --diagnostics messen — kann tsc spürbar verlangsamen |
| Laufzeitvalidierung ersetzen | ❌ Nein | Typen 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.
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
- 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.
inferermöglicht strukturelles Parsen — rekursive Conditional Types können Parameternamen aus Pfad-Strings und ähnlichen Grammatiken in präzise typisierte Objekte extrahieren.- Mit Mapped Types zu vollständigen Interfaces kombinieren — eine Union aus Template Literal Types in ein Interface zu verwandeln, erspart ganze Kategorien manueller Synchronisation.
- Die Compiler-Performance im Blick behalten — tief rekursive Template Literal Types verursachen messbare Mehrkosten bei
tsc; nutze--diagnostics, um Regressionen frühzeitig zu erkennen. - 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.


