Type Inference ist das Versprechen, mit dem TypeScript überhaupt erst alltagstauglich wird: Du schreibst Typen nur dort, wo der Compiler sie nicht selbst ermitteln kann — alles andere leitet tsc aus dem Kontext ab. Hinter der scheinbar simplen Regel „Initialwert bestimmt den Typ" stecken mehrere Inferenz-Mechanismen, die ineinandergreifen: Best Common Type für Arrays mit gemischten Elementen, Contextual Typing für Callback-Parameter, Widening bei let und Objekt-Properties sowie Return-Type-Inferenz für Funktionen ohne explizite Signatur. Wer diese Regeln im Kopf hat, schreibt deutlich weniger Annotations, ohne an Typsicherheit zu verlieren — und versteht die kryptischen Fehlermeldungen, die entstehen, wenn die Inferenz unerwartet aufgibt oder zu weit greift. Seit TypeScript 5.4 gibt es mit NoInfer<T> zudem ein gezieltes Werkzeug, um die Inferenz in Generics an bestimmten Positionen auszuschalten.

Was Type Inference ist

Type Inference ist der Prozess, mit dem der Compiler einem Ausdruck ohne explizite Annotation einen Typ zuweist. Quelle der Information ist immer der Kontext: der Initialwert einer Variable, die Argumente einer Funktion, der erwartete Typ einer Zuweisung.

ts
let count = 42;          // inferred: number
let name  = "Alice";     // inferred: string
let flag  = true;        // inferred: boolean
let list  = [1, 2, 3];   // inferred: number[]

Ohne Inferenz müsstest du jeden dieser Typen einzeln annotieren — der Code würde sich wie eine Mischung aus Java und JavaScript lesen, mit Annotations an jeder zweiten Zeile. Die Devise der TypeScript-Doku lautet ausdrücklich: Schreibe weniger Annotations, als du denkst. Inferenz ist nicht „die billige Alternative", sondern der bevorzugte Stil.

Annotationen sind dort sinnvoll, wo der Compiler nicht raten kann — Funktions-Parameter ohne Default, öffentliche API-Grenzen, Rückgabetypen, die du als Vertrag festschreiben willst. Innerhalb von Funktionskörpern arbeitet die Inferenz fast immer korrekt.

Variablen-Inferenz: let, const und Widening

Der wichtigste Unterschied beim Inferieren von Variablen-Typen ist die Deklarations-Form. const-Variablen behalten den exakten Literal-Typ, let-Variablen werden auf den Basistyp geweitet.

ts
const x = 5;         // Typ: 5         (Literal)
let   y = 5;         // Typ: number    (gewidet)

const s = "hello";   // Typ: "hello"
let   t = "hello";   // Typ: string

const b = true;      // Typ: true
let   c = true;      // Typ: boolean

Die Logik: Eine const-Variable kann nie neu zugewiesen werden, also bleibt ihr Wert für immer 5 — der Compiler vergibt den präzisen Literal-Typ. Eine let-Variable kann jeden anderen Wert desselben Basistyps aufnehmen, also wird zum Basistyp geweitet (Widening).

Innerhalb von Objekt-Literalen widet TypeScript Properties auch dann, wenn das umgebende Konstrukt const ist:

ts
const config = { host: "localhost", port: 8080 };
// Typ: { host: string; port: number }
// NICHT: { host: "localhost"; port: 8080 }

Properties eines const-Objekts sind nämlich weiterhin mutierbar — config.host = "example.com" ist erlaubt. Erst as const friert die Werte zu Literal-Typen ein.

Best Common Type bei Arrays

Wenn ein Array aus mehreren Werten initialisiert wird, sucht der Compiler den Best Common Type — den schmalsten Typ, der alle Elemente abdeckt. Existiert ein gemeinsamer Supertyp, wird er gewählt. Wenn nicht, baut TypeScript eine Union.

ts
let a = [1, 2, 3];              // number[]
let b = [1, 2, null];           // (number | null)[]
let c = [1, "two", true];       // (string | number | boolean)[]
let d = [{ x: 1 }, { x: 2 }];   // { x: number }[]

Bei Klassen-Hierarchien wird es interessanter. Liegt kein expliziter Vertreter des gemeinsamen Supertyps im Array, fällt TypeScript auf die Union zurück — auch wenn semantisch ein Supertyp existiert.

ts
class Animal {}
class Rhino    extends Animal { rhinoTag    = true; }
class Elephant extends Animal { elephantTag = true; }
class Snake    extends Animal { snakeTag    = true; }

let zoo = [new Rhino(), new Elephant(), new Snake()];
// Typ: (Rhino | Elephant | Snake)[]
// Nicht: Animal[]

// Lösung: explizit annotieren
let park: Animal[] = [new Rhino(), new Elephant(), new Snake()];

Der Grund: Der Compiler entscheidet sich nur dann für Animal[], wenn Animal selbst als Kandidat im Array vorkommt. Andernfalls bleibt er beim engsten gemeinsamen Wertebereich, nämlich der Union der konkret vorhandenen Typen.

Contextual Typing

Contextual Typing ist die Inferenz in die umgekehrte Richtung: Statt aus einem Ausdruck den Typ einer Variable abzuleiten, schließt TypeScript aus dem erwarteten Typ einer Position auf den Typ eines Ausdrucks. Klassischer Fall: Callback-Parameter.

ts
const names = ["Alice", "Bob", "Eve"];

names.forEach(function (s) {
  // s: string — kommt aus dem Signaturtyp von forEach
  console.log(s.toUpperCase());
});

names.map(s => s.length);
// s: string, Return: number → map liefert number[]

Ohne Contextual Typing müsstest du jeden Callback-Parameter annotieren ((s: string) => ...) — mit ihm reicht der nackte Parameter-Name, weil Array.prototype.forEach bereits durchsetzt, dass s ein string sein muss.

Contextual Typing greift überall dort, wo ein erwarteter Typ existiert: bei Funktions-Argumenten, Zuweisungen, Return-Statements, Objekt- und Array-Literalen, Type-Assertions.

ts
type Handler = (event: MouseEvent) => void;

const onClick: Handler = (e) => {
  // e: MouseEvent — aus dem Typ von onClick inferiert
  console.log(e.clientX, e.clientY);
};

window.onmousedown = function (mouseEvent) {
  // mouseEvent: MouseEvent — aus Window.onmousedown
  console.log(mouseEvent.button);
};

Wenn der Kontext keinen erwarteten Typ liefert, fallen Callback-Parameter auf any zurück — oder produzieren bei aktiviertem noImplicitAny einen Fehler. Genau dann ist eine explizite Annotation nötig.

Return-Type-Inferenz

Eine Funktion ohne explizite Return-Annotation bekommt ihren Rückgabetyp aus den return-Statements im Körper inferiert.

ts
function add(a: number, b: number) {
  return a + b;
}
// add: (a: number, b: number) => number

function greet(name: string) {
  return `Hallo, ${name}`;
}
// greet: (name: string) => string

function maybe(flag: boolean) {
  if (flag) return 42;
  return "nope";
}
// maybe: (flag: boolean) => number | string

Mehrere return-Statements werden über Best Common Type zu einer Union zusammengefasst — exakt wie bei Array-Inferenz. Auch hier widet TypeScript bei Literal-Werten:

ts
function getMethod() {
  return "GET";
}
// Return-Typ: string  — NICHT "GET"

function getMethodStrict(): "GET" {
  return "GET";
}
// Return-Typ: "GET"

function getMethodConst() {
  return "GET" as const;
}
// Return-Typ: "GET"

In öffentlichen APIs ist eine explizite Return-Annotation Pflicht-Stil: Sie macht die Funktion zum Vertrag, verhindert versehentliche Typ-Erweiterungen durch spätere Code-Änderungen und beschleunigt den Compiler, weil er nicht jedes Mal den Body durchsuchen muss.

Widening und Narrowing im Zusammenspiel

Widening ist der Default-Mechanismus, mit dem TypeScript Literal-Typen auf ihren Basistyp aufweitet — bei let, bei Objekt-Properties, bei Funktions-Rückgaben. Narrowing ist das Gegenstück: In Control-Flow-Analyse-Zweigen verengt der Compiler einen breiten Typ wieder auf einen schmaleren.

ts
function handle(input: string | number) {
  if (typeof input === "string") {
    // input: string  — eingeengt durch typeof
    return input.toUpperCase();
  }
  // input: number  — narrowed durch Ausschluss
  return input.toFixed(2);
}

Manuelles Eingreifen funktioniert über as const (verengt eine Inferenz auf den Literal-Typ) oder über explizite Annotation (legt den Typ unabhängig von der Inferenz fest).

KonstruktInferenz-Verhalten
const x = 5Literal-Typ 5
let x = 5Gewidet zu number
&#123; v: 5 &#125;Property gewidet zu number
&#123; v: 5 &#125; as constProperty bleibt Literal 5, readonly
[1, 2, 3]number[]
[1, 2, 3] as constreadonly [1, 2, 3] (Tupel mit Literal-Elementen)

Generic-Inferenz

Bei generischen Funktionen leitet der Compiler die Type Parameter aus den übergebenen Argumenten ab. Du musst die Generics fast nie explizit angeben.

ts
function identity<T>(value: T): T {
  return value;
}

const a = identity(42);       // T = number,  a: number
const b = identity("hello");  // T = string,  b: string
const c = identity([1, 2]);   // T = number[], c: number[]

function first<T>(arr: T[]): T | undefined {
  return arr[0];
}

const f = first([1, 2, 3]);             // T = number
const g = first(["a", "b"]);            // T = string
const h = first<"x" | "y">(["x", "y"]); // explizit gesetzt

Bei mehreren Parametern wird jeder unabhängig inferiert und ggf. über Best Common Type vereint:

ts
function pair<A, B>(a: A, b: B): [A, B] {
  return [a, b];
}

const p = pair(1, "two");
// A = number, B = string
// p: [number, string]

function pickFrom<T>(list: T[], value: T): T {
  return list.includes(value) ? value : list[0];
}

pickFrom([1, 2, 3], 2);   // T = number
pickFrom([1, 2, 3], "x"); // Error: "x" nicht zuweisbar an T = number

Die Inferenz arbeitet von außen nach innen: Erst werden die Parameter-Typen aus den Argumenten ermittelt, dann der Rückgabetyp daraus berechnet. Wenn ein Type-Parameter an mehreren Stellen vorkommt, müssen alle Stellen konsistent zueinander passen.

Grenzen der Inferenz

So mächtig die Inferenz auch ist — sie hat klare Grenzen. An vier typischen Stellen gibt der Compiler auf oder produziert ein zu breites Ergebnis:

Implicit any bei nicht-kontextualisierten Parametern. Eine Funktion ohne Aufruf-Kontext und ohne Default-Wert bekommt ihre Parameter auf any gesetzt — bei noImplicitAny ist das ein Fehler.

ts
function process(item) {
  //          ^^^^ Error: Parameter 'item' implicitly has an 'any' type.
  return item.value;
}

Zirkuläre Typ-Referenzen. Wenn ein Typ sich selbst über einen anderen Typ referenziert, der ihn ebenfalls braucht, kann die Inferenz nicht terminieren und bricht ab.

Zu komplexe Conditional Types. Conditional Types mit tiefer Verschachtelung erzeugen eine Inferenz-Tiefe, die irgendwann die internen Limits des Compilers übersteigt — Ergebnis ist meist ein never-Typ oder ein „Type instantiation is excessively deep"-Fehler.

Default-Wert-Inferenz. Ein Default-Wert liefert dem Compiler einen Typ-Hinweis, der die explizite Annotation ersetzt — aber nur den Basistyp, nicht den Literal-Typ.

ts
function f(x = 5) {
  // x: number  — aus Default-Wert
}

function g(mode = "lazy") {
  // mode: string  — gewidet, NICHT "lazy"
}

NoInfer<T> als Steuerung (TS 5.4+)

Seit TypeScript 5.4 gibt es den Utility-Typ NoInfer<T>. Er markiert eine Position in einer generischen Signatur so, dass sie nicht zur Inferenz des Type Parameters beiträgt — und löst damit eine klassische Falle.

ts
function createState<T>(initial: T, allowed: T[]): T {
  return initial;
}

createState("idle", ["idle", "loading", "error"]);
// T wird aus BEIDEN Argumenten inferiert
// → T = "idle" | "loading" | "error"
// initial "idle" passt — aber das war nicht die Absicht.

Mit NoInfer schließt du das zweite Argument von der Inferenz aus — T wird nur aus dem ersten ermittelt, das zweite muss dann zu diesem T passen:

ts
function createState<T>(initial: T, allowed: NoInfer<T>[]): T {
  return initial;
}

createState("idle", ["idle", "loading"]);
// T = "idle"
// Error: "loading" ist nicht "idle"

Anwendungsfälle: Default-Werte, deren Typ aus einem anderen Argument kommen muss; Constraint-Argumente, die einen schon ermittelten Typ nur prüfen sollen; Builder-APIs, in denen ein Initial-Wert den Typ vorgibt und alle weiteren Aufrufe darauf eingeengt werden.

Besonderheiten

const x = 5 ergibt Typ 5, nicht number.

Eine der häufigsten Quellen für Verwirrung bei Einsteigern: const x = 5 hat den Typ 5 (Literal), nicht number. Erst let x = 5 widet auf number. Praktische Folge: Wer const für Werte nutzt, die in Literal-Unions weiterverwendet werden, spart sich oft das explizite as const — solange er auf der obersten Ebene bleibt und keine Objekt-Properties verschachtelt.

Best Common Type kann Union erzeugen — überraschend bei gemischten Arrays.

[1, "a"] ergibt (string | number)[], nicht any[]. Das ist meistens hilfreich, kann aber bei Klassen-Hierarchien unerwartet wirken: [new Rhino(), new Elephant()] wird zu (Rhino | Elephant)[], nicht zu Animal[] — selbst wenn beide von Animal erben. Lösung: einen Animal ins Array aufnehmen oder explizit annotieren.

Contextual Typing macht Callbacks ohne Annotation möglich.

arr.map(x => x.length) funktioniert ohne Annotation, weil map seine Signatur schon kennt — x wird automatisch zum Element-Typ. Das gilt überall, wo ein erwarteter Typ existiert: Event-Handler, setTimeout-Callbacks, Promise-Chains. Fehlt der Kontext (freistehende Funktion ohne Aufruf-Bezug), fällt der Parameter auf any bzw. einen noImplicitAny-Fehler zurück.

Generic-Inferenz funktioniert "von außen nach innen".

Bei function f<T>(a: T, b: T): T sieht der Compiler zuerst die Argument-Typen, vereint sie über Best Common Type und setzt T auf das Ergebnis. f(1, "x") ergibt also T = number | string — keinen Fehler. Wer das verhindern will, nutzt NoInfer<T> oder splittet das Generic in zwei Parameter.

NoInfer löst die "Default-Type-Position"-Falle.

Vor TypeScript 5.4 gab es kein sauberes Mittel, ein Argument von der Inferenz auszuschließen — Workarounds nutzten Conditional Types wie [T][T extends any ? 0 : never]. Mit NoInfer<T> markierst du Positionen, die nur prüfen, aber nicht inferieren sollen. Das ist besonders nützlich für Default-Werte und Validierungs-Argumente, die zu einem schon ermittelten Type-Parameter passen müssen.

Inferenz aus Default-Wert: function f(x = 5) ergibt number.

Default-Werte zählen für die Inferenz wie ein Initialwert. function f(x = 5) bekommt x: number — ohne Annotation. Das gilt auch für komplexere Defaults: function g(opts = { retries: 3 }) ergibt opts: { retries: number }. Achtung: Wie bei let wird der Wert auf den Basistyp gewidet, der Literal-Typ 5 oder 3 geht verloren — selbst wenn der Default eine const-Konstante ist.

let vs. const-Widening — praktischer Unterschied bei Object-Literals.

Während const x = "a" den Typ "a" behält, widet const obj = { x: "a" } die Property zu string. Der Grund: Objekt-Properties sind auch in const-Objekten weiterhin mutierbar. Wer Literal-Typen in Strukturen erhalten will, muss explizit as const einsetzen. Genau das ist die häufigste Quelle für „Type 'string' is not assignable to type '...'"-Fehler beim Aufruf von Funktionen mit Literal-Union-Parametern.

TS 4.7 brachte "Control Flow Analysis of Aliased Conditions".

Bis 4.7 verlor TypeScript die Narrowing-Information, sobald du eine Bedingung in eine Konstante extrahiert hattest: const isString = typeof x === "string"; if (isString) { ... } half nichts. Seit 4.7 erkennt der Compiler solche aliased Conditions und narrowt korrekt — vorausgesetzt, das Alias ist eine const-Variable und der zugehörige Wert wurde zwischendurch nicht neu zugewiesen. In der Praxis erlaubt das deutlich lesbarere Type-Guard-Logik.

Weiterführende Ressourcen

Externe Quellen

/ Weiter

Zurück zu Type System

Zur Übersicht