Zehn TypeScript-Muster, die Laufzeitfehler in Kompilierfehler verwandeln
Erlernen Sie zehn TypeScript-Techniken – von diskriminierten Unionen und satisfies über branded Types bis hin zu infer –, die es dem Compiler ermöglichen, ungültige Zustände bereits vor der Veröffentlichung Ihres Codes abzulehnen.
Quellen: Top 10 TypeScript Techniques Senior Developers Secretly Use
Viele Teams adoptieren TypeScript, indem sie Anmerkungen zu bestehendem JavaScript hinzufügen und dabei bleiben. Dadurch werden Tippfehler erkannt, doch der größte Teil des Typsystems bleibt ungenutzt. Der größere Vorteil besteht darin, die Regeln als Typen zu kodieren, sodass ganze Klassen von Fehlern nicht kompiliert werden können und keine Laufzeitprüfung erforderlich ist. Hier sind zehn Techniken, die das ermöglichen, zusammen mit Hinweisen darauf, wo sich jede davon auszahlt.
Modellzustände explizit mit diskriminierten Unionen definieren
Eine gängige Praxis ist es, einen Wert als Sammlung von optionalen Feldern zu beschreiben und darauf zu vertrauen, dass die Nutzer die richtige Kombination überprüfen. Nichts davon verhindert, dass sowohl data als auch error gesetzt werden.
// nothing stops you from having both data and error at once
type ApiResponse = {
data?: User
error?: string
loading?: boolean
}
// consumer has no idea what to check
function handleResponse(res: ApiResponse) {
if (res.data) { /* maybe fine */ }
// what if data AND error are both set?
}
Eine diskriminierte Union listet stattdessen die zulässigen Formen auf. Jede Variante teilt sich ein Literalfeld (status), und TypeScript beschränkt die Auswahl darauf: Im success-Fall ist res.data garantiert, im error-Fall res.message.
type ApiResponse =
| { status: 'loading' }
| { status: 'success'; data: User }
| { status: 'error'; message: string }
function handleResponse(res: ApiResponse) {
switch (res.status) {
case 'loading':
return showSpinner()
case 'success':
// TypeScript KNOWS res.data exists here
return renderUser(res.data)
case 'error':
// TypeScript KNOWS res.message exists here
return showError(res.message)
}
}
Falls später ein vierter Zustand hinzugefügt wird, kann jeder switch, der diesen ignoriert, zu einem Kompilierfehler führen – insbesondere in Verbindung mit der Überprüfung auf Vollständigkeit, die weiter unten gezeigt wird.
Objekte mit satisfies validieren, ohne sie zu erweitern
Seit TypeScript 4.9 verfügbar, behebt satisfies ein subtiles Problem mit Annotationen. const config: Config = ... überprüft das Objekt, doch der Typ der Variablen wird genau auf Config gesetzt, wodurch config1.theme die vollständige Union anstelle von 'dark' ist. Mit satisfies wird das Objekt weiterhin überprüft (Tippfehler in Schlüsseln führen zu Fehlern), doch der präzise abgeleitete Typ bleibt erhalten: config2.theme bleibt weiterhin 'dark' und secondary bleibt ein bekannter Schlüssel.
type Config = {
theme: 'light' | 'dark' | 'system'
colors: Record<string, string>
}
// with a normal annotation you lose the literal type
const config1: Config = {
theme: 'dark',
colors: { primary: '#3d6fe8' }
}
// config1.theme is now 'light' | 'dark' | 'system', not 'dark'
// with satisfies, the shape is validated but types stay narrow
const config2 = {
theme: 'dark',
colors: { primary: '#3d6fe8', secondary: '#7c3aed' }
} satisfies Config// config2.theme is still 'dark' — the literal type is preserved
// config2.colors.primary is still 'string' — shape is validated
// if you typo a field name, TypeScript still catches it
Verwenden Sie Annotationen, wenn Sie den weiter gefassten Typ wünschen – beispielsweise für einen Wert, den Sie neu zuweisen werden; nutzen Sie satisfies, wenn Sie eine Überprüfung zusammen mit einer präzisen Typableitung möchten.
String-Unionen mit Template-Literal-Typen erstellen
TypeScript kann String-Typen mit Backtick-Syntax erstellen. Eine Union in einem Platzhalter erweitert sich auf alle möglichen Kombinationen, was sich für Namen von Ereignishandlern, CSS-Custom-Properties sowie METHOD /path-Endpunkte eignet.
type EventName<T extends string> = `on${Capitalize<T>}`
// produces: 'onClick' | 'onChange' | 'onSubmit'
type DomEvents = EventName<'click' | 'change' | 'submit'>
// type safe CSS properties
type CSSVar<T extends string> = `--${T}`
type AppVars = CSSVar<'primary' | 'secondary' | 'bg'>
// produces: '--primary' | '--secondary' | '--bg'
// type safe API routes
type HttpMethod = 'GET' | 'POST' | 'DELETE'
type Route = '/users' | '/orders' | '/products'
type ApiEndpoint = `${HttpMethod} ${Route}`
// produces: 'GET /users' | 'GET /orders' | 'POST /users' | ...
function callApi(endpoint: ApiEndpoint) { /* ... */ }
callApi('GET /users') // valid
callApi('PATCH /users') // TypeScript error: not in the type
callApi('PATCH /users') wird abgelehnt, weil PATCH nicht in HttpMethod enthalten ist. Kombinationen vermehren sich schnell, daher sollten Eingabefelder mit Unionen klein gehalten werden, um die Verarbeitungsgeschwindigkeit des Compilers nicht zu verringern.
Aussehend ähnliche Werte mit markierten Typen trennen
TypeScript vergleicht Typen nach ihrer Struktur, wodurch type UserId = string im Grunde genommen einfach string ist – ein Benutzer-ID-Wert kann somit problemlos in eine Funktion übergeben werden, die eigentlich eine Bestellnummer erwartet. Durch das Hinzufügen einer „markierten“ Eigenschaft, die zur Laufzeit nie existiert, werden diese beiden Typen inkompatibel. Konstruktorfunktionen werden somit zur einzigen Möglichkeit, solche Werte zu erstellen, und bilden zugleich einen geeigneten Ort für die Validierung.
// without branding — TypeScript cannot tell these apart
type UserId = string
type OrderId = string
function deleteOrder(id: OrderId) { /* ... */ }
const userId: UserId = 'user-42'
deleteOrder(userId) // no error - catastrophic bug
// with branded types - structurally different, enforced
type Brand<T, B> = T & { readonly _brand: B }
type UserId = Brand<string, 'UserId'>
type OrderId = Brand<string, 'OrderId'>
// constructor functions act as the only way to create these types
const UserId = (id: string) => id as UserId
const OrderId = (id: string) => id as OrderId
const userId = UserId('user-42')
const orderId = OrderId('order-99')
deleteOrder(userId) // TypeScript error: UserId is not assignable to OrderId
deleteOrder(orderId) // works fine
Gute Kandidaten sind Identifikatoren (UserId, OrderId, ProductId), überprüfte Werte wie EmailAddress, HashedPassword oder ValidatedInput sowie leicht verwechselbare Einheiten wie Millisekunden gegenüber Sekunden oder Dollar gegenüber Cent. Der Codeausschnitt deklariert jeden Alias zweimal zur Vergleichszweck; im echten Code sollte nur die finale Version verwendet werden.
Typen aus anderen Typen mit infer extrahieren
In einem bedingten Typ deklariert infer einen Slot, den TypeScript durch Musterabgleich füllt. So werden ReturnType, Parameters und Awaited erstellt, und es ermöglicht Ihnen außerdem, eigene Extraktoren zu schreiben.
// extract the return type of any function
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never
function getUser() { return { id: 1, name: 'Alice' } }
type User = ReturnType<typeof getUser>
// User = { id: number, name: string }
// extract the resolved value of a Promise
type UnwrapPromise<T> = T extends Promise<infer V> ? V : T
type Data = UnwrapPromise<Promise<User>>
// Data = User
// extract the element type of an array
type ArrayElement<T> = T extends (infer E)[] ? E : never
type Names = ArrayElement<string[]>
// Names = string
// extract first argument of any function
type FirstArg<T> = T extends (first: infer A, ...rest: any[]) => any ? A : never
type Arg = FirstArg<typeof getUser>
// Arg = never (no args), try a fn with args:
type A2 = FirstArg<(id: string) => void>
// A2 = string
Lesen Sie jede Definition als „Wenn T zu dieser Form passt, was kommt in den infer-Slot?“. Wenn das Muster fehlschlägt, gewinnt die falsche Abzweigung – deshalb ist FirstArg einer nullargumentigen Funktion never. Geben Sie Ihren eigenen Hilfsfunktionen unterschiedliche Namen, anstatt den globalen ReturnType zu überschatten.
Sorgen Sie dafür, dass unvollständige switch-Anweisungen mit never fehlschlagen
Kombinieren Sie Unionen mit einer Hilfsfunktion, die nur never akzeptiert. Sobald jede Variante einen case hat, ist der Typ in default never und die Aufrufung kompiliert. Fügen Sie hexagon hinzu, ohne es zu verarbeiten, dann führt der verbleibende Typ sofort zum Kompilierfehler.
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'rectangle'; width: number; height: number }
| { kind: 'triangle'; base: number; height: number }
// this helper makes the default case scream at compile time
function assertNever(value: never): never {
throw new Error(`Unhandled case: ${JSON.stringify(value)}`)
}
function getArea(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2
case 'rectangle':
return shape.width * shape.height
case 'triangle':
return 0.5 * shape.base * shape.height
default:
return assertNever(shape)
// if you add 'hexagon' to Shape and forget to handle it,
// TypeScript throws an error RIGHT HERE at compile time
}
}
Weil die Hilfsfunktion ebenfalls Ausnahmen wirft, schützt sie vor unerwarteten Werten, die von außerhalb des Typsystems kommen, wie beispielsweise API-Antworten.
Formtypen mit Karten-Typen und Schlüsselummapping umgestalten
Karten-Typen durchlaufen die Schlüssel eines Typs, um einen neuen zu erzeugen, und ein as-Klausel ändert die Schlüssel oder entfernt sie, indem sie auf never umgemappt werden. Die Beispiele erstellen rekursive teilweise Getter-Signaturen sowie ein Filter, der nur Methoden behält.
// make all properties optional but keep their types
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K]
}
// prefix all keys with 'get'
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
}
type User = { id: string; name: string; email: string }
type UserGetters = Getters<User>
// { getId: () => string, getName: () => string, getEmail: () => string }
// pick only function properties from a type
type FunctionKeys<T> = {
[K in keyof T as T[K] extends Function ? K : never]: T[K]
}
type Service = { id: string; save(): void; delete(): void; name: string }
type ServiceMethods = FunctionKeys<Service>
// { save: () => void, delete: () => void }
string & K ist notwendig, weil Schlüssel auch Zahlen oder Symbole sein können, die von Capitalize abgelehnt werden. Dieser einfache DeepPartial geht außerdem rekursiv auf Funktionen und Arrays ein, was für reine Daten in Ordnung ist, aber möglicherweise weiter verfeinert werden muss.
Den Compiler mit Typ-Wächtern eigene Überprüfungen beibringen
TypeScript beschränkt die Typisierung auf typeof, instanceof sowie Literallvergleiche, nicht jedoch innerhalb eigener Benutzerprüfungen. Ein Typprädikat (animal ist Cat) gibt an, was eine boolesche Funktion beweist; eine Assertionsfunktion (asserts val ist NonNullable) wirft bei Fehlschlag einen Fehler aus und beschränkt die Typisierung aller nachfolgenden Elemente.
type Cat = { kind: 'cat'; meows: boolean }
type Dog = { kind: 'dog'; barks: boolean }
type Animal = Cat | Dog
// type predicate - return type tells TypeScript what the guard proves
function isCat(animal: Animal): animal is Cat {
return animal.kind === 'cat'
}
function speak(animal: Animal) {
if (isCat(animal)) {
console.log(animal.meows) // TypeScript knows animal is Cat here
} else {
console.log(animal.barks) // TypeScript knows animal is Dog here
}
}
// assertion function - throws if condition not met, narrows after the call
function assertDefined<T>(val: T): asserts val is NonNullable<T> {
if (val === null || val === undefined) {
throw new Error('Value is null or undefined')
}
}
const user = getUser() // returns User | null
assertDefined(user) // throws at runtime if null
console.log(user.name) // TypeScript knows user is User here, not User | null
Der Compiler vertraut diesen Signaturen, ohne sie zu überprüfen, wodurch eine fehlerhafte Schutzbedingung alle Aufrufer in die Irre führt. Halten Sie sie klein und getestet.
Halten Sie Literallwerte mit as const präzise
TypeScript erweitert Literale standardmäßig: 'production' wird zu string. as const festschreibt einen Ausdruck auf seine engste mögliche Typisierung und macht ihn readonly. Der eigentliche Vorteil liegt darin, mithilfe von typeof und indiziertem Zugriff Union-Typen aus Daten abzuleiten, sodass das Array oder Objekt zur einzigen Quelle der Wahrheit wird.
// without as const — types are widened
const config = {
env: 'production', // inferred as string, not 'production'
port: 3000, // inferred as number, not 3000
features: ['auth', 'search'] // inferred as string[]
}
// with as const - every value is exactly as written
const config = {
env: 'production',
port: 3000,
features: ['auth', 'search']
} as const
// config.env is 'production', not string
// config.port is 3000, not number
// config.features is readonly ['auth', 'search']
// the real power: derive union types from the values
const ROLES = ['admin', 'editor', 'viewer'] as const
type Role = typeof ROLES[number]
// Role = 'admin' | 'editor' | 'viewer'
// add a new role to the array and the type updates automatically
const ROUTES = {
home: '/',
users: '/users',
profile: '/profile'
} as const
type AppRoute = typeof ROUTES[keyof typeof ROUTES]
// AppRoute = '/' | '/users' | '/profile'
Der Codeausschnitt deklariert config zweimal zum Vergleich. Mit diesem Muster führt das Hinzufügen einer Rolle oder eines Routenpfades automatisch alle abhängigen Typen ohne Duplikationen zur Aktualisierung.
Ausgabe- und Eingabestrukturen mit bedingten Typen variieren
Konditionale Typen ermöglichen es einer generischen Funktion, je nach ihren Argumenten unterschiedliche Strukturen zurückzugeben – so klassifiziert Prisma die Ergebnisse von include und select. Hier ergibt includePosts: true einen Typ mit posts, und im zweiten Beispiel wird publishedAt nur im publish-Modus erforderlich.
// function returns different shapes based on a flag
type UserResponse<T extends boolean> =
T extends true
? { user: User; posts: Post[] } // with posts
: { user: User } // without posts
function fetchUser<T extends boolean>(
id: string,
includePosts: T
): Promise<UserResponse<T>> {
/* implementation */
}
// TypeScript knows exactly what comes back
const result1 = await fetchUser('123', true)
result1.posts // valid - TypeScript knows posts exists
const result2 = await fetchUser('123', false)
result2.posts // TypeScript error - posts does not exist on this return type
// another real world example: optional fields based on mode
type CreateMode = 'draft' | 'publish'
type ArticleInput<M extends CreateMode> =
M extends 'publish'
? { title: string; content: string; publishedAt: Date }
: { title: string; content?: string }
function createArticle<M extends CreateMode>(
mode: M,
input: ArticleInput<M>
) { /* ... */ }
// publishedAt is REQUIRED when mode is 'publish'
createArticle('publish', { title: 'Hey', content: '...', publishedAt: new Date() })
// publishedAt is NOT required when mode is 'draft'
createArticle('draft', { title: 'Hey' })
In der Implementierung kann TypeScript in der Regel nicht nachweisen, dass der zurückgegebene Wert mit einem noch nicht gelösten konditionalen Typ übereinstimmt, daher ist ein Cast zu erwarten. Bei zwei oder drei Varianten sind Überladungen oft klarer.
Wie die Bestandteile zusammenpassen
Dies sind keine isolierten Tricks. Union-Typen definieren Zustände, never sorgt dafür, dass ihre Handhabung vollständig abgedeckt wird, as const sowie Template-Literals erzeugen die Union-Typen, und mappierte sowie bedingte Typen leiten den Rest ab. Typen werden zu einer Logikschicht statt zur Dokumentation. Die untenstehende Abkürzung dient als Überprüfungsliste; sie geht davon aus, dass Typen wie Config, User und Animal vorhanden sind.
// 01. Discriminated unions — model every valid state explicitly
type State = | { status: 'idle' } | { status: 'ok'; data: T } | { status: 'err'; msg: string }
// 02. satisfies - validate shape without widening literal types
const x = { mode: 'dark' } satisfies Config
// 03. Template literal types - build string unions programmatically
type Event<T extends string> = `on${Capitalize<T>}`
// 04. Branded types - prevent mixing structurally identical primitives
type UserId = string & { readonly _brand: 'UserId' }
// 05. infer - extract types from inside other types
type Unwrap<T> = T extends Promise<infer V> ? V : T
// 06. assertNever - exhaustiveness check at compile time
function assertNever(x: never): never { throw new Error('Unhandled case') }
// 07. Mapped types - transform type shapes programmatically
type Getters<T> = { [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K] }
// 08. Type guards - teach TypeScript how to narrow your types
function isCat(a: Animal): a is Cat { return a.kind === 'cat' }
// 09. as const - lock values to literals, derive union types from data
const ROLES = ['admin', 'editor'] as const
type Role = typeof ROLES[number]
// 10. Conditional types - return different shapes based on input
type Res<T extends boolean> = T extends true ? { data: User; posts: Post[] } : { data: User }
Wichtige Erkenntnisse
- Bevorzugen Sie präzise Typstrukturen vor optionalen Feldern; Union-Typen in Kombination mit
assertNeververwandeln fehlende Fälle in Build-Fehler. - Verwenden Sie
satisfiesundas const, um Werte zu validieren, während die Literal-Typen beibehalten werden. - Geben Sie primitiven Typen eine aussagekräftige Bezeichnung, damit IDs und Einheiten nicht vertauscht werden können.
- Berechnen Sie Typen mithilfe von
infer, mappierten sowie bedingten Typen, aber halten Sie sie lesbar.
Für eine umfassendere Kodierung von Geschäftsregeln siehe das Modellieren von Domänen in TypeScript.