Template Literal Types in TypeScript anhand von Beispielen
1. Übersicht
Template Literal Types ermöglichen es Ihnen, die Backtick-Syntax direkt im Typensystem von TypeScript zu verwenden. Dadurch können Sie Stringtypen zur Compile-Zeit zusammensetzen, umwandeln und analysieren. Dieser Artikel behandelt jede Funktion anhand konkreter Beispiele: Union-Verkettung, Umbenennung von Schlüsseln in Mapped Types, Extrahieren von Parametern aus Routes und typensichere Event-Emitter. Nach dem Lesen werden Sie wissen, wann Sie diese Technik einsetzen sollten. Sie werden auch wissen, wie Sie sie testen und wo es leicht zu langsamen Compiler-Zeiten oder unverständlichen Fehlern kommen kann.
Alle Beispiele in diesem Artikel verwenden TypeScript 5.x mit strict: true.
2. Grundlegende Syntax
Ein Template Literal Type sieht genau wie ein Template String zur Laufzeit aus. Der Unterschied ist, dass der Interpolationsteil ${...} einen Typ enthält, keinen Wert:
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}`'.
Der Typ OrderId akzeptiert jeden String, der mit ORD- beginnt und dem eine numerische Zeichenfolge folgt. Sie können string, number, bigint, boolean, null, undefined und die entsprechenden Literal Types interpolieren.
Ein bekanntes Beispiel sind CSS-Werte mit Einheiten:
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: Einheit fehlt
Der Compiler kann jetzt Fehler beim Vergessen von Einheiten erfassen. Vorher wurde dieser Fehler erst offensichtlich, wenn Sie den Browser öffneten.
3. Mit Union kombinieren: Kartesisches Produkt
Wenn der Interpolationsteil eine Union ist, verteilt TypeScript das Template über jeden Mitglied. Wenn es mehrere Unions gibt, ist das Ergebnis jede mögliche Kombination:
type Size = "sm" | "md" | "lg";
type Variant = "primary" | "danger";
type ButtonClass = `btn-${Size}-${Variant}`;
Wenn Sie den Mauszeiger über ButtonClass im Editor halten, sehen Sie:
type ButtonClass = "btn-sm-primary" | "btn-sm-danger" | "btn-md-primary"
| "btn-md-danger" | "btn-lg-primary" | "btn-lg-danger"
Hier werden 3 × 2 = 6 Mitglieder automatisch generiert. Wenn Sie "xl" zu Size hinzufügen, aktualisiert sich ButtonClass automatisch. Sie müssen nichts weiter ändern.
Hinweis: Die Anzahl der Kombinationen wächst exponentiell. Wenn die resultierende Union über 100.000 Mitglieder hinausgeht, meldet TypeScript einen Fehler “Expression produces a union type that is too complex to represent”. Verketten Sie nicht mehrere große Unions miteinander, z.B. eine Union aus 1.000 Produktcodes mit einer Union aus 200 Ländercodes.
4. Eingebaute Stringtransformationen
TypeScript bietet vier Intrinsic Types für Groß-/Kleinschreibung:
type Event = "orderCreated";
type A = Uppercase<Event>; // "ORDERCREATED"
type B = Lowercase<Event>; // "ordercreated"
type C = Capitalize<Event>; // "OrderCreated"
type D = Uncapitalize<"OrderCreated">; // "orderCreated"
Sie sind im Compiler vorinstalliert, nicht in TypeScript geschrieben. Diese vier Typen begleiten normalerweise Template Literals, um Namen nach Konvention zu erstellen, z.B. on${Capitalize<K>} oder ${Uppercase<K>}_ENV.
type ConfigKey = "databaseUrl" | "apiToken";
type EnvName = `APP_${Uppercase<ConfigKey>}`;
// "APP_DATABASEURL" | "APP_APITOKEN"
5. Schlüssel in Mapped Types umbenennen
Hier kommen Template Literal Types am meisten zum Einsatz. Die as-Klausel in Mapped Types ermöglicht es Ihnen, jeden Schlüssel umzubenennen, wenn Sie einen neuen Typ erstellen.
5.1. Getter aus einer Schnittstelle generieren
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>;
Ergebnis:
type CustomerGetters = {
getName: () => string;
getEmail: () => string;
getLoyaltyPoints: () => number;
}
Erklärung der einzelnen Teile:
keyof T & stringfiltert Schlüssel vom Typnumberundsymbolheraus. Der Grund ist, dassCapitalizenurstringakzeptiert. Wenn Sie& stringweglassen, erhalten Sie einen Fehler “Type ‘K’ does not satisfy the constraint ‘string’”.as \get${Capitalize}` ändertnameingetName`.() => T[K]behält den korrekten Rückgabetyp der ursprünglichen Eigenschaft bei.
Eine Klasse, die diesen Typ implementiert, wird vom Compiler sofort mit einem Fehler gekennzeichnet, wenn ein Getter fehlt oder der falsche Typ zurückgegeben wird:
class CustomerView implements CustomerGetters {
constructor(private readonly data: Customer) {}
getName = () => this.data.name;
getEmail = () => this.data.email;
getLoyaltyPoints = () => this.data.loyaltyPoints;
}
5.2. Schlüssel durch Zurückgeben von never filtern
Wenn der Ausdruck in as never erzeugt, wird dieser Schlüssel aus dem Ergebnis ausgeschlossen. Sie können dies mit Template Literals kombinieren, um Schlüssel nach Präfix auszuwählen:
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 entspricht nicht dem Muster charge${string} und wird daher ausgeschlossen.
6. Strings mit infer analysieren
Template Literals dienen nicht nur zum Verketten von Strings. Sie können sie auch zur Zerlegung von Strings in Conditional Types verwenden, kombiniert mit infer.
6.1. Einen Teil einfach extrahieren
type StripPrefix<S extends string> =
S extends `ORD-${infer Rest}` ? Rest : never;
type X = StripPrefix<"ORD-1024">; // "1024"
type Y = StripPrefix<"INV-1024">; // never
Bei einer Musterübereinstimmung weist TypeScript den Rest Rest zu.
6.2. Parameter aus der Route extrahieren
Dies ist das praktischste Beispiel. Aus dem String "/users/:userId/orders/:orderId" wollen wir den Typ "userId" | "orderId" erhalten:
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"
So funktioniert es:
- Der erste Zweig stimmt überein mit
"/users/" + ":" + "userId" + "/" + "orders/:orderId". Jeder Platzhalter stimmt mit dem ersten Vorkommen des Literal-Zeichens überein, das ihm folgt. Daher istParam"userId". - Rekursiver Typ mit
"/orders/:orderId". Diesmal gibt es kein/nach dem Parameter, daher schlägt der erste Zweig fehl. - Der zweite Zweig stimmt überein und gibt
"orderId"zurück. - Wenn der String kein
:enthält, ist das Ergebnisnever.
Verwenden Sie es jetzt für eine URL-Builder-Funktion:
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...
Ausgabe:
/users/42/orders/ORD-7
Der generische Typ Path wird als Literal Type aus dem ersten Argument hergeleitet. Dadurch wird das Objekt params genau überprüft: fehlende Schlüssel, zusätzliche Schlüssel oder Tippfehler in Namen werden alle gemeldet. Die Umwandlung in Record<string, string> im Funktionsrumpf ist erforderlich. TypeScript kann nicht beweisen, dass ein Schlüssel, der zur Laufzeit aus dem Regex extrahiert wird, zu ExtractParams<Path> gehört.
7. Beispiel kombiniert: typensicherer Event-Emitter
Wenn wir die obigen Techniken kombinieren, haben wir einen Emitter für die Order-Domain:
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
Ausgabe:
Created ORD-1: 250000
Shipped via GHN
Was Sie bekommen:
- Handler-Namen
onCreated,onShipped,onCancelledwerden automatisch vonOrderEventsgeneriert. - Der Parameter
pin jedem Handler wird auf den korrekten Payload-Typ hergeleitet, keine Deklaration erforderlich. emit("shipped", ...)erfordert, dass die Payloadcarrierhat.
Die beiden as-Umwandlungen in emit sind wo wir dem Compiler sagen, was es nicht selbst ableiten kann. Ein String, der zur Laufzeit konstruiert wird, enthält keine Typinformationen. Halten Sie solche Typumwandlungen in kleinen Bereichen, im Implementierungsteil. Die öffentliche API ist immer noch vollständig typensicher.
8. Typen mit Vitest testen
Logik auf der Typebene muss auch getestet werden. Wenn nicht, könnte ein Refactoring ExtractParams zu string ändern, ohne dass dies jemand bemerkt. Installieren Sie Vitest:
npm install -D vitest typescript
Schreiben Sie Tests sowohl für Typverhalten als auch für Laufzeitverhalten:
// route.test.ts
import { describe, it, expect, expectTypeOf } from "vitest";
import { buildUrl, type ExtractParams } from "./route";
describe("ExtractParams", () => {
it("extrahiert Parameternamen korrekt", () => {
expectTypeOf<ExtractParams<"/users/:userId/orders/:orderId">>()
.toEqualTypeOf<"userId" | "orderId">();
});
it("gibt never zurück wenn keine Parameter vorhanden", () => {
expectTypeOf<ExtractParams<"/health">>().toBeNever();
});
});
describe("buildUrl", () => {
it("ersetzt Parameter und kodiert Werte", () => {
const url = buildUrl("/customers/:customerId", { customerId: "a b" });
expect(url).toBe("/customers/a%20b");
});
it("lehnt falschen Schlüssel ab", () => {
// @ts-expect-error - 'id' ist nicht ein Parameter der Route
buildUrl("/customers/:customerId", { id: "1" });
});
});
Führen Sie aus:
npx tsc --noEmit && npx vitest run
expectTypeOf ist zur Laufzeit eine No-Op. Seine Fehler zeigen sich nur beim Type-Checking, daher ist der Schritt tsc --noEmit (oder vitest --typecheck) erforderlich. Die Direktive @ts-expect-error ist auch eine Assertion. Wenn die Zeile darunter keinen Typ-Fehler mehr hat, meldet tsc “Unused ‘@ts-expect-error’ directive”.
9. Häufige Fehler und wann Sie es nicht verwenden sollten
Hinweis: Template Literal Types existieren nur zur Compile-Zeit. Daten von APIs, Formularen oder
process.envwerden überhaupt nicht überprüft. Bei externen Daten müssen Sie immer noch zur Laufzeit mit Regex oder einer Schema-Bibliothek validieren.
Einige spezifische Fallstricke:
${number}ist breiter als Sie denken."1e3px"oder"0.5px"sind beide gültig für`${number}px`. Wenn Sie “positive ganze Zahl” benötigen, kann Template Literal das nicht ausdrücken. Führen Sie zur Laufzeit eine Überprüfung durch.- Union bricht zusammen. Wie in Punkt 3 beschrieben, verlangsamt das Verketten mehrerer großer Unions den Compiler erheblich und kann sogar Fehler hervorrufen, die zu komplex sind.
- Tiefe Rekursion. Rekursive Typen wie
ExtractParamshaben eine Tiefenbegrenzung. Bei sehr langen Strings treffen Sie auf “Type instantiation is excessively deep and possibly infinite”. Normale Routes erreichen diesen Grenzwert nicht, aber universelle String-Parser könnten dies tun. - Schwer verständliche Fehlermeldungen. Bei komplexen Typen können Fehler Dutzende von Zeilen lang sein. Wenn Ihre Kollegen diesen Fehler täglich lesen müssen, könnte der Nachteil größer als der Nutzen sein.
- Fehlendes
& string.keyof Tkannnumber | symbolenthalten, undCapitalizewird sie ablehnen. - Übernutzung. Wenn der Wertesatz klein und fest ist, schreiben Sie einfach direkt ein Literal Union. Das Schreiben von
type Size = "sm" | "md"ist lesbarer als jeder automatisch generierte Typ.
Verwenden Sie es, wenn Sie eine einzige Wahrheitsquelle haben (Schnittstelle, Event-Objekt, Route-String) und andere Typen automatisch davon ableiten möchten. Verwenden Sie es nicht nur zur Demonstration von Type-Level-Fähigkeiten.
10. Fazit
Template Literal Types machen Strings zu First-Class Citizens im Typensystem von TypeScript. Der Artikel hat die Haupttechniken behandelt:
- Verkettung von Strings und Verteilung über Unions;
- Groß-/Kleinschreibungskonvertierung mit
Uppercase,Capitalize…; - Umbenennung und Filterung von Schlüsseln in Mapped Types mit
as; - String-Parsing mit
infer, auch rekursiv.
Wenn Sie es richtig einsetzen, z.B. bei Route-Parametern, Event-Namen oder Gettern, können Sie eine ganze Klasse von Tippfehlern eliminieren, die früher nur zur Laufzeit erkannt wurden. Denken Sie daran, Typen mit expectTypeOf und @ts-expect-error zu testen. Beachten Sie die Komplexität der Union und die Rekursionstiefe. Und validieren Sie immer Daten, die von außerhalb des Programms kommen.