Type Narrowing ist der Mechanismus, mit dem TypeScript einen breiten Union-Typ innerhalb eines Code-Pfads auf einen schmaleren Typ verengt. Ohne Narrowing wäre die Arbeit mit Unions praktisch unmöglich — jeder Zugriff auf ein Member, das nur in einem Teil der Union existiert, würde fehlschlagen. Der Compiler beobachtet stattdessen jeden Kontrollfluss-Zweig und merkt sich, welche Möglichkeiten an welcher Stelle noch übrig sind. Werkzeuge dafür sind typeof, instanceof, der in-Operator, Truthiness- und Equality-Checks sowie selbstgeschriebene Type Guards und Assertion Functions. Wer diese Werkzeuge sauber kombiniert, schreibt Union-Code, der sich anfühlt wie statisch typisiert — aber zur Laufzeit jeden Spezialfall korrekt behandelt.

Was Type Narrowing ist

Type Narrowing bezeichnet den Vorgang, mit dem TypeScript den deklarierten Typ einer Variable innerhalb eines konkreten Code-Pfads auf eine engere Teilmenge reduziert. Grundlage ist die Control-Flow-Analyse: Der Compiler verfolgt jeden möglichen Ausführungs-Pfad und merkt sich, welche zur Compile-Zeit beweisbaren Aussagen an jeder Stelle gelten.

ts
function describe(value: string | number) {
  // Hier: value ist string | number — der deklarierte Typ.
  if (typeof value === "string") {
    // Hier verengt: value ist string.
    // Der Compiler weiß, dass der else-Zweig number ausschließt.
    return value.toUpperCase();
  }
  // Hier verengt: value ist number — durch Ausschluss.
  return value.toFixed(2);
}

Das Schöne daran: Du musst nichts annotieren, nichts casten, keine Assertion schreiben. Der Compiler folgt dem Code so, wie ihn ein Mensch lesen würde — typeof filtert, instanceof filtert, eine Equality filtert. Das ist der Kern des „statisch-aber-praktisch"-Versprechens von TypeScript: Du arbeitest mit denselben Patterns wie in JavaScript und bekommst dafür präzise Typen.

Wichtig ist die Unterscheidung zwischen deklariertem Typ (das, was du in der Signatur stehen hast) und narrowed Type (das, was im aktuellen Block davon noch übrig ist). Eine erneute Zuweisung kann den narrowed Type zurücksetzen — der deklarierte Typ aber bleibt stets die obere Schranke.

typeof-Narrowing

Der typeof-Operator existiert seit den Anfängen von JavaScript und liefert einen festen Satz von String-Literalen. TypeScript erkennt diese Strings und verengt entsprechend.

typeof-ErgebnisJS-Typ
"string"String-Primitiv
"number"Number-Primitiv (inkl. NaN)
"boolean"Boolean-Primitiv
"bigint"BigInt-Primitiv
"symbol"Symbol
"undefined"undefined
"function"Funktion
"object"Objekt — inkl. null und Arrays (!)
ts
function pad(input: string | number, width: number): string {
  // typeof grenzt sauber zwischen den beiden Primitiven ab.
  if (typeof input === "number") {
    // input: number
    return input.toString().padStart(width, "0");
  }
  // input: string  — der einzig verbleibende Branch.
  return input.padStart(width, " ");
}

Die klassische Falle: typeof null liefert "object". Wer eine Union aus string[] | null per typeof x === "object" verengt, hat null immer noch drin.

ts
function printAll(values: string[] | null) {
  if (typeof values === "object") {
    // values: string[] | null  — null ist NICHT weg!
    // for (const v of values) würde zur Laufzeit knallen.
  }

  // Saubere Variante: explizit gegen null prüfen.
  if (values !== null) {
    // values: string[]
    for (const v of values) console.log(v);
  }
}

Eine zweite Falle: typeof [] ergibt "object", nicht "array". Für Arrays musst du Array.isArray() nutzen — eine eingebaute Type-Guard-Funktion, die in der TS-Library als Predicate typisiert ist.

Truthiness-Narrowing

JavaScript kennt eine fixe Liste von falsy Werten: 0, 0n, NaN, "", null, undefined, false. Ein einfaches if (value) wirft alle davon raus. TypeScript respektiert das und verengt entsprechend.

ts
function greet(name: string | null | undefined) {
  if (name) {
    // name: string — null und undefined sind beide weg.
    return `Hallo, ${name.toUpperCase()}`;
  }
  // name: string | null | undefined
  // Achtung: auch der leere String "" landet hier!
  return "Hallo, Fremder";
}

Der gefährliche Fall ist exakt das letzte Kommentar-Detail: Truthiness filtert zu aggressiv. Wer mit number | undefined arbeitet, verliert die 0 mit:

ts
function describeCount(count: number | undefined) {
  if (count) {
    // count: number — aber 0 ist hier nicht möglich!
    return `Es gibt ${count} Einträge.`;
  }
  // count: number | undefined
  // Der Branch fängt sowohl 0 als auch undefined.
  return "Keine Daten.";
}

// Korrekt: explizit gegen undefined prüfen.
function describeCountStrict(count: number | undefined) {
  if (count !== undefined) {
    // count: number — auch 0 kommt hier an.
    return `Es gibt ${count} Einträge.`;
  }
  return "Keine Daten.";
}

Faustregel: Truthiness ist okay, wenn 0 oder "" als „leer" zählen sollen. Sobald sie gültige Werte sind, brauchst du explizite Equality-Checks.

Equality-Narrowing

Vergleiche mit ===, !==, == und != engen Typen ein — oft präziser, als man erwartet. Der Compiler weiß, dass zwei Werte nach einem === denselben Typ haben müssen.

ts
type Direction = "up" | "down" | "left" | "right";

function step(dir: Direction) {
  if (dir === "up") {
    // dir: "up"  — Literal-Narrowing.
    return [0, -1];
  }
  // dir: "down" | "left" | "right"
  return [0, 0];
}

Spannender wird es, wenn zwei Variablen aus zwei Unions gegeneinander verglichen werden. Der Compiler ermittelt die Schnittmenge der möglichen Typen.

ts
function compare(x: string | number, y: string | boolean) {
  if (x === y) {
    // Welcher Typ kommt in beiden Unions vor? Nur string.
    // → x: string, y: string
    return x.toUpperCase() === y.toUpperCase();
  }
  return false;
}

Ein extrem nützlicher Spezialfall: x != null (mit doppeltem Gleichheitszeichen!) filtert in einem Schritt beidesnull und undefined. Das funktioniert, weil JavaScript null == undefined als wahr ausgibt.

ts
interface Container { value: number | null | undefined; }

function multiply(c: Container, factor: number) {
  if (c.value != null) {
    // c.value: number — null UND undefined entfernt.
    return c.value * factor;
  }
  return 0;
}

in-Operator-Narrowing

Der JavaScript-in-Operator prüft, ob eine Property auf einem Objekt existiert. TypeScript nutzt das, um Object-Unions zu verengen — besonders praktisch, wenn keine gemeinsame Discriminator-Property vorhanden ist.

ts
type Fish = { swim: () => void };
type Bird = { fly:  () => void };

function move(animal: Fish | Bird) {
  if ("swim" in animal) {
    // animal: Fish — der Compiler weiß, dass nur Fish ein swim hat.
    return animal.swim();
  }
  // animal: Bird — durch Ausschluss.
  return animal.fly();
}

Aufpassen bei optionalen Properties: Sind sie auf beiden Seiten optional, hilft in nicht — der Operator prüft nur die Existenz, nicht den Typ. Und: Optionale Properties tauchen in beiden Branches auf, weil der Compiler nicht beweisen kann, dass sie nur in einer Variante vorkommen.

ts
type Human = { swim?: () => void; fly?: () => void };

function moveHuman(h: Human) {
  if ("swim" in h) {
    // h: Human  — kein Narrowing, weil swim optional ist.
    h.swim?.();
  }
}

in ist die Goto-Lösung für strukturell unterscheidbare Unions ohne Discriminator-String. Sobald du aber ein gemeinsames Discriminator-Feld einführen kannst, ist das die robustere Variante (siehe Discriminated Unions).

instanceof-Narrowing

instanceof prüft, ob ein Objekt in der Prototype-Kette einer Klasse liegt. Der Compiler nutzt das, um zwischen Klassen-Instanzen zu unterscheiden.

ts
function format(value: Date | string) {
  if (value instanceof Date) {
    // value: Date
    return value.toISOString();
  }
  // value: string
  return value.toUpperCase();
}

// Auch mit Error-Subklassen:
class NotFoundError    extends Error { status = 404 as const; }
class UnauthorizedError extends Error { status = 401 as const; }

function handle(err: NotFoundError | UnauthorizedError) {
  if (err instanceof NotFoundError) {
    // err: NotFoundError
    return `404: ${err.message}`;
  }
  // err: UnauthorizedError
  return `401: ${err.message}`;
}

instanceof funktioniert ausschließlich für Klassen — also Konstruktoren, die zur Laufzeit existieren. Ein reines Interface oder ein Type-Alias hat keine Runtime-Repräsentation und kann deshalb nicht mit instanceof geprüft werden:

ts
interface User { name: string; }

function check(x: unknown) {
  // x instanceof User  // ← Compile-Fehler:
  //                       'User' only refers to a type,
  //                       but is being used as a value here.
}

Workaround für Interfaces: ein Custom Type Guard (siehe nächster Abschnitt) oder eine Klasse statt eines Interfaces.

Custom Type Guards (User-Defined Type Predicates)

Wenn weder typeof noch instanceof noch in ausreichen — etwa weil zur Laufzeit komplexere Logik nötig ist —, definierst du einen User-Defined Type Guard. Das ist eine Funktion, deren Return-Typ die Spezial-Syntax parameter is Type trägt.

ts
// Predicate-Syntax: "x is string" statt boolean.
function isString(x: unknown): x is string {
  return typeof x === "string";
}

function processInput(input: unknown) {
  if (isString(input)) {
    // input: string  — der Compiler vertraut dem Predicate.
    return input.toUpperCase();
  }
  // input: unknown
}

Praktischer Anwendungsfall: Interfaces ohne instanceof testen.

ts
interface User { kind: "user"; name: string; }
interface Bot  { kind: "bot";  id:   string; }

function isUser(x: User | Bot): x is User {
  // Wir prüfen die Discriminator-Property zur Laufzeit.
  return x.kind === "user";
}

function greet(x: User | Bot) {
  if (isUser(x)) {
    // x: User
    return `Hallo, ${x.name}`;
  }
  // x: Bot
  return `Bot ${x.id}`;
}

Auch wertvoll: Custom Guards funktionieren mit Array.prototype.filter, weil dessen Overload Predicates erkennt.

ts
function isDefined<T>(x: T | null | undefined): x is T {
  return x !== null && x !== undefined;
}

const mixed: (number | null)[] = [1, null, 2, null, 3];
const onlyNumbers = mixed.filter(isDefined);
// onlyNumbers: number[]  — null entfernt, Typ verengt.

Vorsicht: Type Guards lügen, wenn die Predicate-Logik nicht zum behaupteten Typ passt. Der Compiler glaubt jedem x is T blind — du bist für die Korrektheit verantwortlich.

ts
function isNumber(x: unknown): x is number {
  return typeof x === "string"; // ← falsch, aber kompiliert!
}
// Folge: alle Aufrufer bekommen unsafe number.

Assertion Functions

Eine Assertion Function ist die andere Variante eines selbstgeschriebenen Type Guards. Statt einen booleschen Wert zurückzugeben, wirft sie eine Exception, wenn die Bedingung nicht stimmt. Ab dem Aufruf gilt der Typ als gesichert.

ts
function assertIsString(x: unknown): asserts x is string {
  if (typeof x !== "string") {
    throw new TypeError(`Erwartet string, bekam ${typeof x}`);
  }
}

function process(input: unknown) {
  assertIsString(input);
  // input: string  — gilt für den gesamten Rest der Funktion.
  return input.toUpperCase();
}

Die asserts-Syntax kennt zwei Formen:

ts
// 1) Behauptet einen konkreten Typ.
function assertIsNumber(x: unknown): asserts x is number {
  if (typeof x !== "number") throw new TypeError();
}

// 2) Behauptet, dass eine Bedingung wahr ist (wie Node's assert).
function invariant(cond: unknown, msg = "Invariant failed"):
    asserts cond {
  if (!cond) throw new Error(msg);
}

function divide(a: number, b: number | null) {
  invariant(b !== null, "b darf nicht null sein");
  // b: number — durch invariant gesichert.
  return a / b;
}

Unterschied zum Boolean-Guard: Assertion Functions sind nicht aufrufbar in if-Bedingungen — sie wirken inline auf den restlichen Block. Genau richtig für Vorbedingungen am Funktions-Anfang.

Discriminated Union Narrowing

Die saubere Lösung für Object-Unions ist eine gemeinsame Literal-Property — der Discriminator. Eine switch-Anweisung darüber verengt automatisch jeden Branch auf die richtige Variante. Details dazu im eigenen Artikel zu Discriminated Unions — hier nur ein kompaktes Beispiel als Einordnung in den Narrowing-Werkzeugkasten.

ts
type Shape =
  | { kind: "circle";    radius: number }
  | { kind: "square";    side: number }
  | { kind: "rectangle"; width: number; height: number };

function area(s: Shape): number {
  switch (s.kind) {
    case "circle":
      // s: { kind: "circle"; radius: number }
      return Math.PI * s.radius ** 2;
    case "square":
      return s.side ** 2;
    case "rectangle":
      return s.width * s.height;
  }
}

Der große Vorteil gegenüber in oder Custom Guards: Der Compiler kann Exhaustiveness prüfen. Fehlt ein Case und du fügst später eine neue Variante hinzu, fällt das sofort auf — vorausgesetzt, du nutzt einen never-Default-Case als Sicherung.

Control-Flow-Analyse für Aliase (TS 4.4+)

Bis TypeScript 4.3 ging Narrowing-Information verloren, sobald du eine Bedingung in eine Konstante extrahiert hast. Seit Version 4.4 erkennt der Compiler solche aliased conditions und transportiert das Narrowing durch.

ts
function example(arg: unknown) {
  const isString = typeof arg === "string";
  if (isString) {
    // arg: string  — funktioniert seit TS 4.4.
    return arg.toUpperCase();
  }
}

Die Analyse funktioniert sogar transitiv — der Compiler folgt einer Kette aus Konstanten:

ts
function classify(x: string | number | boolean) {
  const isString         = typeof x === "string";
  const isNumber         = typeof x === "number";
  const isStringOrNumber = isString || isNumber;

  if (isStringOrNumber) {
    // x: string | number  — Compiler folgt der Alias-Kette.
  } else {
    // x: boolean
  }
}

Auch für extrahierte Discriminanten funktioniert das Pattern:

ts
type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; side:   number };

function area(shape: Shape) {
  const { kind } = shape;
  if (kind === "circle") {
    // shape: { kind: "circle"; radius: number }
    return Math.PI * shape.radius ** 2;
  }
  return shape.side ** 2;
}

Bedingung für das Funktionieren: Das Alias muss eine const-Variable sein (oder eine readonly Property), und der zugehörige Wert darf zwischen Definition und Nutzung nicht neu zugewiesen werden. TypeScript führt die Analyse nicht beliebig tief — für alltägliche Fälle reicht es aber locker.

Grenzen des Narrowings

Narrowing ist mächtig, aber nicht unbegrenzt. Die folgenden Fälle sollte jeder kennen, der länger mit Unions arbeitet.

Funktions-Grenze verliert Narrowing. Sobald du einen narrowed Wert in einen Callback gibst, vergisst der Compiler die Verengung — der Callback könnte den Wert mutieren oder die Funktion könnte später erneut aufgerufen werden.

ts
interface State { value: number | null; }
const state: State = { value: 5 };

if (state.value !== null) {
  // state.value: number  — verengt.
  [1, 2, 3].forEach(() => {
    // state.value: number | null  — Narrowing weg!
    // const v = state.value + 1; // ← Fehler.
  });
}

Lösung: den Wert in eine lokale const ziehen.

ts
if (state.value !== null) {
  const v = state.value; // v: number — bleibt im Closure stabil.
  [1, 2, 3].forEach(() => v + 1);
}

async/await zwischen Check und Use. Ähnliches Problem: Zwischen if (x !== null) und der Nutzung darf der Wert sich theoretisch geändert haben. TypeScript ist hier konservativ — und nimmt für Object-Properties an, dass jeder asynchrone Aufruf den Wert kompromittieren könnte.

ts
async function loadUser(repo: { current: User | null }) {
  if (repo.current !== null) {
    await delay(100);
    // repo.current: User | null  — Narrowing nach await verloren.
  }
}

Generic-Type-Parameter narrowing nur eingeschränkt. Ein T extends string | number verengt sich innerhalb der Funktion zwar via typeof, aber das Ergebnis ist meist T & string — nicht so handlich wie ein direkter Literal-Typ.

Komplexe Predicate-Ausdrücke ohne Alias. Inline-Bedingungen mit mehreren && werden verengt; sobald sie aber nicht direkt im if stehen, war vor TS 4.4 Schluss. Heute geht es weitgehend — aber nicht beliebig tief.

Interessantes

typeof null ist "object" — klassische JS-Falle.

Ein Erbe der ersten JavaScript-Tage: typeof null liefert "object". Wer eine Union mit null auf typeof x === "object" prüft, hat null hinterher noch im Typ — und fängt sich beim Property-Zugriff eine TypeError-Exception ein. Standardlösung: zusätzlich gegen null prüfen, oder gleich x !== null && typeof x === "object" kombinieren.

typeof [] ist "object", nicht "array".

Arrays sind in JavaScript gewöhnliche Objekte. typeof kennt deshalb keinen Sondercase für sie. Wer ein Array von einem reinen Object unterscheiden will, nutzt Array.isArray(value) — die Funktion ist in der TS-Library als Type Predicate (value is unknown[]) typisiert und narrowt entsprechend.

Truthiness fängt zu viel: 0, "", null, undefined gleich.

Ein nacktes if (value) verwirft alle falsy Werte — das ist meistens praktisch, manchmal aber falsch. Bei number | undefined fällt die legale 0 in den else-Branch, bei string | null der leere String. Sobald diese Werte semantisch erlaubt sind, gehört ein expliziter Equality-Check her: x !== undefined oder x !== null.

instanceof funktioniert für Klassen, nicht für Interfaces.

Ein Interface oder Type-Alias hat keine Runtime-Repräsentation — es gibt also kein Konstruktor-Funktion-Objekt, gegen das instanceof prüfen könnte. Konsequenz: x instanceof MyInterface ist ein Compile-Fehler. Lösungen: entweder eine echte Klasse statt eines Interfaces nutzen, oder einen Custom Type Guard mit Predicate-Syntax schreiben.

Custom Type Guards lügen, wenn die Predicate-Logik nicht passt.

TypeScript vertraut jeder Funktion mit x is T blind. Wer einen Guard schreibt, dessen Body nicht das tut, was die Signatur behauptet, vergiftet damit den ganzen Aufrufgraphen — der Compiler hält den Wert für T, obwohl er es nicht ist. Tests für Type Guards sind deshalb keine Spielerei, sondern Pflicht.

asserts-Functions sind powerful, aber Linter-untypisch.

Die Syntax asserts x is T ist relativ neu (TS 3.7) und vielen Linter- und IDE-Tools schlecht bekannt. Manche Tools markieren die Funktion als „nie aufgerufen", weil sie keinen Boolean zurückgibt. Praktisch wirken sie wie Node's assert: Sie werfen bei Mismatch und verengen danach den Typ für den Rest des Blocks. Sehr nützlich für Vorbedingungen am Funktions-Anfang.

Narrowing geht über Funktions-Boundary verloren.

Ein if (x !== null) verengt x nur im aktuellen Closure. Sobald du einen Callback übergibst, betrachtet der Compiler den Wert als potenziell mutiert — Narrowing weg. Standard-Workaround: den verengten Wert in eine lokale const speichern, dann fängt das Closure den engen Typ ein. Bei Object-Properties hilft auch ein Helper mit Type Predicate, der die Verengung für Aufrufer transportiert.

Generic-Funktionen narrown ihre Type-Parameter selten.

Bei function f<T>(x: T) ist T aus Sicht des Compilers eine Blackbox — eine typeof-Prüfung innerhalb der Funktion engt zwar das beobachtete x ein, aber das Ergebnis ist meist eine Intersection wie T & string. Wer in einem Generic granular auf Subtypen unterscheiden will, sollte Overloads, Conditional Return Types oder eine Discriminated Union als Eingabe nutzen.

TS 4.4 hat aliased-condition-narrowing eingeführt — Game-Changer.

Vor 4.4 musste jede Type-Guard-Bedingung direkt im if stehen. Wer sie in eine Konstante extrahierte (etwa für Lesbarkeit oder Wiederverwendung), verlor das Narrowing. Seit 4.4 folgt der Compiler aliased Conditions — selbst transitive Ketten aus mehreren Konstanten. Bedingung: const-Aliase und unveränderte Werte. Erlaubt deutlich saubereren Code, in dem komplexe Bedingungen erst sprechende Namen bekommen und dann benutzt werden.

Array.isArray(value) narrowt auf unknown[], nicht auf Array.

Die TS-Lib-Signatur von Array.isArray liefert value is any[] (bzw. seit kompatiblen TS-Versionen value is unknown[]). Element-Typ-Information aus der Eingabe-Union geht verloren — wer eine Union string | string[] per isArray verengt, bekommt nicht string[], sondern den Lib-Default. In strengen Fällen lohnt sich ein eigener Guard, der explizit value is string[] behauptet.

Weiterführende Ressourcen

Externe Quellen

/ Weiter

Zurück zu Union & Intersection

Zur Übersicht