Dein Type Check war bestanden. Deine App ist trotzdem abgestürzt.
Du hast strict: true in der tsconfig.json aktiviert. Du hast jeden roten Wellenstrich behoben. Du hast mit dem Gefühl in Produktion ausgeliefert, dass null und undefined gelöste Probleme seien.
Dann änderte sich die Form einer Backend-Antwort, eine DOM-Query gab nichts zurück, und user.profile.name warf Cannot read properties of null in genau dem Code, von dem TypeScript dir versichert hatte, er sei sicher. Was ist passiert?
TypeScripts strikte Null-Checks sind ein Compile-Time-Contract. Sie prüfen, dass die Typen, die du geschrieben hast, intern konsistent sind. Sie prüfen nicht, dass die Daten, die zur Laufzeit ankommen, zu diesen Typen passen. Die Lücke zwischen diesen beiden Dingen ist der Ort, an dem die meisten Null-Crashes in der Produktion leben.
Wo strictNullChecks tatsächlich hilft
Wenn du strictNullChecks aktivierst, behandelt TypeScript null und undefined als eigenständige Typen, die explizit behandelt werden müssen.
// Without strictNullChecks, this compiles. With it, you get a type error.
function greet(name: string) {
console.log(name.toUpperCase())
}
greet(null) // Error: Argument of type 'null' is not assignable to parameter of type 'string'.
Das ist wirklich nützlich. Es fängt die Nulls auf, die du in deinem eigenen Code einführst: nicht initialisierte Variablen, fehlende Rückgabewerte, vergessene Default-Cases.
Der Haken ist, dass es nur auf Code funktioniert, den TypeScript zur Compile-Time sehen kann. Sobald deine App läuft, ist TypeScript weg. Das Typsystem verdampft. Was übrig bleibt, ist plain JavaScript, und plain JavaScript lässt null gerne durch jede Lücke fließen, die du offen gelassen hast.
Die vier Runtime-Lücken, die strictNullChecks nicht schließen kann
1. API-Antworten geben vor, zu deinen Typen zu passen
Du hast deine API-Antwort getypt. TypeScript hat dir geglaubt.
interface User {
id: number
profile: {
name: string
avatar: string
}
}
async function fetchUser(): Promise<User> {
const res = await fetch('/api/user')
return res.json() as User // TypeScript trusts this cast. The backend does not.
}
const user = await fetchUser()
console.log(user.profile.name) // Crashes if profile is null
Die as User-Assertion sagt TypeScript, keine Fragen mehr zu stellen. Sie validiert nicht den Payload. Wenn das Backend { id: 1, profile: null } zurückgibt, hat TypeScript deinen Code gegen eine Lüge compiliert. Der Crash ist real.
2. DOM-Queries geben null per Design zurück
const button = document.getElementById('submit')
button.addEventListener('click', handleClick) // Crashes if the element is missing
TypeScript weiß, dass getElementById HTMLElement | null zurückgibt. Im Strict Mode zwingt es dich, den Null-Fall zu behandeln. Viele Entwickler umgehen das mit einer Non-Null-Assertion:
const button = document.getElementById('submit')!
Das ! ist ein Versprechen an TypeScript, dass du es besser weißt. Wenn du dich irrst, landet der Crash in der Produktion.
3. noUncheckedIndexedAccess ist standardmäßig deaktiviert
Auch mit strict: true markiert TypeScript Array- oder Objekt-Index-Zugriffe nicht als potenziell undefined.
const users: User[] = []
const first = users[0]
first.name // No type error, but first is undefined at runtime
Gleiches gilt für Records:
const cache: Record<string, string> = {}
const value = cache['missing'] // Type: string. Runtime value: undefined.
Du brauchst noUncheckedIndexedAccess: true in deiner tsconfig.json, um diese Lücke zu schließen. Die meisten Teams schalten es nie ein, weil es den täglichen Code umständlicher macht. Die Umständlichkeit ist aber der Sinn.
4. any und Type Assertions deaktivieren alles
const data: any = JSON.parse(raw)
const user = data.user as User // All null checks are now voluntarily disabled
Jedes any oder as in deinem Codebase ist eine Tür, die du unverschlossen gelassen hast. TypeScript hält am Threshold an. Was hindurchgeht, ist deine Verantwortung.
Was tatsächlich zur Runtime funktioniert
TypeScripts Aufgabe ist es, Fehler im Code, den du schreibst, aufzuspüren. Runtime-Safety erfordert eine zweite Schicht an Defenses.
Am Boundary validieren
Nutze eine Schema-Library wie Zod, um externe Daten zu validieren, bevor sie in dein getyptes System gelangen.
import { z } from 'zod'
const UserSchema = z.object({
id: z.number(),
profile: z.object({
name: z.string(),
avatar: z.string().optional(),
}).nullable(),
})
const res = await fetch('/api/user')
const raw = await res.json()
const user = UserSchema.parse(raw) // Throws if the shape is wrong
// user.profile is typed as { name: string; avatar?: string } | null
if (user.profile) {
console.log(user.profile.name) // Safe
}
Die Kosten sind ein Runtime-Validation-Step an jedem Boundary. Der Nutzen ist, dass deine Typen nicht mehr Wunschdenken sind. Sie werden enforced.
noUncheckedIndexedAccess verwenden
Schalte es ein. Akzeptiere die Reibung.
// With noUncheckedIndexedAccess
const first = users[0] // Type: User | undefined
if (first) {
console.log(first.name) // Safe
}
Ja, es fügt überall Checks hinzu, wo du indexierst. Genau das willst du, wenn es dir um Null-Safety ernst ist.
Optional Chaining als Standard, nicht Non-Null Assertions
// Dangerous
const name = user.profile!.name
// Safe
const name = user.profile?.name ?? 'Anonymous'
Optional Chaining bricht bei null oder undefined ab. Der Nullish-Coalescing-Operator bietet einen Fallback. Dieses Pattern ist standardmäßig defensiv und explizit darüber, was passiert, wenn Daten fehlen.
Halte any aus deinem Datenfluss fern
Behandle any wie eine giftige Substanz. Wenn du es unbedingt verwenden musst, beschränke es auf den kleinstmöglichen Scope und validiere den Output sofort.
// Bad: any propagates
const data: any = JSON.parse(raw)
processData(data)
// Better: validate and narrow immediately
const parsed = JSON.parse(raw) as unknown
const data = UserSchema.parse(parsed)
processData(data)
unknown zwingt dich, die Shape zu beweisen, bevor TypeScript dich sie verwenden lässt. Es ist any mit Konsequenzen.
Warum Teams diese Defenses überspringen
Die meisten Teams überspringen sie nicht aus Unwissenheit. Sie überspringen sie, weil die sichereren Patterns bei jedem Datenzugriff, jedem API-Call, jeder DOM-Query Reibung erzeugen. Der Code wird länger. Die Typen werden lauter. Die Versuchung, ein ! oder ein as auf das Problem zu klatschen, wächst mit jeder Deadline.
Die Alternative sind Produktions-Crashes, von denen TypeScript dir versprochen hatte, du hättest sie nicht. Der Compiler hat seinen Teil der Abmachung eingehalten. Er hat deinen Code gegen die Typen geprüft, die du bereitgestellt hast. Er kann deinen Code nicht gegen die Realität prüfen.
Der Fix besteht aus zwei Schichten, nicht einer
strictNullChecks ist Schicht eins. Sie fängt die Nulls auf, die du selbst einführst. Schicht zwei ist alles andere: Schema-Validation, defensive Indexing, Optional Chaining und die Disziplin, any zu vermeiden.
Wenn deine TypeScript-App immer noch mit Nulls abstürzt, versagt der Compiler nicht. Die Lücke zwischen deinen Typen und deinen Runtime-Daten tut es. Schließe diese Lücke an den Boundaries, schalte noUncheckedIndexedAccess ein und hör auf, Backends zu vertrauen, dass sie deinen Interfaces entsprechen. TypeScript wird weiterhin strict sein. Aber zumindest werden deine Typen die Welt so beschreiben, wie sie tatsächlich ankommt.