TypeScript ist eine Compile-Zeit-Schicht über JavaScript — und genau das ist die wichtigste Eigenschaft, die du als Entwickler verinnerlicht haben solltest. Jede Typ-Annotation, jedes Interface, jeder Generic-Parameter existiert ausschließlich während der Übersetzung durch tsc. Sobald der Compiler fertig ist, bleibt schlankes JavaScript übrig — ohne jede Spur von Typen. Dieses Prinzip heißt Type Erasure und hat weitreichende Folgen: für Reflection, für die Validierung externer Daten, für Tests und für die Architektur ganzer Anwendungen. Dieser Artikel zeigt, was wirklich passiert, warum es so gewollt ist und welche Brücken du brauchst, um trotzdem zur Laufzeit sicher zu sein.

Das Kernprinzip: Type Erasure

Der Begriff Type Erasure beschreibt einen einfachen, aber folgenreichen Vorgang: Der TypeScript-Compiler nimmt deinen typisierten Quellcode entgegen, prüft die Typen — und entfernt sie anschließend vollständig, bevor er JavaScript ausgibt. Was übrig bleibt, ist Code, der genauso aussieht, als hättest du ihn von Hand in JavaScript geschrieben.

Die offizielle Handbook-Formulierung bringt es auf den Punkt:

Type annotations never change the runtime behavior of your program.

Daraus folgt eine harte, oft unterschätzte Wahrheit: Zur Laufzeit weiß deine Anwendung nichts mehr von TypeScript. Es gibt kein Interface namens User, es gibt keinen Typ string | number, es gibt keinen Generic Array<T>. All das ist eine reine Compile-Zeit-Fiktion, die dir der TypeScript-Service im Editor und der tsc-Lauf im CI vorspielt — danach ist sie weg.

Das ist kein Bug, sondern ein zentraler Designentscheid. TypeScript hat sich bewusst gegen ein eigenes Runtime-Format entschieden, weil es ein striktes Superset von JavaScript bleiben wollte. Jedes gültige JavaScript ist gültiges TypeScript, und jeder TypeScript-Output ist ganz normales JavaScript, das in jeder Engine läuft.

Was tsc tatsächlich macht

Am klarsten siehst du das Prinzip im direkten Vorher/Nachher-Vergleich. Hier ein typisches TypeScript-Modul mit Interface, Typen und Funktion:

ts user.ts
interface User {
    id: number;
    name: string;
    email: string;
}

function greet(user: User): string {
    return `Hallo, ${user.name}!`;
}

const anna: User = {
    id: 1,
    name: "Anna",
    email: "anna@example.com",
};

console.log(greet(anna));

Und so sieht das emittierte JavaScript aus — exakt das, was im Browser oder in Node landet:

js user.js (Output)
function greet(user) {
    return `Hallo, ${user.name}!`;
}

const anna = {
    id: 1,
    name: "Anna",
    email: "anna@example.com",
};

console.log(greet(anna));

Das Interface User ist komplett verschwunden. Es gibt keine Datei, keinen Eintrag, keinen Marker — gar nichts. Auch die Typ-Annotation : User an anna und : string am Funktions-Rückgabewert sind weg. Genau das kannst du jederzeit selbst im TypeScript Playground nachvollziehen: TS-Code links, JS-Output rechts.

Was übrig bleibt, ist die Struktur der Daten — die id, name, email-Properties auf dem Objekt. Aber die Garantie, dass id immer eine Zahl ist, lebt nur im Editor. Schickst du dem Code zur Laufzeit ein Objekt mit id: "nicht-eine-zahl", beschwert sich nichts. Das ist der zentrale Punkt, an dem viele Bugs entstehen.

Konsequenzen für Reflection

Klassische OOP-Sprachen wie Java oder C# bieten Reflection: zur Laufzeit Typ-Metadaten abfragen, Felder enumerieren, Annotationen auslesen. In TypeScript existiert das in dieser Form nicht, weil die Typen ja nicht mehr da sind.

Konkret heißt das:

  • typeof MyInterface funktioniert nicht — MyInterface ist gar kein Wert, sondern ein Typ. Im JS-Output existiert das Symbol gar nicht.
  • instanceof MyInterface ist ebenfalls unmöglich. instanceof arbeitet auf realen JavaScript-Klassen, nicht auf strukturellen TypeScript-Typen.
  • Es gibt keine API à la Object.getType(value), die dir den deklarierten TypeScript-Typ zurückgibt.

Frameworks, die so etwas suggerieren — etwa NestJS oder class-validator — funktionieren nur, weil sie auf einer Hilfskonstruktion aufsetzen: Decorators plus reflect-metadata. Schaltest du in tsconfig.json die Optionen experimentalDecorators und emitDecoratorMetadata an, schreibt der Compiler zusätzliche Aufrufe in das emittierte JS, die Typ-Metadaten zur Laufzeit ablegen. Das ist ein Opt-in-Mechanismus, der die Type-Erasure-Regel gezielt durchbricht — kein Standardverhalten.

Mit dem Stage-3-Decorators-Standard, der seit TypeScript 5.0 ausgereift ist, ändert sich das Bild noch einmal: Die neuen Decorators emittieren standardmäßig keine Metadaten mehr. Wer das alte Verhalten braucht, muss bei den Legacy-Decorators bleiben — oder die Metadaten von Hand verdrahten.

Konsequenzen für Validation

Die zweite große Konsequenz betrifft alles, was von außen in deine Anwendung kommt: HTTP-Responses, JSON aus dem Filesystem, Form-Daten, postMessage-Payloads, WebSocket-Nachrichten. Aus TypeScript-Sicht ist jeder dieser Werte zunächst unknown — und das aus gutem Grund.

Folgender Code sieht typsicher aus, ist es aber nicht:

ts unsafe-fetch.ts
interface User {
    id: number;
    name: string;
}

async function loadUser(): Promise<User> {
    const response = await fetch("/api/user/1");
    const data = await response.json();
    return data as User; // Lüge!
}

Der as User-Cast ist eine reine Behauptung gegenüber dem Compiler. Zur Laufzeit prüft niemand, ob das zurückgegebene JSON tatsächlich die Felder id und name im richtigen Format hat. Liefert die API stattdessen { user_id: 1, full_name: "Anna" }, fällt das erst auf, wenn weit unten im Code user.name plötzlich undefined ist.

Genau diese Lücke schließen Runtime-Schema-Validatoren wie Zod, Valibot oder io-ts. Sie definieren ein Schema, das tatsächlich zur Laufzeit Code ausführt, jede Property prüft und im Fehlerfall einen aussagekräftigen Fehler wirft. Gleichzeitig leiten sie aus dem Schema einen TypeScript-Typ ab — du musst die Struktur also nicht doppelt definieren.

Type Guards als Laufzeit-Brücke

Innerhalb deines eigenen Codes hast du eine elegantere Möglichkeit, die Compile-Zeit-Welt mit der Laufzeit zu verbinden: Type Guards. Das sind Ausdrücke, die TypeScript versteht und in eine Type Narrowing übersetzt — gleichzeitig sind sie aber echter JavaScript-Code, der zur Laufzeit ausgeführt wird.

Die wichtigsten Varianten:

ts type-guards.ts
// typeof — für Primitive
function format(value: string | number): string {
    if (typeof value === "number") {
        return value.toFixed(2); // value: number
    }
    return value.toUpperCase();  // value: string
}

// instanceof — für Klassen
function describe(input: Date | Error): string {
    if (input instanceof Date) {
        return input.toISOString();
    }
    return input.message;
}

// in — für Property-Checks
type Cat = { meow: () => void };
type Dog = { bark: () => void };

function speak(pet: Cat | Dog) {
    if ("meow" in pet) {
        pet.meow();
    } else {
        pet.bark();
    }
}

Mit User-defined Type Predicates definierst du eigene Guards, deren Signatur value is T zurückgibt:

ts predicate.ts
interface Admin {
    role: "admin";
    permissions: string[];
}

function isAdmin(value: unknown): value is Admin {
    return (
        typeof value === "object" &&
        value !== null &&
        "role" in value &&
        (value as { role: unknown }).role === "admin"
    );
}

function handle(input: unknown) {
    if (isAdmin(input)) {
        // input: Admin
        console.log(input.permissions);
    }
}

Wichtig: Ein Predicate ist nur so verlässlich wie seine Implementierung. Schreibst du return true rein, vertraut TypeScript dir — und der Bug ist im Code zementiert.

Was NICHT erased wird

Type Erasure trifft Typen, Interfaces, Generics, Type Aliases und Namespaces — also alles rein deklarative TypeScript-Material. Es gibt aber Konstrukte, die im JS-Output sehr wohl überleben, weil sie echte JavaScript-Werte sind:

  • Klassen-Deklarationenclass Foo { ... } wird zu einer echten JavaScript-Klasse. instanceof Foo funktioniert wie gewohnt.
  • Numerische Enums — werden zu einem Objekt mit beidseitiger Zuordnung ({ 0: "Red", Red: 0 }).
  • String-Enums — werden zu einem einfachen Objekt mit String-Werten.
  • const enum — wird komplett inlined, also durch die Literalwerte ersetzt (sofern preserveConstEnums nicht aktiv ist).
  • Decorators — werden zu Funktionsaufrufen, die auf Klasse/Methode angewendet werden.

Beispielhaft, was aus einem numerischen Enum wird:

ts enum.ts
enum Status {
    Active,
    Inactive,
    Pending,
}
js enum.js (Output)
var Status;
(function (Status) {
    Status[Status["Active"] = 0] = "Active";
    Status[Status["Inactive"] = 1] = "Inactive";
    Status[Status["Pending"] = 2] = "Pending";
})(Status || (Status = {}));

Das ist überraschend viel Code für etwas, das wie eine Konstanten-Liste aussieht — und ein Grund, warum viele Teams heute String-Literal-Unions (type Status = "active" | "inactive" | "pending") gegenüber Enums bevorzugen.

Praxisbeispiel: API-Antwort sauber validieren

So sieht der vollständige Pattern aus, wenn du externe Daten korrekt validierst und den Typ daraus ableitest:

ts user-api.ts
import { z } from "zod";

const UserSchema = z.object({
    id: z.number().int().positive(),
    name: z.string().min(1),
    email: z.string().email(),
    role: z.enum(["admin", "editor", "viewer"]),
});

// Typ wird AUS dem Schema abgeleitet — keine Doppelung.
type User = z.infer<typeof UserSchema>;

async function loadUser(id: number): Promise<User> {
    const response = await fetch(`/api/users/${id}`);
    const raw: unknown = await response.json();

    // Echte Laufzeit-Validierung. Wirft bei Schema-Verletzung.
    return UserSchema.parse(raw);
}

Drei Dinge passieren hier sauber:

  • Das Schema ist die einzige Quelle der Wahrheit — Compile-Zeit-Typ und Laufzeit-Prüfung kommen aus derselben Definition.
  • response.json() wird bewusst nach unknown typisiert (statt any), damit TypeScript dich zwingt, durch das Schema zu gehen.
  • UserSchema.parse(raw) ist echter Laufzeit-Code — er liest jede Property, prüft Format und Wertebereich und wirft eine ZodError, wenn etwas nicht passt.

Genau diese Brücke fehlt vielen TypeScript-Codebases — und ist meistens der Grund, warum undefined is not a function trotz Typen weiterhin in Produktion auftaucht.

Auswirkungen auf Tests

Aus dem Type-Erasure-Prinzip folgt eine wichtige Konsequenz für deine Test-Strategie: TypeScript-Typen sind keine Tests. Sie ersetzen weder Unit-Tests noch Integration-Tests — sie sind eine andere Art von Garantie.

Man unterscheidet sinnvoll:

  • Typen-Tests — Code, der ausschließlich beim Compile-Lauf geprüft wird. Tools wie tsd, expect-type oder @ts-expect-error-Kommentare validieren, dass bestimmte Type-Inference-Ergebnisse stabil bleiben. Sinnvoll bei Libraries mit komplexen Typ-APIs.
  • Verhaltens-Tests — Vitest, Jest, Playwright. Sie prüfen, was der emittierte JavaScript-Code zur Laufzeit tatsächlich tut. Hier liegen alle Garantien gegen Regressionen, Edge-Cases und kaputte Inputs.

Ein häufiges Anti-Pattern: Eine Funktion akzeptiert User, das Test-Setup übergibt ein as User gecastetes Mock-Objekt, das Felder fehlen lässt — der Test ist grün, weil die Funktion die fehlenden Felder gar nicht berührt. In Produktion knallt es trotzdem, weil das echte Backend andere Daten schickt. Die Lehre: Tests, die nur die TS-Typen mocken, geben falsche Sicherheit. Echte Validation, echte Fixtures, echte End-to-End-Pfade fangen das auf, was der Compiler nicht sehen kann.

Häufige Stolperfallen

Vertrauen, dass parse(json) automatisch typsicher ist.

JSON.parse liefert any bzw. unknown — die Struktur prüft niemand. Ohne Schema-Validator (Zod, Valibot, io-ts) ist jede daraus abgeleitete Typ-Annotation eine reine Behauptung.

as-Casts als Hoffnung statt Garantie.

value as User sagt dem Compiler nur, er solle die Klappe halten. Zur Laufzeit ändert sich nichts. Casts gehören in Ausnahmefälle — bei jedem as solltest du dich fragen, ob nicht ein Type Guard oder ein Schema die bessere Lösung wäre.

typeof MyInterface funktioniert nicht.

Interfaces existieren zur Laufzeit nicht. typeof arbeitet auf Werten — und ein Interface ist kein Wert. Der TypeScript-Operator typeof auf Typ-Ebene ist eine ganz andere Sache und wird nur vom Compiler ausgewertet.

instanceof MyInterface ebenfalls nicht.

instanceof prüft die Prototype-Chain einer echten JS-Klasse. Interfaces und Type Aliases haben keine Prototype, keinen Constructor, nichts — du musst für strukturelle Checks ein Type Predicate oder Schema schreiben.

Numerische Enums hinterlassen Code-Spuren.

Anders als viele erwarten, sind numerische Enums zur Laufzeit ein vollständiges Objekt mit beidseitigem Lookup — kein Inline-Constant. Wer reine Konstanten will, nutzt const enum oder String-Literal-Unions.

Decorators-Metadata braucht zusätzliche Konfiguration.

Damit Frameworks wie NestJS oder class-validator zur Laufzeit Typ-Infos lesen können, müssen experimentalDecorators und emitDecoratorMetadata aktiv sein — und reflect-metadata muss als Polyfill geladen werden. Ohne diese Drei-Komponenten-Konstellation gibt es keine Typ-Reflection.

Stage-3-Decorators verhalten sich anders als Legacy.

Seit TypeScript 5.0 sind die neuen Stage-3-Decorators stabil — sie emittieren standardmäßig keine Metadaten und haben eine andere Signatur. Bestehende Decorator-Bibliotheken funktionieren oft nur mit dem Legacy-Modell. Migration sorgfältig planen.

unknown ist sicher, aber zwingt zu Type Guards.

Wer von any auf unknown umstellt, gewinnt enorm an Sicherheit — der Compiler verlangt jetzt vor jeder Operation einen Narrowing-Schritt. Genau das ist gewollt: unknown macht die fehlende Laufzeit-Garantie sichtbar, statt sie zu verstecken.

Weiterführende Ressourcen

Externe Quellen

/ Weiter

Zurück zu Grundlagen

Zur Übersicht