Zehn TypeScript-Gewohnheiten, die große Codebasen lesbar und sicher halten
Erfahren Sie zehn praktische TypeScript-Gewohnheiten – von sinnvollen Generiken und Einschränkungen über ausführliche Überprüfungen, readonly-Attributen bis hin zu einer strengen tsconfig – die dazu beitragen, wachsende Codebasen wartbar zu halten.
Die meisten Probleme mit TypeScript in wachsenden Codebasen haben nichts damit zu tun, dass man nicht weiß, was Generiktypen oder bedingte Typen sind. Sie entstehen durch alltägliche Entscheidungen: zu starke Abstraktionen, Typen, die zu viel zulassen, überall verwendete as-Anweisungen, zweimal definierte Verträge, unleserliche Generiktyp-Signaturen, Funktionen, deren Argumente am Aufrufort keinen Sinn ergeben, Typen, die in zufälligen Dateien verstreut sind, sowie ein Compiler, der zu locker konfiguriert ist, um das zu erkennen, wofür das Team von ihm abhängt. In kleinen Projekten fallen diese Gewohnheiten kaum auf; bei Dutzenden von Entwicklern und über mehrere Jahre hinweg summieren sie sich jedoch zu erheblichen Problemen. Dieser Leitfaden zeigt zehn konkrete Praktiken auf, die dafür sorgen, dass TypeScript-Code leicht lesbar und änderbar bleibt, sowie eine Checkliste, die Sie bei Code-Reviews anwenden können.
Falls Sie zunächst den Modellierungsteil möchten, einschließlich der Methode, wie ungültige Zustände undarstellbar gemacht werden können, beginnen Sie mit der Modellierung von Domänen in TypeScript jenseits grundlegender Typangaben. Hier liegt der Fokus auf den Wartungsgewohnheiten, die zu guten Modellen gehören.
1. Generics als Mittel zur Darstellung von Beziehungen betrachten
Generics werden in der Regel als Wiederverwendungsmechanismus vorgestellt, und das sind sie tatsächlich. Ihre wichtigere Aufgabe besteht jedoch darin, Typen miteinander zu verbinden – dem Compiler mitzuteilen, dass das Ergebnis einer Funktion mit dem Eingang verbunden ist. Hier ist das kleinste nützliche Beispiel: eine Funktion, die das erste Element eines Arrays zurückgibt.
function getFirst<T>(items: T[]): T | undefined {
return items[0]
}
Der Typparameter T wird aus dem Argument übernommen. Geben Sie ein Array von Benutzern an:
const users: User[] = [...]
und der Compiler schließt entsprechend das Ergebnis ab:
const user = getFirst(users)
// User | undefined
Die gleiche Funktion funktioniert für einen anderen Elementtyp ohne jegliche zusätzliche Anmerkung:
const products: Product[] = [...]
und liefert ein korrekt typisiertes Produktresultat:
const product = getFirst(products)
// Product | undefined
Betrachten Sie nun, was passiert, wenn man das Generikum weglässt und stattdessen unknown verwendet. Die Funktion läuft weiter, doch der Zusammenhang zwischen Eingabe und Ausgabe verschwindet, wodurch jeder Aufrufer das Ergebnis casten oder einschränken muss.
function getFirst(items: unknown[]): unknown {
return items[0]
}
Ein guter Test vor der Einführung eines Typparameters besteht darin, die Beziehung zu benennen, die er aufrechterhält. Wenn man nicht sagen kann, welcher Eingabetyp welchen Ausgabetyp bestimmt, erfüllt das Generikum vermutlich nicht seine Aufgabe.
Ein Detail, das beachtet werden sollte: Wenn noUncheckedIndexedAccess aktiviert ist (erläutert in Abschnitt 10), wird items[0] vom Compiler selbst als T | undefined typisiert, was mit dem hier explizit angegebenen Rückgabetyp übereinstimmt.
2. Vermeiden Sie es, alles in Generics umzuwandeln
Weil Generics mächtig sind, wird leicht übermäßig davon Gebrauch gemacht. Es ist verlockend, eine Signatur mit mehreren voneinander abhängigen, eingeschränkten Typparametern zu schreiben, wie im folgenden Beispiel dargestellt. So erscheint die Schreibweise anspruchsvoll.
function processData<
T extends Record<string, unknown>,
K extends keyof T,
R extends ...
>(...) {
// ...
}
Der Entwickler, der das Datei sechs Monate später öffnet, hat in der Regel eine andere Ansicht. Jeder zusätzliche Typparameter ist etwas, das der Leser im Kopf behalten muss. Wenn die Funktion tatsächlich nur Benutzer verarbeitet, kommuniziert eine einfache Signatur weitaus effektiver:
function processUser(user: User) {
// ...
}
Die Expertise in TypeScript wird nicht dadurch gemessen, wie viel vom Typsystem man in eine einzige Deklaration unterbringen kann. Greifen Sie auf Generics zurück, wenn sie eine echte Beziehung zwischen Typen darstellen – und nicht nur, weil die Sprache es zulässt.
3. Beschränken Sie Werte statt sie zu casten
Eine Typbehauptung ist der schnellste Weg, ein Problem zu beseitigen:
const value = something as string
Das Problem ist, dass as nichts überprüft. Es weist den Compiler an, seine Unsicherheit beiseitezulegen und Ihnen zu glauben; falls Sie falsch liegen, tritt der Fehler stattdessen zur Laufzeit auf. Ein sichererer Ansatz besteht darin, den Typ mithilfe einer vom Compiler verstandenen Laufzeitprüfung nachzuweisen:
if (typeof something === 'string') {
console.log(something.toUpperCase())
}
Innerhalb des if-Blocks ist something eine String, da typeof ein Eingrenzungskonstrukt ist. Für Objekte sollten Sie einen benutzerdefinierten Typschutz schreiben. Der Rückgabetyp value is User teilt dem Compiler mit, dass ein true-Ergebnis bedeutet, dass der Argument als User behandelt werden kann.
function isUser(value: unknown): value is User {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
'name' in value
)
}
Dann erhalten die Aufrufer diese Eingrenzung kostenlos:
if (isUser(value)) {
console.log(value.name)
}
Der Unterschied ist einfach: Eine Assertion bittet den Compiler, Ihnen zu vertrauen, während eine Einschränkung die Beweise liefert. Denken Sie daran, dass ein Type Guard nur so ehrlich ist wie sein Inhalt. Das Beispiel überprüft, ob id und name vorhanden sind, aber nicht, welche Typen sie enthalten – daher möchten Sie für Daten aus dem Netzwerk oder der Speicherung möglicherweise strengere Überprüfungen oder einen Schema-Validator verwenden. Der Compiler vertraut vollständig auf das Urteil des Guards.
4. Lassen Sie never unvollständige Branching-Strukturen erkennen
Nehmen wir an, ein Status wird als Union von String-Literalen modelliert:
type Status =
| 'pending'
| 'approved'
| 'rejected'
Ein switch, der jeden Status auf ein Label abbildet, scheint vollständig zu sein:
function getLabel(status: Status) {
switch (status) {
case 'pending':
return 'Pending'
case 'approved':
return 'Approved'
case 'rejected':
return 'Rejected'
}
}
Er ist heute vollständig. Das Problem beginnt, wenn die Union wächst – zum Beispiel, wenn jemand einen „abgebrochenen“ Zustand hinzufügt:
type Status =
| 'pending'
| 'approved'
| 'rejected'
| 'cancelled'
Status kann an Dutzenden von Stellen verwendet werden, und man möchte, dass der Compiler auf jede dieser Stellen hinweist, die nicht mehr alle Fälle abdeckt. Die gängige Technik ist ein Exhaustiveness-Helper, der never akzeptiert. Im default-Zweig hat TypeScript bereits alle behandelten Member entfernt, sodass der verbleibende Typ never sein sollte. Wenn ein neuer Member durchrutscht, kann er nicht mit never zugewiesen werden, wodurch die Kompilierung fehlschlägt.
function assertNever(value: never): never {
throw new Error(`Unhandled value: ${value}`)
}
function getLabel(status: Status) {
switch (status) {
case 'pending':
return 'Pending'
case 'approved':
return 'Approved'
case 'rejected':
return 'Rejected'
default:
return assertNever(status)
}
}
Nach dem Hinzufügen von 'cancelled' führt der Aufruf von assertNever(status) zu einem Kompilierfehler, solange der neue Fall nicht behandelt wird. Die Union-Definition wird zur einzigen Quelle der Wahrheit, und der Compiler listet die Stellen auf, die aktualisiert werden müssen. Als Bonus schützt throw Sie zur Laufzeit, falls ein unerwarteter Wert von außerhalb des Typsystems eingehend wird.
5. Verwenden Sie readonly, um anzugeben, wie Daten verwendet werden sollen
Typen beschreiben, welche Werte zulässig sind, können aber auch angeben, wie mit diesen Werten umgegangen werden darf. Wenn eine Eigenschaft als readonly markiert wird, signalisiert das, dass sie einmal der Objekt erstellt wurde, festgelegt ist:
type User = {
readonly id: string
name: string
}
Eine Zuweisung wie die folgende wird dann vom Compiler abgelehnt:
user.id = '123'
Arrays können auf dieselbe Weise geschützt werden. Ein Parameter, der als readonly User[] typisiert ist, ermöglicht es der Funktion, zu iterieren und zu lesen, aber nicht hinzuzufügen, zu ersetzen oder vor Ort zu sortieren:
function processUsers(users: readonly User[]) {
// ...
}
Diese Signatur teilt jedem Aufrufer mit, dass die Funktion ihre Sammlung nicht ändern wird. readonly ist insbesondere nützlich für Konfigurationsobjekte, gemeinsam genutzte Daten, Konstanten, Funktionsparameter und unveränderlichen Zustand. Der Hauptvorteil liegt weniger darin, eine bestimmte Änderung zu verhindern, sondern vielmehr darin, die Absicht für alle, die den Typ lesen, zu dokumentieren. Beachten Sie, dass readonly nur oberflächlich und ausschließlich zur Kompilierzeit wirkt: Eingebettete Objekte bleiben veränderbar, es sei denn, sie werden ebenfalls als readonly markiert, und nichts wird zur Laufzeit „eingefroren“.
6. Verstecken Sie keine echten Strukturen hinter Record<string, unknown>
Signaturen wie diese sind häufig zu finden:
function process(data: Record<string, unknown>) {
// ...
}
Mannchmal ist das der richtige Typ. Wenn eine Funktion tatsächlich beliebige Schlüssel-Wert-Daten akzeptiert, wie beispielsweise ein generischer Logger oder ein Serialisierungs-Hilfsprogramm, ist ein breiter Datentyp ehrlich. Das Problem entsteht, wenn man ihn verwendet, obwohl man bereits weiß, um welches Objekt es sich handelt. Nehmen wir dieselbe Signatur:
function process(data: Record<string, unknown>) {
// ...
}
und modellieren wir die Daten, die wir tatsächlich erwarten:
type User = {
id: string
name: string
}
function process(user: User) {
// ...
}
Die Änderung wirkt oberflächlich, bringt aber große Vorteile: Autocomplete, inline-Dokumentation, sicheres Refactoring, Kompilierzeitgarantien sowie eine klare Absichtserklärung. Breite Typen gehören an wirklich dynamische Grenzen, wie zum Beispiel beim Parsen unbekannten JSONs, und sollten so bald wie möglich nach dieser Grenze in echte Typen umgewandelt werden, anstatt überall als Standard verwendet zu werden.
7. Entwerfen Sie Funktion-APIs, die sich selbst erklären
Positionale Argumente werden schnell unübersichtlich, insbesondere Boolesche Werte. Wenn man einen solchen Aufruf liest, kann man ohne Betrachtung der Definition nicht erkennen, was true und false steuern:
createUser(
'Akshat',
'akshat@example.com',
true,
false,
)
Ein Options-Objekt bringt die Bedeutung direkt am Aufrufort unter:
createUser({
name: 'Akshat',
email: 'akshat@example.com',
sendWelcomeEmail: true,
isAdmin: false,
})
Dann deklariert die Funktion einen benannten Typ für ihre Optionen:
type CreateUserOptions = {
name: string
email: string
sendWelcomeEmail: boolean
isAdmin: boolean
}
function createUser(options: CreateUserOptions) {
// ...
}
Der Vorteil nimmt mit der Anzahl der Parameter zu. Zwei Argumente sind in der Regel positionell ausreichend; sieben verursachen fast immer Fehler, besonders wenn mehrere denselben Typ haben und ohne Probleme vertauscht werden können. Ein Options-Objekt macht es außerdem einfach, später optionale Felder hinzuzufügen, ohne bestehende Aufrufer zu beeinträchtigen.
8. Halten Sie Typen neben dem Bereich, den sie beschreiben
Viele Projekte beginnen mit einer gemeinsamen Datei types.ts. Zuerst ist das praktisch, doch dann fügt jeder Entwickler etwas hinzu, und nach einem Jahr enthält die Datei Hunderte unzusammenhängender Definitionen. Die richtige Typdefinition zu finden wird zur globalen Suche, und die Datei entwickelt sich zum Hotspot für Kollisionskonflikte.
Eine bessere Standardlösung besteht darin, Typen neben dem Domain-Code zu platzieren, der sie verwendet:
users/
user.types.ts
user.service.ts
user.repository.ts
payments/
payment.types.ts
payment.service.ts
payment.repository.ts
facilities/
facility.types.ts
facility.service.ts
facility.repository.ts
Die genaue Ordnerstruktur ist weniger wichtig als die dahinterstehende Regel: Ein Typ gehört zum Domainbereich, den er beschreibt. Wenn man weiß, wo die Geschäftslogik für Zahlungen liegt, sollte man auch erraten können, wo sich die Typen für Zahlungen befinden. Wirklich cross-cutting genutzte Typen, wie gemeinsame API-Strukturen, können weiterhin in einem kleinen gemeinsamen Modul untergebracht werden.
9. Halten Sie das Typensystem einfacher als die Geschäftslogik
TypeScript bietet mappierte Typen, bedingte Typen, Template-Literal-Typen, rekursive Typen, infer sowie distributive Bedingungen. Mit diesen Werkzeugen kann man auf Typebene fast alles erstellen – genau deshalb ist Mäßigung wichtig. Betrachten Sie dazu einen solchen Hilfsfunktion, der ein Objekt auf Schlüssel reduziert, die mit Id enden:
type Magic<T> =
T extends infer U
? U extends Record<string, unknown>
? {
[K in keyof U as K extends `${string}Id`
? K
: never]: U[K]
}
: never
: never
Die Programmierung auf Typebene hat legitime Anwendungsbereiche, insbesondere in Bibliotheken. Doch es gibt einen Punkt, an dem ein Typ mehr Komplexität hinzufügt, als er beseitigt. Wenn ein Teammitglied einen aufwendigen Typ entschlüsseln muss, bevor es die dahinterstehende Geschäftsregel verstehen kann, fragen Sie, ob eine einfachere Version ausreichen würde. Manchmal ist die Antwort nein und die Komplexität gerechtfertigt; oft jedoch nicht. Cleverness ist keine Qualität. Langweilige, gut lesbare Typen sind in der Regel besser als beeindruckende, und wenn ein fortgeschrittener Typ tatsächlich benötigt wird, machen kurze Kommentare sowie einige Typuntersuchungen die Wartung erheblich einfacher.
10. Konfigurieren Sie tsconfig absichtlich
Eine der einfachsten Möglichkeiten, TypeScript zu schwächen, besteht darin, eine Konfiguration zu verwenden, die genau die Probleme ignoriert, die man eigentlich von ihm erwarten würde. Zumindest sollten Sie wissen, was diese Optionen bewirken:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true
}
}
strict aktiviert eine Reihe von Überprüfungen, darunter strictNullChecks und noImplicitAny. Die anderen beiden sind separate Optionen, die durch strict nicht automatisch aktiviert werden: noUncheckedIndexedAccess fügt bei indizierten Lesevorgängen in Arrays und Records den Wert undefined hinzu, während exactOptionalPropertyTypes zwischen einer fehlenden Eigenschaft und einer explizit auf undefined gesetzten Eigenschaft unterscheidet. Beide können in bestehenden Projekten viele Fehler aufzeigen.
Die richtige Kombination hängt vom Codebasis ab. Ein Legacy-Projekt kann möglicherweise nicht sofort alle Optionen aktivieren, und es ist völlig vernünftig, die Flags schrittweise einzuschalten. Entscheidend ist, dass das Team weiß, was der Compiler überprüft und was nicht – beginnend mit dieser Einstellung:
"strict": true
Der strenge Modus dient nicht dazu, TypeScript umständlich zu machen. Er sorgt dafür, dass der Compiler Unsicherheiten offenlegt – genau das ist der Zweck von TypeScript: Probleme bereits vor den Nutzern aufzudecken.
Warum diese Gewohnheiten zusammen wichtig sind
Niemand dieser Ansätze ist als Trick nützlich. Ihr Wert liegt darin, dass sie die Codebasis verständlicher machen. Stellen Sie sich einen neuen Teamkollegen vor, der auf folgenden Typ stößt:
type Payment =
| {
status: 'SUCCESS'
transactionId: string
}
| {
status: 'FAILED'
error: string
}
Ohne die Implementierung lesen zu müssen, lernt er eine Geschäftsregel: Eine erfolgreiche Zahlung weist eine Transaktions-ID auf, während bei einer fehlgeschlagenen Zahlung ein Fehler angezeigt wird. Der Typ gibt an, wie sich dieser Teil des Systems verhält – und nicht nur, dass eine Eigenschaft ein String ist. Das ist das Ziel, das man anstreben sollte.
Checkliste für Code-Reviews
Vor dem Commit von TypeScript sollten Sie sich diese Fragen durchdenken:
- Könnte dieses
anyunknownoder ein spezifischer Typ sein? - Wiederholt diese Annotation etwas, was der Compiler bereits erkannt hat?
- Bezeichnen die Typen nur gültige Zustände des Domänenraums?
- Ist diese Eigenschaft optional, weil sie tatsächlich optional ist, oder aus Bequemlichkeit?
- Könnte eine Union diesen Zustand genauer beschreiben?
- Gehört das
ashier dazu, weil der Wert nachweislich sicher ist, oder nur, um einen Fehler zu beseitigen? - Drückt dieser Generiktyp eine tatsächliche Beziehung zwischen Typen aus?
- Ist die neue Abstraktion verständlicher als der ursprüngliche Code?
- Kann ein Leser erkennen, was jeder Argument bei der Aufrufstelle bedeutet?
- Könnte
readonlydas Eigentumsverhältnis oder die Unveränderlichkeit klarer machen? - Wird der Compiler bemerken, wenn sich dieser Domänenzustand ändert?
- Kann ein Teamkollege diesen Typ verstehen, ohne ihn entschlüsseln zu müssen?
Die letzte Frage ist in der Regel am wichtigsten.
Zusammenfassung: Typen als Gestaltungstool
Durch Erfahrung wird die Syntax zum weniger interessanten Teil von TypeScript. Entscheidend ist, was man ausdrücken möchte. Man kann ein Objekt beschreiben, das zufällig einige Zeichenketten enthält, oder eine Operation, die stets in einem von vier Zuständen ist, wobei jeder Zustand eine bestimmte Menge an Eigenschaften garantiert. Die zweite Variante ist weitaus nützlicher.
Gutes TypeScript wird daher nicht danach beurteilt, wie viele fortgeschrittene Funktionen ein Entwickler aufzählen kann, sondern danach, wie gut das Typensystem einem Team dabei hilft, Software zu verstehen, zu ändern und zu warten. Wenn der Compiler die bereits von der Anwendung genutzten Regeln durchsetzt, sind Typen nicht mehr nur ein Sicherheitsnetz, sondern werden zu einem Bestandteil der Architektur.
- Verwenden Sie Generics für Beziehungen und einfache Signaturen, wenn keine Beziehung erfasst werden muss.
readonly, präzise Formen sowie Options-Objekte dokumentieren.tsconfig vornimmt, und verschärfen Sie ihn gezielt.