TypeScript Best Practices für große Projekte
TypeScript-Best Practices für große Projekte: Strict Mode, noUncheckedIndexedAccess, exactOptionalPropertyTypes, generierte Typen, Laufzeitvalidierung und CI-Guardrails.
Im großen Maßstab zahlt sich TypeScript nur durch disziplinierte Konsistenz aus: strict mode als Ausgangspunkt, explizite und validierte Grenzen, an den Schnittstellen generierte Typen, damit Teams nicht auseinanderdriften, und ein kleines Set an Patterns, auf das sich das gesamte Team tatsächlich geeinigt hat. Die Sprache selbst ist seit Jahren nicht mehr das eigentliche Problem — die eigentliche Herausforderung besteht darin, eine millionenzeilige Codebasis mit vielen Mitwirkenden refaktorierbar zu halten, ohne dass bei jedem Pull Request eine Type-Safety-Regression einschleicht. Dieser Leitfaden behandelt die Konventionen, Compiler-Flags und Architekturmuster, die in dieser Größenordnung standhalten, einschließlich eines Migrationspfads für die nachlässig konfigurierte Codebasis, die Sie wahrscheinlich geerbt haben — und er ist auf dem Stand der beiden Releases, die die Landschaft 2026 neu definiert haben: TypeScript 6.0 (GA) und 7.0 (Release Candidate).
Wichtigste Erkenntnisse
- Ab TypeScript 6.0 (veröffentlicht am 23. März 2026) ist
strictstandardmäßig auftruegesetzt — bei einer modernen, großen Codebasis ist strict mode damit der Ausgangspunkt, nicht das Ziel. - Die zwei Flags, die bei einer großen Codebasis wirklich den Unterschied machen, sind gar nicht Teil von
strict:noUncheckedIndexedAccessundexactOptionalPropertyTypesmüssen explizit aktiviert werden und fangen genau die Array-Index- und Optional-Property-Fehler ab, die strict stillschweigend durchlässt. - Generierte Typen sind die einzelne wirkungsvollste Praxis für eine Codebasis mit mehreren Teams: Wenn Frontend und Backend beide ihre Typen aus einem gemeinsamen OpenAPI- oder Prisma-Schema ableiten, können die beiden Seiten physisch nicht auseinanderdriften, und CI schlägt in dem Moment fehl, in dem sich der Vertrag ändert.
- Statische Typen sind ein Versprechen zur Kompilierzeit, keine Laufzeitprüfung — eine als
Usertypisierte Response wird nur behauptet, eine solche zu sein, weshalb jede externe Schnittstelle neben einem generierten Typ auch eine Laufzeitvalidierung benötigt. - Microsoft berichtet, dass der Go-basierte Compiler von TypeScript 7.0 bei großen Codebasen oft ~10× schneller ist als 6.0; mit dem Release Candidate vom Juni 2026 ist er im Standard-
tsc-Binary und imtypescript-Paket enthalten.
Compiler-Disziplin: strict mode ist der Boden, nicht die Errungenschaft
Jeder oberflächliche Best-Practices-Artikel empfiehlt noch immer, „strict mode zu aktivieren”, als wäre das eine mutige Entscheidung. Diese Sichtweise ist inzwischen überholt. Seit den Release Notes von TypeScript 6.0 ist strict standardmäßig true auf Compiler-Ebene — wer sich auf den alten Standard false verlassen hat, muss jetzt explizit "strict": false setzen. TypeScript 6.0 wurde am 23. März 2026 angekündigt und soll das letzte Release sein, das auf der aktuellen JavaScript-Codebasis basiert. Bei jedem Projekt, das seinen Compiler aktualisiert, ist strict also die vorausgesetzte Grundlage.
Das eigentliche Upgrade für große Projekte sind die zwei besonders wertvollen Flags, die strict nicht bündelt. strict aktiviert ungefähr neun Typsicherheitsprüfungen (noImplicitAny, strictNullChecks und ähnliche), lässt aber noUncheckedIndexedAccess aus — das jedem nicht deklarierten Index-Zugriff undefined hinzufügt — sowie exactOptionalPropertyTypes, das zwischen einer auf undefined gesetzten Property und einer fehlenden Property unterscheidet. Diese Flags fangen genau die Fehler ab, die durch eine „strikte” Codebasis schlüpfen: der Array-Zugriff, der das Vorhandensein eines Elements voraussetzt, und das optionale Feld, das vorhanden, aber undefined ist.
Eine versionsstempelbasierte tsconfig.json für große Projekte (TypeScript 6.0.x):
{
"compilerOptions": {
"strict": true, // Standard seit 6.0; für ältere Toolchains explizit behalten
"noUncheckedIndexedAccess": true, // arr[i] ist T | undefined, nicht T
"exactOptionalPropertyTypes": true, // { x?: number } lehnt { x: undefined } ab
"verbatimModuleSyntax": true, // Type-only-Imports erzwingen (siehe Build-Performance)
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"composite": true // für Project References erforderlich
}
}
Das schrittweise Migrations-Playbook für mehr Striktheit
Discover how at OpenReplay.com.
Wenn Sie eine nachlässig konfigurierte Codebasis geerbt haben, schalten Sie nicht alle Flags auf einmal um — wenden Sie Striktheit Verzeichnis für Verzeichnis an, verfolgen Sie einen type-coverage-Prozentsatz in CI und erhöhen Sie den Schwellenwert schrittweise, sodass die Coverage niemals zurückgehen kann. Eine 200.000-Zeilen-App mit Tausenden impliziter anys wird am ersten Tag nicht sauber kompilieren, und ein PR mit 2.800 Fehlern ist nicht reviewbar.
Eine realistische Vorgehensweise:
strict: trueglobal aktivieren, die Durchsetzung aber eingrenzen: eine permissive Basis-tsconfigbeibehalten und pro Feature-Verzeichnis strengeretsconfig.json-Dateien mit Project References hinzufügen, beginnend mit den problematischsten Bereichen.type-coverageals Ratsche in CI einbauen — den Build scheitern lassen, wenn der Prozentsatz typisierter Symbole unter die zuletzt erfasste Zahl fällt. Die Coverage kann stagnieren, aber niemals zurückgehen.- Die zusätzlichen Flags (
noUncheckedIndexedAccess,exactOptionalPropertyTypes) stufenweise einführen und verbleibende Verstöße mit// @ts-expect-errorversehen, dann die Liste abarbeiten.@ts-expect-errormeldet sich selbst, wenn eine Unterdrückung unnötig wird, sodass der Rückstand nicht stillschweigend veralten kann.
Typendesign im großen Maßstab
Gute Typen im großen Maßstab machen illegale Zustände nicht kompilierbar und machen fachliche Fehler laut. Drei Patterns erledigen den Großteil der Arbeit; der Rest ist Konsistenz.
Die interface-vs-type-Regel, ein für alle Mal formuliert: Verwenden Sie interface für öffentliche, erweiterbare Objektverträge (es unterstützt Declaration Merging und erzeugt bei großen Objektstrukturen tendenziell bessere Fehlermeldungen), und verwenden Sie type für Unions, Intersections, Mapped Types und Conditional Types. Das ist die gesamte Debatte. Legen Sie die Regel fest, setzen Sie sie per Lint durch, und gehen Sie weiter.
Unmögliche Zustände undarstellbar machen
Boolean-Flag-Chaos ist die häufigste Quelle von „Das sollte nie passieren”-Fehlern in einer großen UI-Codebasis. Der folgende Typ erlaubt sechzehn Kombinationen, von denen die meisten keinen Sinn ergeben — isLoading und error gleichzeitig gesetzt, data während eines Fehlers vorhanden:
// Anti-Pattern: jedes Feld unabhängig, unmögliche Zustände erlaubt
interface RequestState<T> {
isLoading: boolean;
isError: boolean;
data?: T;
error?: Error;
}
Eine Discriminated Union reduziert das auf genau die Zustände, die auftreten können, und der Compiler zwingt Sie, jeden einzelnen zu behandeln:
type RequestState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error };
function render<T>(state: RequestState<T>) {
switch (state.status) {
case "success":
return state.data; // data existiert nur hier
case "error":
return state.error; // error existiert nur hier
// ein fehlender Case ist ein Compile-Fehler bei einer korrekten Exhaustiveness-Prüfung
}
}
Session-Replays von Boolean-Flag-basierten Request-States zeigen häufig genau den Fehlermodus, den dieses Pattern eliminiert: eine UI, die gleichzeitig einen Spinner und veraltete Daten rendert, weil zwei unabhängige Booleans nicht mehr synchron sind.
Domain-IDs mit Branded Types versehen
Branded Types machen UserId und OrderId zu inkompatiblen Typen, obwohl beide zur Laufzeit string sind — es ist ein Compile-Fehler, einen dort zu übergeben, wo der andere erwartet wird:
declare const brand: unique symbol;
type Brand<T, B> = T & { readonly [brand]: B };
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
const asUserId = (s: string) => s as UserId;
function cancelOrder(id: OrderId) { /* ... */ }
const u = asUserId("u_123");
cancelOrder(u); // ❌ Argument of type 'UserId' is not assignable to 'OrderId'
Bei einer Funktionssignatur mit fünf String-Argumenten ist Branding der Unterschied zwischen einem vertauschten-Argument-Fehler, der zur Kompilierzeit gefunden wird, und einem, der erst in der Produktion auftritt.
unknown gegenüber any bevorzugen. any deaktiviert den Checker und propagiert sich stillschweigend; unknown erzwingt einen Narrowing-Schritt vor der Verwendung. Verbieten Sie any per Lint und behandeln Sie jeden externen Wert — JSON.parse, catch-Bindings, Rückgaben untypisierter Bibliotheken — als unknown, bis das Gegenteil bewiesen ist. Verwenden Sie as const für literale Konfigurationen und Lookup-Tabellen, damit diese als enge Literal-Typen inferiert werden statt als erweiterte Primitive.
Typen an den Schnittstellen modellieren und generieren
Die wirkungsvollste Architekturentscheidung in einer großen Codebasis ist, wie Sie die Ränder typisieren. Zwei Regeln.
Erstens: Verwenden Sie nicht einen einzigen User-Typ über die Wire-Schicht, die Datenbank und die UI hinweg — modellieren Sie die API-Response, das DTO und die Domain-Entity als drei verschiedene Typen, damit eine Änderung an einer Schnittstelle nicht stillschweigend eine andere korrumpieren kann. Die Form, die Ihr Backend serialisiert, die Form, die Ihr ORM zurückgibt, und die Form, die Ihre Komponenten konsumieren, divergieren mit der Zeit; sie in einem einzigen Typ zusammenzufassen, koppelt jede Schicht mit jeder anderen.
Zweitens: Generieren Sie die Schnittstellentypen, anstatt sie von Hand zu schreiben. Wenn Frontend und Backend beide ihre Typen aus einem gemeinsamen Schema ableiten, können die beiden Seiten physisch nicht auseinanderdriften, und CI schlägt in dem Moment fehl, in dem sich der Vertrag ändert. Verwenden Sie openapi-typescript, um ein OpenAPI-3.0/3.1-Dokument in laufzeitfreie Typen umzuwandeln, Prisma für datenbankabgeleitete Typen oder GraphQL Code Generator für typisierte Operationen. Regenerieren Sie in CI und schlagen Sie bei einer Abweichung fehl:
# CI-Schritt: Neu generieren und fehlschlagen, wenn die eingecheckten Typen veraltet sind
npx openapi-typescript ./openapi.yaml -o ./src/api/schema.gen.ts
git diff --exit-code ./src/api/schema.gen.ts
Ein generierter Typ ist jedoch immer noch nur ein Versprechen zur Kompilierzeit. Statische Typen sind ein Versprechen zur Kompilierzeit, keine Laufzeitprüfung — eine als User typisierte Response wird nur behauptet, eine solche zu sein, weshalb jede externe Schnittstelle neben einem generierten Typ auch eine Laufzeitvalidierung benötigt. Validieren Sie den tatsächlichen Payload mit einer Schema-Bibliothek wie Zod oder Valibot und leiten Sie den statischen Typ aus dem Schema ab, sodass eine einzige Definition beide Schichten absichert:
import { z } from "zod";
const User = z.object({ id: z.string(), email: z.email() });
type User = z.infer<typeof User>;
const res = await fetch("/api/me");
const user = User.parse(await res.json()); // wirft einen Fehler, wenn die tatsächliche Form nicht stimmt
Das Argument für die Validierung von Schnittstellen — nicht nur deren Typisierung — ist empirisch: Statische Typen verschwinden zur Laufzeit, und Session-Replay ist eine Technik, um die Fehler sichtbar zu machen, die Typen nicht abfangen können — den Moment, in dem eine echte API-Response nicht mit ihrer deklarierten Form übereinstimmt und die UI in einer Benutzersitzung abbricht.
Typen für eine Codebasis mit mehreren Mitwirkenden organisieren
- Typen zusammen mit dem Code ablegen, der sie verwendet — in derselben Datei oder als Geschwisterdatei
*.types.ts— und einetypes/index.ts(oder ein dediziertes Paket) ausschließlich für wirklich gemeinsam genutzte Verträge reservieren. - Nach Feature/Domain-Ordner organisieren, nicht nach technischer Schicht, damit Typen, Komponenten und Logik eines Features zusammenbleiben und die Zuständigkeit klar ist.
- In einem Monorepo Pakete mit Project References und
paths-Mapping verknüpfen, damit Imports saubere Modulgrenzen überqueren (@org/billing) statt fragiler../../../-Ketten, und damit der Compiler den Abhängigkeitsgraphen durchsetzt.
Build-Performance 2026: Der native Compiler verändert die Ausgangslage
Die Diskussion über Build-Performance hat sich grundlegend verändert und dreht sich nicht mehr darum, mit tsc-Flags Sekunden einzusparen. TypeScript kündigte den 7.0 Release Candidate am 18. Juni 2026 an; mit nativer Code-Geschwindigkeit und Shared-Memory-Parallelismus ist er bei großen Codebasen oft etwa zehnmal schneller als TypeScript 6.0. Microsofts Benchmark-Schlagzeile: Die ~1,5-Millionen-Zeilen-Codebasis von VS Code wurde in etwa 7,5 Sekunden geprüft, gegenüber 77,8 Sekunden mit dem vorherigen Compiler — wobei der Faktor bei kleineren Projekten geringer ausfällt.
Das Packaging ist der entscheidende Punkt. Die wichtigste praktische Änderung im RC betrifft das Packaging: Der Go-basierte Rewrite wurde aus einem separaten native-preview-Paket in das reguläre TypeScript-npm-Paket verschoben, sodass TypeScript 7.0 nun als normaler tsc-Compiler für breitere Tests bereitsteht. Installieren Sie es mit npm install -D typescript@rc und führen Sie das Standard-tsc-Binary aus — die älteren Pakete tsgo / @typescript/native-preview enthalten nun nur noch Nightlies. Stand Ende Juni 2026 ist die neueste stabile Version TypeScript 6.0.3, mit 7.0 im RC-Stadium und einem stabilen GA, der etwa einen Monat nach dem RC erwartet wird. Betrachten Sie Version und Status als volatil und prüfen Sie sie zum Zeitpunkt der Übernahme erneut.
Die strukturellen Stellschrauben, die Sie in Ihrer eigenen Konfiguration kontrollieren, lohnen sich weiterhin auf jedem Compiler: Project References für inkrementelle, abhängigkeitsbewusste Builds und Type-only-Imports, die durch verbatimModuleSyntax erzwungen werden, damit type-only-Symbole gelöscht und nie als Laufzeit-Imports emittiert werden. verbatimModuleSyntax (eingeführt in 5.0) ist der aktuell empfohlene Ansatz; die Flags, die es ersetzt hat — importsNotUsedAsValues und preserveValueImports — wurden in 5.5 zu No-ops und sind ab 6.0 ein Fehler, wenn sie angegeben werden.
import type { User } from "./user"; // wird vollständig aus der JS-Ausgabe entfernt
import { fetchUser } from "./api"; // Value-Import, wird beibehalten
Die Leitplanken automatisieren
Konventionen, die nicht durchgesetzt werden, verfallen. Führen Sie typescript-eslint mit typbewussten Regeln aus (no-explicit-any, no-floating-promises, no-misused-promises), damit die oben genannten Patterns mechanisch geprüft werden, nicht im Review. Überlassen Sie die Durchsetzung von Type-only-Imports verbatimModuleSyntax statt der Lint-Regel consistent-type-imports — beide gleichzeitig auszuführen ist redundant und kann zu widersprüchlichen Fehlern führen. Führen Sie tsc --noEmit in CI bei jedem PR als harte Schranke aus, zusammen mit der type-coverage-Ratsche aus dem Migrations-Playbook. Und behalten Sie eine menschliche Leitplanke über all der Automatisierung: Klarheit vor Cleverness. Ein tief verschachtelter Conditional-und-Mapped-Type, den ein erfahrener Engineer zehn Minuten zum Lesen braucht, ist eine Verbindlichkeit, kein Beweis von Können — der Großteil des Typcodes in großen Projekten sollte langweilig, lesbar und offensichtlich sein.
Der rote Faden ist Konsistenz, nicht Raffinesse. Aktivieren Sie die zwei Flags, die strict auslässt, machen Sie Ihre illegalen Zustände nicht kompilierbar, generieren und validieren Sie Ihre Schnittstellen, und lassen Sie CI den Rest durchsetzen. Nehmen Sie die oben genannte, versionsgepinnte tsconfig diese Woche als Ausgangspunkt, richten Sie dann die type-coverage-Ratsche auf Ihr problematischstes Verzeichnis und beginnen Sie aufzusteigen.
FAQs
Ist strict mode ausreichend für ein großes TypeScript-Projekt?
Nein. Ab TypeScript 6.0 ist strict bereits standardmäßig auf true gesetzt, sodass es die Grundlage und nicht eine Errungenschaft darstellt. Die zwei Flags, die bei einer großen Codebasis am meisten bewirken, gehören gar nicht zur strict-Familie: noUncheckedIndexedAccess, das undefined zu nicht deklarierten Index-Zugriffen hinzufügt, und exactOptionalPropertyTypes, das zwischen einer auf undefined gesetzten Property und einer fehlenden Property unterscheidet. Aktivieren Sie beide explizit und fügen Sie dann Schnittstellentypen und Laufzeitvalidierung hinzu.
Was ist der Unterschied zwischen interface und type in TypeScript, und wann sollte ich welches verwenden?
Verwenden Sie interface für öffentliche, erweiterbare Objektverträge, da es Declaration Merging unterstützt und bei großen Objektstrukturen tendenziell klarere Fehlermeldungen erzeugt. Verwenden Sie type für Unions, Intersections, Mapped Types und Conditional Types, die interface nicht ausdrücken kann. Für ein großes Team lautet die praktische Regel: Diese Konvention einmal festlegen, per Lint-Regel durchsetzen und die Debatte beenden. Beide kompilieren zu identischen Typprüfungen für einfache Objektstrukturen, sodass die Wahl Ausdrucksstärke und Konsistenz betrifft, nicht Fähigkeiten.
Machen generierte Typen aus OpenAPI oder Prisma die Laufzeitvalidierung überflüssig?
Nein. Ein generierter Typ ist ausschließlich ein Versprechen zur Kompilierzeit. Eine als User typisierte JSON-Response wird lediglich behauptet, dieser Form zu entsprechen; der Compiler prüft den tatsächlichen Payload zur Laufzeit nie, sodass eine Backend-Änderung oder ein null-Feld weiterhin durchschlüpft. Generierte Typen verhindern, dass Frontend und Backend beim Vertrag auseinanderdriften, aber Sie benötigen dennoch eine Schema-Bibliothek wie Zod oder Valibot, um den tatsächlichen Payload an jeder externen Schnittstelle zu validieren. Leiten Sie den statischen Typ aus dem Schema ab, damit eine einzige Definition beide Schichten absichert.
Wie installiere und verwende ich TypeScript 7.0 im Release-Candidate-Stadium?
Installieren Sie es mit npm install -D typescript@rc und führen Sie das Standard-tsc-Binary aus. Mit dem Release Candidate vom Juni 2026 wurde der Go-basierte native Compiler aus dem separaten native-preview-Paket in das reguläre typescript-npm-Paket verschoben, sodass es kein separates tsgo-Binary mehr für den RC gibt; die älteren Pakete tsgo und typescript/native-preview enthalten nun nur noch Nightlies. Microsoft berichtet, dass 7.0 bei großen Codebasen oft etwa zehnmal schneller ist als 6.0. Betrachten Sie Version und Status als volatil und prüfen Sie sie vor der Übernahme erneut.
Complete picture for complete understanding
Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.
Star on GitHub12k