Artikel

Wo endet die Typsicherheit von TypeScript?

Nur weil der Compiler durchläuft, ist dein Programm noch nicht korrekt. Fünf Stellen, an denen du das Typsystem anlügen kannst, ohne je any zu schreiben, was der Strict Mode übersieht und wie du die Lücken an der Grenze schließt.

Furkan Özay10. Juni 2025 · 7 Min. Lesezeit
czCZAY.DEV // EDITORIALDE
TYPE SYSTEM

#TypeScript · 7 MIN LESEZEIT

FURKAN ÖZAYVOL. 2025 // #119C

Es gibt unzählige Artikel, die erklären, was TypeScript ist. Dieser Beitrag stellt eine andere Frage: Du hast strict: true aktiviert, any nicht angefasst, der Compiler läuft durch – ist dein Programm damit korrekt?

Nein, ist es nicht. Und zu wissen, wo es hakt, macht den Unterschied zwischen einer echten Nutzung des Typsystems und blindem Vertrauen darauf.

Die grundlegende Wahrheit: Typen gibt es zur Laufzeit nicht

Beim Kompilieren von TypeScript werden Typen gelöscht. Was tatsächlich im Browser oder in Node landet, ist reines JavaScript; keine einzige Typprüfung überlebt bis zur Laufzeit.

TypeScript
type User = { id: number; name: string };
 
function greet(user: User) {
	return `Merhaba ${user.name}`;
}

Nach dem Kompilieren bleibt von dieser Funktion nur Folgendes übrig:

JavaScript
function greet(user) {
	return `Merhaba ${user.name}`;
}

Wenn greet zur Laufzeit also null bekommt, hält nichts es auf. Die Garantie von TypeScript lautet: Du kannst aus deinem eigenen Code heraus keinen ungültigen Aufruf machen. Alles, was von außerhalb deiner Codebasis hereinkommt – API-Antworten, Formulardaten, localStorage, Umgebungsvariablen – fällt komplett aus dieser Garantie heraus.

Die fünf folgenden Lücken ergeben sich alle aus genau dieser Realität.

Lücke 1: as ist keine Prüfung, sondern eine Behauptung

Das ist die häufigste und gefährlichste Lücke.

TypeScript
const res = await fetch("/api/user");
const user = (await res.json()) as User;
 
console.log(user.name.toUpperCase());

Hier findet keinerlei Validierung statt. as User zu schreiben heißt nur: „Ich sage dir, das ist ein User.“ Liefert die API { error: "not found" }, bleibt der Compiler völlig still, und die Zeile fliegt zur Laufzeit mit Cannot read properties of undefined in die Luft.

Warnung: Wann solltest du as verwenden?

Nur, wenn du wirklich etwas weißt, das der Compiler unmöglich ableiten kann, zum Beispiel document.getElementById("x") as HTMLInputElement. Wenn du as auf Daten aus der Außenwelt anwendest, validierst du nichts – du glaubst es nur.

Lücke 2: JSON.parse behandelt alles als any

TypeScript
const raw = localStorage.getItem("settings");
const settings = JSON.parse(raw!); // any
settings.theme.colors.primary; // compiler is silent

Der Rückgabetyp von JSON.parse ist any. Und any schaltet das Typsystem im Grunde ab: Alles, worauf du über any zugreifst, umgeht die Prüfungen ungeprüft, und dieses Verhalten breitet sich überallhin aus, wo der Wert zugewiesen wird.

Dasselbe gilt für fetch: res.json() gibt Promise<any> zurück.

Tipp: Eine kleine, aber wirksame Gewohnheit

Nimm das Ergebnis von JSON.parse als unknown entgegen. unknown akzeptiert ebenfalls jeden Wert, aber es zwingt dich, den Typ einzugrenzen, bevor du ihn verwendest:

TypeScript
const settings: unknown = JSON.parse(raw ?? "{}");
// settings.theme  → compiler error, you need to validate first

Lücke 3: Der !-Operator

Das Ausrufezeichen behauptet „das ist weder null noch undefined“ – wieder nur eine Behauptung.

TypeScript
const el = document.querySelector(".modal")!;
el.classList.add("open"); // blows up at runtime if the element doesn't exist
 
const user = users.find((u) => u.id === id)!;

Die zweite Zeile ist besonders tückisch: Findet find nichts, gibt es undefined zurück, aber ! bringt den Compiler zum Schweigen. Richtig ist, die Prüfung explizit zu behandeln:

TypeScript
const user = users.find((u) => u.id === id);
if (!user) {
	return notFound();
}

Lücke 4: strict fängt Array-Indexzugriffe nicht ab

Das habe ich selbst überprüft. Der folgende Code läuft unter strict: true ohne einen einzigen Fehler durch:

TypeScript
const arr: string[] = [];
const first = arr[0];
console.log(first.length); // at runtime: Cannot read properties of undefined

TypeScript geht davon aus, dass arr[0] den Typ string hat, obwohl das Array komplett leer ist. Genau dasselbe Problem gilt für Property-Zugriffe auf Record<string, T>.

Die Lösung steckt nicht in strict; dafür brauchst du ein explizites Flag:

Beispiel: tsconfig.json

JSON
{
	"compilerOptions": {
		"strict": true,
		"noUncheckedIndexedAccess": true
	}
}

Jetzt wirft derselbe Code einen Fehler: 'first' is possibly 'undefined'.

Wenn du dieses Flag aktivierst, siehst du in bestehendem Code womöglich eine Flut von Fehlern – aber jeder einzelne davon weist auf ein echtes Laufzeitrisiko hin.

Erklärung: Weitere Flags, die nicht in strict enthalten sind

strict: true ist ein Bündel, enthält aber nicht alles. Diese Flags musst du separat aktivieren:

  • noUncheckedIndexedAccess — ergänzt bei Index-Zugriffen auf Arrays und Objekte | undefined
  • exactOptionalPropertyTypes — verhindert, dass du Properties vom Typ { a?: string } explizit undefined zuweist
  • noImplicitOverride — erzwingt override bei Klassenmethoden
  • noPropertyAccessFromIndexSignature — verhindert Zugriffe per Punktnotation auf Index Signatures

Die ersten beiden solltest du in jedem neuen Projekt von Anfang an einschalten; sie später zu aktivieren, wird zu einer vollständigen Migration.

Lücke 5: process.env und Konfiguration

TypeScript
const dbUrl = process.env.DATABASE_URL; // string | undefined
const client = new Client({ url: dbUrl! }); // assertion again

Eine Umgebungsvariable ist kein Code, sondern ein Deployment-Detail. Ist eine Variable auf dem Server nicht gesetzt, tritt der Fehler an der schlimmstmöglichen Stelle auf: nicht beim Start, sondern in dem Moment, in dem genau dieser Codepfad zum ersten Mal läuft. Das richtige Muster ist, einmalig beim Start zu validieren:

TypeScript
function requireEnv(name: string): string {
	const value = process.env[name];
	if (!value) {
		throw new Error(`Eksik ortam değişkeni: ${name}`);
	}
	return value;
}
 
export const env = {
	databaseUrl: requireEnv("DATABASE_URL"),
	resendApiKey: requireEnv("RESEND_API_KEY"),
};

Jetzt weigert sich die App schlicht zu starten, wenn Konfiguration fehlt – sie scheitert früh, eindeutig und zum richtigen Zeitpunkt.

Die Lücken an der Grenze schließen

Alle fünf Lücken haben einen gemeinsamen Nenner: Sie entstehen genau an der Grenze, an der externe Daten ins System gelangen. Deshalb gibt es nur eine echte Lösung: an den Grenzen zur Laufzeit validieren und im Inneren deinen Typen vertrauen.

Zod löst das in einem Schritt – es validiert die Laufzeitdaten und leitet den TypeScript-Typ automatisch aus dem Schema ab, sodass du Typen nie doppelt deklarierst:

Beispiel: Validierung an der Grenze

TypeScript
import { z } from "zod";
 
const UserSchema = z.object({
	id: z.number(),
	name: z.string(),
	email: z.string().email(),
});
 
// Type is inferred from the schema — no need to write `type User` separately
type User = z.infer<typeof UserSchema>;
 
export async function getUser(id: number): Promise<User> {
	const res = await fetch(`/api/users/${id}`);
	const json: unknown = await res.json();
 
	// This actually validates at runtime; it's not an assertion like `as`
	return UserSchema.parse(json);
}

Ab diesem Punkt weißt du, dass user.name ein String ist; du rätst nicht. Genau derselbe Ansatz gilt für Formulardaten, Webhook-Payloads und localStorage.

Passend dazu: Formularvalidierung in Next.js: Client- und Server-seitig

Zwei veraltete Gewohnheiten

Hör auf, React.FC zu verwenden. Eine Zeit lang war es Standard, aber heute ist es völlig unnötig. Props direkt zu typisieren ist sauberer und harmoniert deutlich besser mit generischen Komponenten:

TSX
// Old
const Button: React.FC<ButtonProps> = ({ children }) => { /* ... */ };
 
// Now
function Button({ children }: ButtonProps) { /* ... */ }

Nimm statt enum lieber as const. Beim Kompilieren erzeugt enum echten Laufzeitcode – Typen werden entfernt, Enums bleiben. Ein Union Type dagegen erzeugt überhaupt nichts:

TypeScript
// Generates an object at runtime
enum LogLevel { DEBUG = "debug", INFO = "info" }
 
// Generates nothing, does the exact same job
const LOG_LEVELS = ["debug", "info", "warn", "error"] as const;
type LogLevel = (typeof LOG_LEVELS)[number];

Warnung: Vorsicht bei const enum

Vielleicht liest du den Rat, „const enum sei performanter“, aber es gibt einen gewichtigen Haken: Ist isolatedModules aktiviert – was Next.js erzwingt –, löst der Zugriff auf ein ambientes const enum in einer .d.ts einen Build-Fehler aus:

TS2748: Cannot access ambient const enums when 'isolatedModules' is enabled.

Ein const enum, das in deiner eigenen Datei definiert und importiert wird, funktioniert problemlos; ein ambientes aus einem Drittanbieter-Paket scheitert. Kennst du diesen Unterschied nicht, kostet dich das Debugging Stunden.

Zusammenfassung

Zusammenfassung: Checkliste

  • Typen gibt es zur Laufzeit nicht; Garantien gelten nur innerhalb deines eigenen Codes
  • as und ! sind Behauptungen; wendest du sie auf externe Eingaben an, hat keine Validierung stattgefunden
  • JSON.parse und res.json() geben any zurück – nimm sie als unknown entgegen, um das Eingrenzen zu erzwingen
  • strict fängt Array-Indexzugriffe nicht ab; aktiviere noUncheckedIndexedAccess separat
  • Validiere Umgebungsvariablen einmalig beim Start
  • Verlege die Laufzeitvalidierung an die Grenzen (mit Zod) und vertraue intern deinen Typen
  • Überdenke veraltete Gewohnheiten rund um React.FC und enum

TypeScript ist keine Engine zur formalen Verifikation, sondern ein Kommunikationswerkzeug. Du hältst damit in deinem Code fest, was du weißt – aber wenn du es anlügst, glaubt es dir stillschweigend. Das Schwierige ist nicht, Typen zu schreiben, sondern sicherzustellen, dass du den Compiler nicht anlügst.

Interaktive Roadmap & Tool

Als Nächstes lesen