Type Guard und Assertion-Funktionen in TypeScript
1. Übersicht
TypeScript verengt Typen (Narrowing) sehr gut mit typeof, instanceof oder ===. Wenn die Prüfungslogik komplexer wird und Sie sie in eine separate Funktion auslagern, kann der Compiler die Prüfungsergebnisse nicht mehr „sehen”.
Dieser Artikel stellt zwei Tools vor, die dieses Problem lösen: Type Guard (value is T) und Assertion-Funktionen (asserts value is T). Nach dieser Lektüre werden Sie wissen, wie Sie sie schreiben, unknown-Daten von APIs validieren, Tests für sie schreiben und erkennen, wann sie dem Compiler im Stillen lügen.
2. Narrowing mit vordefinierten Mitteln und seine Grenzen
Wir verwenden einen Order-Domain als durchgehendes Beispiel:
type PendingOrder = { status: "pending"; id: string; total: number };
type PaidOrder = { status: "paid"; id: string; total: number; paidAt: Date };
type CancelledOrder = { status: "cancelled"; id: string; reason: string };
type Order = PendingOrder | PaidOrder | CancelledOrder;
function describe(order: Order): string {
if (order.status === "paid") {
return `Bestellung ${order.id} bezahlt am ${order.paidAt.toISOString()}`;
}
return `Bestellung ${order.id} ist ${order.status}`;
}
Order ist eine Discriminated Union mit dem Diskriminator status. Im if-Zweig weiß TypeScript, dass order vom Typ PaidOrder ist, daher ist der Zugriff auf paidAt zulässig.
Jetzt möchten Sie diese Bedingung wiederverwenden und in eine separate Funktion auslagern:
function isPaid(order: Order): boolean {
return order.status === "paid";
}
function describe(order: Order): string {
if (isPaid(order)) {
return `Bezahlt am ${order.paidAt.toISOString()}`;
// ^^^^^^ Fehler
}
return "Noch nicht bezahlt";
}
error TS2339: Property 'paidAt' does not exist on type 'Order'.
Property 'paidAt' does not exist on type 'PendingOrder'.
Die Funktion isPaid gibt boolean zurück. Der Typ boolean trägt keine Informationen über order, daher kann der Compiler das Narrowing nicht durchführen. Type Guard wurde genau dafür entwickelt, dieses Problem zu lösen.
3. Benutzerdefinierte Type Guard
3.1. Syntax value is T
Sie müssen nur den Rückgabetyp von boolean zu einem Type Predicate ändern:
function isPaidOrder(order: Order): order is PaidOrder {
return order.status === "paid";
}
function describe(order: Order): string {
if (isPaidOrder(order)) {
return `Bezahlt am ${order.paidAt.toISOString()}`;
}
// hier: order: PendingOrder | CancelledOrder
return `Status: ${order.status}`;
}
console.log(describe({ status: "paid", id: "A1", total: 120, paidAt: new Date("2026-10-01") }));
console.log(describe({ status: "cancelled", id: "A2", reason: "Ausverkauft" }));
Bezahlt am 2026-10-01T00:00:00.000Z
Status: cancelled
Erklärung:
order is PaidOrderbedeutet: Wenn die Funktiontruezurückgibt, istordervom TypPaidOrder.- Zur Laufzeit gibt die Funktion immer noch nur
true/falsezurück. Der Teilisexistiert nur zur Compile-Zeit. - Im
false-Zweig schließt TypeScriptPaidOrderaus der Union aus.orderwird zuPendingOrder | CancelledOrder. - Der Parametername im Prädikat (
order) muss mit dem Namen eines Funktionsparameters übereinstimmen.
3.2. Array mit Type Guard filtern
Type Guard ist besonders nützlich bei Array.prototype.filter, da filter Overloads für Prädikate hat:
const orders: Order[] = [
{ status: "pending", id: "A1", total: 50 },
{ status: "paid", id: "A2", total: 120, paidAt: new Date() },
{ status: "paid", id: "A3", total: 80, paidAt: new Date() },
];
const paidOrders = orders.filter(isPaidOrder); // PaidOrder[]
const revenue = paidOrders.reduce((sum, o) => sum + o.total, 0);
console.log(revenue);
200
paidOrders hat den Typ PaidOrder[] und nicht Order[]. Dadurch können alle nachfolgenden Operationen auf paidAt zugreifen, ohne zu casten.
Ab TypeScript 5.5 kann der Compiler Type Predicates für einfache Arrow Functions selbst ableiten. Zum Beispiel gibt orders.filter(o => o.status === "paid") auch PaidOrder[] zurück. Diese Inferenz funktioniert nur, wenn der Ausdruck ausreichend einfach ist und die Beziehung „true ⇔ gehört zum Typ” korrekt ist. Bei komplexerer Logik müssen Sie weiterhin Type Guards explizit schreiben.
3.3. unknown-Daten validieren
Der praktischste Anwendungsfall für Type Guard ist die Validierung von externen Daten, z. B. JSON von einer API:
interface Customer {
id: string;
name: string;
email: string;
}
function isCustomer(value: unknown): value is Customer {
return (
typeof value === "object" &&
value !== null &&
"id" in value && typeof value.id === "string" &&
"name" in value && typeof value.name === "string" &&
"email" in value && typeof value.email === "string"
);
}
const raw: unknown = JSON.parse('{"id":"C1","name":"Lan","email":"lan@example.com"}');
if (isCustomer(raw)) {
console.log(raw.email.toUpperCase());
}
LAN@EXAMPLE.COM
Ab TypeScript 4.9 verengt der Operator in value zu object & Record<"id", unknown>. Daher können Sie value.id schreiben, ohne as any zu benötigen. Jedes Feld wird sowohl auf Existenz als auch auf Typ überprüft.
3.4. Fallstrick: Type Guard lügt
Der Compiler vertraut bedingungslos dem Type Predicate. Er überprüft nicht, ob der Funktionsrumpf dem Versprechen wirklich entspricht:
function isCustomerBroken(value: unknown): value is Customer {
return typeof value === "object" && value !== null; // vergisst, die Felder zu überprüfen
}
const data: unknown = { id: 1 };
if (isCustomerBroken(data)) {
console.log(data.email.toUpperCase()); // Compile OK, Runtime-Fehler
}
TypeError: Cannot read properties of undefined (reading 'toUpperCase')
Es gibt einen subtileren Fallstrick im false-Zweig:
function isLargeAmount(value: string | number): value is number {
return typeof value === "number" && value > 1000;
}
function format(value: string | number) {
if (isLargeAmount(value)) return `Großer Betrag: ${value}`;
return value.toUpperCase(); // TS denkt value: string
}
format(5); // TypeError: value.toUpperCase is not a function
Die Funktion gibt false für die Zahl 5 zurück. TypeScript schließt daraus, dass value im verbleibenden Zweig nicht number ist. Das ist falsch.
Hinweis: Ein Type Predicate ist eine bidirektionale Beziehung.
truezurückzugeben muss bedeuten „gehört zum Typ T”.falsezurückzugeben muss bedeuten „gehört nicht zum Typ T”. Stecken Sie keine zusätzlichen geschäftlichen Bedingungen (wie> 1000) in Type Guard. Trennen Sie diese in separateboolean-Funktionen.
4. Assertion-Funktionen
4.1. asserts value is T
Type Guard zwingt Sie, den Code in einem if zu wrappen. Es gibt Zeiten, in denen Sie ein Verhalten wie „wenn falsch, Fehler werfen, sonst weitermachen” wünschen. Das ist der Zweck von Assertion-Funktionen:
function assertIsCustomer(value: unknown): asserts value is Customer {
if (!isCustomer(value)) {
throw new TypeError("Daten sind kein gültiger Customer");
}
}
function greet(payload: unknown): string {
assertIsCustomer(payload);
// ab hier: payload: Customer
return `Hallo ${payload.name}`;
}
console.log(greet({ id: "C1", name: "Minh", email: "minh@example.com" }));
console.log(greet({ id: "C2" }));
Hallo Minh
TypeError: Daten sind kein gültiger Customer
Unterschiede zu Type Guards:
- Der Rückgabetyp ist
asserts value is Customer. Der Funktionsrumpfreturnt keinen Wert, d. h. die Funktion gibtvoidzurück. - Die Funktion wirft einen Fehler, wenn die Bedingung falsch ist. Wenn die Funktion normal zurückkehrt, nimmt TypeScript an, dass die Bedingung wahr ist.
- Der Typ wird für den gesamten Code nach dem Aufruf verengt, nicht nur im
if-Block.
4.2. asserts condition
Die zweite Form ist nicht an einen bestimmten Typ gebunden. Sie behauptet, dass ein Ausdruck truthy ist, ähnlich wie assert in Node.js:
function assert(condition: unknown, message: string): asserts condition {
if (!condition) {
throw new Error(message);
}
}
function applyDiscount(order: Order, percent: number): number {
assert(order.status !== "cancelled", `Bestellung ${order.id} wurde storniert`);
// order: PendingOrder | PaidOrder → hat total
assert(percent > 0 && percent <= 50, "Rabatt muss in (0, 50] liegen");
return order.total * (1 - percent / 100);
}
console.log(applyDiscount({ status: "pending", id: "A1", total: 200 }, 10));
180
Nach der Zeile assert(order.status !== "cancelled", ...) verengt TypeScript order wie bei einem if. Daher ist order.total gültig, obwohl CancelledOrder kein total-Feld hat.
4.3. Fehler TS2775: fehlende explizite Typannotation
Dies ist der häufigste Fehler beim Schreiben von Assertion-Funktionen als Arrow Functions:
const assertPositive = (n: number): asserts n => {
if (n <= 0) throw new Error("Muss positiv sein");
};
assertPositive(5);
error TS2775: Assertions require every name in the call target
to be declared with an explicit type annotation.
Der Compiler muss den Typ von assertPositive an der Deklarationsstelle kennen, nicht durch Inferenz. Es gibt zwei Möglichkeiten, dies zu beheben. Die erste besteht darin, eine function-Deklaration zu verwenden. Die zweite besteht darin, den Typ der Variablen anzugeben:
type AssertPositive = (n: number) => asserts n;
const assertPositive: AssertPositive = (n) => {
if (n <= 0) throw new Error("Muss positiv sein");
};
Diese Regel gilt auch für Methodenaufrufe über Objekte, z. B. validator.assertCustomer(x). Die Variable validator muss einen explizit deklarierten Typ haben, z. B. const validator: CustomerValidator = new CustomerValidator().
Hinweis: Assertion-Funktionen können nicht
asyncsein. Der TypPromise<asserts x is T>existiert nicht. Wenn Sie asynchrone Validierung benötigen,awaiten Sie zuerst die Daten und rufen dann die synchrone Assertion-Funktion auf das Ergebnis auf.
5. Tests für Type Guard und Assertion-Funktionen
Dies ist Laufzeit-Logik, daher müssen Sie wie bei jeder anderen Funktion testen. Das Beispiel unten verwendet Vitest:
npm install -D vitest typescript
// customer.test.ts
import { describe, it, expect } from "vitest";
import { isCustomer, assertIsCustomer } from "./customer";
describe("isCustomer", () => {
it("gibt true für ein gültiges Objekt zurück", () => {
expect(isCustomer({ id: "C1", name: "Lan", email: "lan@example.com" })).toBe(true);
});
it.each([
null,
"C1",
{ id: "C1", name: "Lan" },
{ id: 1, name: "Lan", email: "lan@example.com" },
])("gibt false für %j zurück", (input) => {
expect(isCustomer(input)).toBe(false);
});
});
describe("assertIsCustomer", () => {
it("wirft TypeError, wenn die Daten ungültig sind", () => {
expect(() => assertIsCustomer({ id: "C1" })).toThrow(TypeError);
});
it("verengt den Typ, wenn die Daten gültig sind", () => {
const payload: unknown = { id: "C1", name: "Lan", email: "lan@example.com" };
assertIsCustomer(payload);
expect(payload.email).toBe("lan@example.com"); // kompiliert dank Narrowing
});
});
npx vitest run
✓ customer.test.ts (7 tests)
Test Files 1 passed (1)
Tests 7 passed (7)
Achten Sie auf negative Fälle: fehlende Felder, falsche Typen, null, primitive Werte. Die meisten Bugs in Type Guards entstehen, wenn sie true zurückgeben, obwohl sie false zurückgeben sollten, wie im Beispiel in Abschnitt 3.4. Der letzte Test dient auch als Compile-Time-Test. Wenn jemand die Funktionssignatur in void ändert, wird die Test-Datei nicht kompilieren.
6. Type Guard oder Assertion-Funktion?
| Szenario | Verwenden Sie |
|---|---|
| Ungültige Daten sind normal, Sie müssen verzweigen | Type Guard |
Array filtern, find, mit filter kombinieren |
Type Guard |
| Ungültige Daten sind ein Programmierfehler oder Vertragsbruch, sollten sofort stoppen | Assertion-Funktion |
Überprüfung am Funktionskopf (Vorbedingung), vermeiden Sie verschachtelte if |
Assertion-Funktion |
Die beiden Tools gehen oft zusammen. Sie schreiben einen Type Guard als Kern, wickeln ihn dann in eine Assertion-Funktion, wie assertIsCustomer ruft isCustomer auf. Auf diese Weise befindet sich die Validierungslogik nur an einer Stelle.
Wann Sie NICHT selbst schreiben sollten:
- Große oder tief verschachtelte Schemas. Es ist sehr leicht,
isCustomervon Hand für ein Objekt mit 30 Feldern und verschachtelte Arrays zu vergessen. Eine Validierungsbibliothek wie Zod oder Valibot generiert Typen und Validierung aus demselben Schema, daher können die beiden nicht auseinandergehen. - Ersatz für
as. Ein Type Guard mit einem hohlen Funktionszweig (return true) ist nicht sicherer alsvalue as Customer. Es verdeckt das Problem nur eleganter. - Einfache Discriminated Unions. Wenn
if (order.status === "paid")bereits klar genug ist, lagern Sie es nicht nur um der Auslagerung willen aus.
7. Fazit
Type Guard (value is T) ermöglicht es Ihnen, die Typ-Prüfungslogik in einer Funktion zu kapseln und dabei das Narrowing an der Aufrufstelle zu bewahren. Es funktioniert besonders gut mit if und filter. Assertion-Funktionen (asserts value is T oder asserts condition) verengen den Typ für den gesamten nachfolgenden Code, indem sie einen Fehler werfen, wenn die Bedingung falsch ist. Sie eignen sich zum Überprüfen von Vorbedingungen und Eingabedaten.
Beide sind Versprechen, die der Compiler nicht überprüft. Daher sollten Sie drei Dinge beachten:
- Bewahren Sie die Beziehung „true ⇔ gehört zum Typ”.
- Geben Sie den Typ explizit für Assertion-Funktionen an.
- Testen Sie gründlich die negativen Fälle.