TypeScript-Typsicherheitsmuster: Generics, DI und wann sich ein Muster lohnt
Designmuster und TypeScript auf Typebene für Typsicherheit: Generics, Abhängigkeitsinjektion, markierte IDs sowie wie man erkennt, wann ein Muster Komplexität reduziert.
Die meisten Teams verwenden TypeScript für Autocomplete-Funktionen und die Erkennung von Tippfehlern, bevor sie seinen wahren Wert entdecken: Es ermöglicht es, Designentscheidungen zu kodieren, sodass der Compiler sie durchsetzt. Diese Anleitung führt durch die klassischen objektorientierten Muster sowie die am wichtigstenen bei großen Frontend- und Full-Stack-Codebasen stehenden techniken auf Typenebene und zeigt, wie man entscheidet, wann sich ein Muster lohnt.
Betrachten Sie das Typensystem als etwas, das architektonische Fragen beantworten kann:
- Ist diese Kombination von Zustandswerten tatsächlich möglich?
- Könnte diese API eine Struktur zurückgeben, die der Rest des Codes nicht erwartet?
- Kann ein
ProductIdin eine Funktion gelangen, die eigentlich einenUserIderwartet? - Kann einer Komponente Eigenschaften gegeben werden, die miteinander im Widerspruch stehen?
- Falls ein neuer Zustand hinzugefügt wird, muss dann jeder Nutzer damit umgehen?
any zurückzugreifen?Ein Muster ist keine Funktion, die man einfach hinzufügt. Es ist ein Name für eine Lösung, die man erkennt, sobald ein wiederkehrendes Problem auftaucht.
Vier Familien von Mustern sowie das Typensystem
Die klassische Einteilung umfasst kreative, strukturelle und verhaltensbasierte Muster; TypeScript fügt eine vierte Familie von typenbezogenen Techniken hinzu, die auf Generiken, Unionen und Sicherheitstools beruhen.
TYPESCRIPT PATTERNS
│
┌───────────────────────┼────────────────────────┐
│ │ │
▼ ▼ ▼
CREATIONAL STRUCTURAL BEHAVIORAL
│ │ │
├─ Factory ├─ Adapter ├─ Strategy
├─ Builder ├─ Facade ├─ Observer
├─ Singleton ├─ Decorator ├─ Command
└─ Abstract Factory ├─ Repository └─ State
└─ Composition
+
TYPESCRIPT TYPE PATTERNS
│
┌───────────────────────┼────────────────────────┐
│ │ │
▼ ▼ ▼
Generics Unions Type Safety
│ │ │
├─ Constraints ├─ Discriminated ├─ Type Guards
├─ keyof Unions ├─ Branded Types
├─ typeof ├─ Result Types ├─ Exhaustiveness
├─ infer └─ State Modeling └─ satisfies
└─ Mapped Types
Reale Anwendungen verwenden selten nur ein Muster allein. Ein typischer Datenfluss in React kombiniert mehrere Muster, und jede Grenze kann präzise Typen enthalten:
React Component
│
▼
Custom Hook
│
▼
Service
│
▼
Repository
│
▼
API Client
│
▼
Result<T, E>
│
▼
Discriminated Union
Wenn Typen durch diese gesamte Kette fließen, wird TypeScript zu einer Beschreibung davon, wie sich das System verhalten darf.
Beginnen Sie mit dem Problem, nicht mit dem Muster
Eine häufige Falle ist das Denken in die falsche Richtung:
"I know Factory Pattern.
Where can I use Factory?"
Wenn man zuerst ein Muster wählt, entstehen Abstraktionen, die niemand benötigt. Lassen Sie stattdessen das Problem die Entscheidung bestimmen:
What problem do I have?
↓
Where is the complexity?
↓
What is changing frequently?
↓
What should remain stable?
↓
What abstraction reduces that complexity?
↓
Is a known pattern appropriate?
Betrachten Sie einen Zahlungsablauf, bei dem eine Reihe von Überprüfungen der Zahlungsmethode durchgeführt werden:
if (paymentMethod === "card") {
// ...
}
if (paymentMethod === "paypal") {
// ...
}
if (paymentMethod === "upi") {
// ...
}
Die Reflexreaktion ist in der Regel wie folgt:
Don’t immediately think:
„Dafür ist eine Strategie notwendig.“ Bevor man danach greift, sollte man prüfen, ob die verschiedenen Varianten tatsächlich austauschbare Algorithmen hinter einem einzigen Vertrag sind. Sind sie das, eignet sich eine Strategie. Wenn das eigentliche Problem darin besteht, dass einige Felder nur für bestimmte Methoden sinnvoll sind, könnte eine diskriminierte Union, die ungültige Kombinationen undarstellbar macht, das bessere Werkzeug sein. Den Katalog zu kennen ist einfach; eine Eintragszuordnung zu einem spezifischen Szenario ist die eigentliche Fähigkeit.
Kreativmuster
Singleton: eine gemeinsame Instanz
Ein Singleton sorgt dafür, dass eine Klasse genau eine Instanz hat. Der private Konstruktor verhindert Aufrufe außerhalb von new, und eine statische Methode erstellt die Instanz nur dann, wenn sie benötigt wird, und speichert sie anschließend im Cache:
class Logger {
private static instance: Logger;
private constructor() {}
static getInstance(): Logger {
if (!Logger.instance) {
Logger.instance = new Logger();
}
return Logger.instance;
}
log(message: string) {
console.log(message);
}
}
Die Anrufer bitten um das gemeinsam genutzte Objekt anstelle dessen, selbst eines zu erstellen:
const logger = Logger.getInstance();
logger.log("Application started");
Jeder Verbraucher zeigt letztendlich auf dasselbe Objekt:
Logger
│
getInstance()
│
▼
┌───────────┐
│ Logger │
│ Instance │
└───────────┘
▲ ▲
│ │
Service A Service B
Angemessene Kandidaten sind:
- Logging-Systeme
- Analytics-Manager
- Konfigurationsverwalter
- Einige Verbindungsmanager
- Weitere cross-cutting Infrastrukturdienste
Das Problem ist, dass ein Singleton ein getarnter globaler Zugriff ist: Tests werden schwieriger zu isolieren, Abhängigkeiten verschwinden aus den Signaturen, der Lebenszyklus wird unklar, und gemeinsamer, veränderlicher Zustand ändert sich auf un nachvollziehbare Weise. In einem modernen Frontend wird eine aus einem ES-Modul exportierte Instanz bereits geteilt, und Dependency Injection, React Context oder eine State-Bibliothek bieten denselben Schutz mit sichtbarer Verkabelung.
Factory: Verbergen, welche Klasse erstellt wird
Eine Factory entzieht dem Nutzer die Entscheidung, welche konkrete Klasse instanziert werden soll. Vergleichen Sie mit der direkten Erstellung:
const payment = new StripePayment();
mit delegierter Bauausführung:
const payment = PaymentFactory.create("stripe");
Sowohl Anbieter implementieren eine Schnittstelle, und die Factory ordnet eine Union von String-Literalen der richtigen Klasse zu. Da der Parametertyp "stripe" | "paypal" ist, führt ein nicht unterstützter Name zum Kompilierfehler:
interface PaymentProvider {
pay(amount: number): Promise<void>;
}
class StripePayment implements PaymentProvider {
async pay(amount: number) {
console.log("Stripe:", amount);
}
}
class PayPalPayment implements PaymentProvider {
async pay(amount: number) {
console.log("PayPal:", amount);
}
}
class PaymentFactory {
static create(
provider: "stripe" | "paypal"
): PaymentProvider {
switch (provider) {
case "stripe":
return new StripePayment();
case "paypal":
return new PayPalPayment();
}
}
}
Visuell wird die Fabrik durch einen Pfad gekennzeichnet, der vom Anbieternamen abhängt:
PaymentFactory
│
┌────────────┴────────────┐
│ │
"stripe" "paypal"
│ │
▼ ▼
StripePayment PayPalPayment
Eine Fabrik ist sinnvoll, wenn:
- der Aufbau auf echter Logik beruht
- mehrere Implementierungen denselben Vertrag teilen
- die Nutzer keine konkreten Klassen kennen sollten
- die Implementierungen unabhängig von den Aufrufern weiterentwickelt werden müssen
Bei trivialen Aufbauten wie der untenstehenden Zeile wird dadurch nur eine zusätzliche Ebene zum Durchlesen hinzugefügt:
new User();
Abstrakte Fabrik: Familien verwandter Objekte
Die Abstrakte Fabrik erweitert das Konzept auf Gruppen von Objekten, die miteinander übereinstimmen müssen. In einem UI-Kit für mehrere Plattformen sollte ein Web-Button niemals mit einem mobilen Modal-Fenster kombiniert werden, weshalb jede Fabrik eine konsistente Familie von Objekten erzeugt:
interface Button {
render(): void;
}
interface Modal {
open(): void;
}
interface UIFactory {
createButton(): Button;
createModal(): Modal;
}
UIFactory
│
┌────────┴────────┐
▼ ▼
WebUIFactory MobileUIFactory
│ │
┌────┴────┐ ┌────┴────┐
▼ ▼ ▼ ▼
Button Modal Button Modal
Es ist leistungsstark, doch es ist leicht, zu überdimensioniert zu bauen. In den meisten Frontend-Anwendungen reicht die einfache Kombination von Komponenten aus, um das Ziel mit weniger Aufwand zu erreichen.
Builder: kontrollierte, schrittweise Konstruktion
Der Builder ist nützlich, wenn ein Objekt viele optionale Einstellungen hat oder in mehreren Schritten konfiguriert werden muss. Jeder Setter aktualisiert die private Konfiguration und gibt this zurück, wodurch eine Kettensuche möglich wird:
class RequestBuilder {
private config: RequestInit = {};
setMethod(method: string) {
this.config.method = method;
return this;
}
setHeaders(headers: HeadersInit) {
this.config.headers = headers;
return this;
}
setBody(body: BodyInit) {
this.config.body = body;
return this;
}
build() {
return this.config;
}
}
Das Zusammenstellen einer Anfrage wird als explizite Schritte dargestellt:
const request = new RequestBuilder()
.setMethod("POST")
.setHeaders({
"Content-Type": "application/json"
})
.setBody(JSON.stringify(data))
.build();
Fließende Syntax ist ein Nebeneffekt, nicht das Ziel. Es geht darum, komplexe Konstruktionen explizit und an einem Ort zu halten, wo build() sie auch überprüfen kann – beispielsweise indem ein Körper in einer GET-Anfrage abgelehnt wird.
Strukturelle Muster
Adapter: ein stabiler Vertrag um eine fremde API
Der Adapter gehört zu den am häufigsten verwendeten Mustern in Anwendungscode. Angenommen, Ihr Code erwartet diesen internen Vertrag:
interface PaymentGateway {
pay(amount: number): Promise<void>;
}
während ein SDK von Drittanbietern eine methodenbezogene Bezeichnung verwendet, die anders lautet:
class LegacyPaymentSDK {
makePayment(value: number) {
// third-party implementation
}
}
Ein dünner Adapter implementiert Ihre Schnittstelle und übersetzt die Aufrufe:
class PaymentAdapter implements PaymentGateway {
constructor(
private readonly sdk: LegacyPaymentSDK
) {}
async pay(amount: number) {
this.sdk.makePayment(amount);
}
}
Application
│
▼
PaymentGateway
▲
│
PaymentAdapter
│
▼
Third-party SDK
Die Anwendung hängt nun von einer von Ihnen besessenen Schnittstelle ab:
PaymentGateway
anstatt bei jedem Anbieter dahinter:
Stripe
PayPal
LegacySDK
SomeFutureProvider
Die Unterstützung eines neuen Anbieters bedeutet das Schreiben eines weiteren Adapters.
Fassade: Ein Aufruf für einen mehrschrittigen Workflow
Eine Fassade stellt einen einfachen Eingangspunkt vor einem komplizierten Untersystem bereit. Das Anmelden kann mehrere Dienste betreffen:
Authentication
+
User Service
+
Permissions
+
Notification
+
Analytics
Ohne Fassade orchestriert jedes Komponente, das einen Benutzer einloggt, die Abfolge selbst:
auth.login();
user.load();
permission.load();
analytics.track();
Eine Fassade umhüllt diese Orchestrierung einmal:
class AppFacade {
async login(username: string, password: string) {
const token = await auth.login(username, password);
const user = await userService.getUser(token);
await permissionService.load(user);
analytics.track("login");
return user;
}
}
und der Komponententext kürzt sich auf einen Aufruf:
await appFacade.login(username, password);
Component
│
▼
AppFacade
│
┌─────────────┼─────────────┐
▼ ▼ ▼
Auth User Permission
Service Service Service
Das ist nützlich, wenn die Reihenfolge der Schritte wichtig ist.
Decorator: Verhalten hinzufügen durch Umhüllen
Ein Decorator fügt Verhalten hinzu, ohne die ursprüngliche Implementierung zu ändern, da Wrapper und umhülltes Objekt dieselbe Schnittstelle teilen. Der Vertrag:
interface Logger {
log(message: string): void;
}
Eine einfache Implementierung:
class ConsoleLogger implements Logger {
log(message: string) {
console.log(message);
}
}
Ein Dekorator, der jeden Logger enthält und Nachrichten mit einem Zeitstempel voransetzt:
class TimestampLogger implements Logger {
constructor(
private readonly logger: Logger
) {}
log(message: string) {
this.logger.log(
`[${new Date().toISOString()}] ${message}`
);
}
}
Verpacken ist einfach nur Konstruktion:
const logger = new TimestampLogger(
new ConsoleLogger()
);
Da jeder Dekorator selbst ein Logger ist, werden sie gestapelt:
Logger
│
▼
ConsoleLogger
│
▼
TimestampLogger
│
▼
AdditionalDecorator
Dieselbe Idee kommt unter anderen Namen vor:
- Middleware-Ketten
- Wrapper-Funktionen
- React-Höherer-Ordnungs-Komponenten
- Protokollierung
- Caching-Schichten
- Authentifizierungsprüfungen
- Instrumentierung und Tracing
Verhaltensmuster
Strategie: austauschbare Algorithmen
Durch die Strategiemethode werden verzweigte Geschäftsregeln beseitigt. Nehmen wir einen Kundentyp als Beispiel:
type CustomerType =
| "regular"
| "premium"
| "enterprise";
Eine bedingte Implementierung platziert jede Regel in einer Funktion:
function calculateDiscount(
type: CustomerType,
price: number
) {
if (type === "regular") {
return price;
}
if (type === "premium") {
return price * 0.9;
}
return price * 0.8;
}
Durch Strategy wird jede Regel zu einer Klasse hinter einer gemeinsamen Schnittstelle:
interface DiscountStrategy {
calculate(price: number): number;
}
class RegularDiscount implements DiscountStrategy {
calculate(price: number) {
return price;
}
}
class PremiumDiscount implements DiscountStrategy {
calculate(price: number) {
return price * 0.9;
}
}
class EnterpriseDiscount implements DiscountStrategy {
calculate(price: number) {
return price * 0.8;
}
}
Order
│
▼
DiscountStrategy
│
┌───────────┼───────────┐
▼ ▼ ▼
Regular Premium Enterprise
Strategy Strategy Strategy
Das Hinzufügen eines neuen Levels bedeutet in der Regel das Einfügen einer Strategie statt die bestehende Logik zu ändern – das ist in der Praxis das Offen/Schließen-Prinzip. Für drei sehr einfache Regeln reicht eine bedingte Struktur aus; die Strategie-Approche lohnt sich erst, wenn die Regeln eigene Abhängigkeiten oder Tests entwickeln.
Observer: Eins-zu-vielen-Benachrichtigungen
Observer ermöglicht es einem Subjekt, beliebig viele Zuhörer zu benachrichtigen, wenn sich etwas ändert:
Subject
│
┌──────────┼──────────┐
▼ ▼ ▼
Observer A Observer B Observer C
Ein kleiner, typbasierter Emitter speichert die Zuhörer in einem Set und gibt eine Aufräumfunktion zurück, wenn ein Zuhörer registriert wird:
type Listener<T> = (value: T) => void;
class EventEmitter<T> {
private listeners = new Set<Listener<T>>();
subscribe(listener: Listener<T>) {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
emit(value: T) {
this.listeners.forEach(listener => {
listener(value);
});
}
}
const emitter = new EventEmitter<string>();
const unsubscribe = emitter.subscribe(message => {
console.log(message);
});
emitter.emit("Hello");
unsubscribe();
Diese Aufräumfunktion ist eine Lebenszyklusverwaltung. Wenn man sie vergisst aufzurufen, führt das zu:
- Memory-Leaks durch Zuhörer, die länger existieren als ihre Eigentümer
- doppeltem Verarbeiten, wenn ein Zuhörer zweimal hinzugefügt wird
- veralteten Schließungen, die veraltete Werte lesen
- Nebeneffekten, die nach dem Verschwinden einer Komponente ausgelöst werden
In React ist das genau das, was man aus einem useEffect-Callback zurückgibt.
Befehl: Aktionen als Objekte
Command wandelt eine Aktion in ein Objekt mit einer einheitlichen Schnittstelle um:
interface Command {
execute(): void;
}
Konkrete Befehle setzen es um:
class SaveCommand implements Command {
execute() {
console.log("Saving...");
}
}
class UndoCommand implements Command {
execute() {
console.log("Undo");
}
}
Die Behandlung von Aktionen als Werte ist hilfreich, wenn Sie Folgendes benötigen:
- eine Historie dessen, was passiert ist
- Umbuchen und Wiederholen
- Warteschlangenbasierte Ausführung
- Wiederholungsversuche
- verschobene Ausführung
- Auditspeicherung
Ein echtes Umbuchen erfordert in der Regel, dass jede Anweisung sich selbst rückgängig macht, weshalb Produktionsversionen oft eine undo()-Methode neben der execute()-Methode hinzufügen.
User Action
│
▼
Command
│
├── execute()
│
▼
Receiver
Daten- und Kompositionsmuster
Repository: Trennung des Datenzugriffs
Sobald der Datenzugriff komplex wird, schafft ein Repository einen Vertrag zwischen der Geschäftslogik und dem, was die Daten speichert oder abruft:
UI
│
▼
Hook / Controller
│
▼
Service
│
▼
Repository
│
├── REST
├── GraphQL
├── IndexedDB
└── Cache
Der Vertrag beschreibt, was die App anfordern darf:
interface UserRepository {
getUser(id: string): Promise<User>;
getUsers(): Promise<User[]>;
}
Eine Implementierung kommuniziert mit einer REST API:
class ApiUserRepository implements UserRepository {
async getUser(id: string) {
const response = await fetch(`/users/${id}`);
return response.json();
}
async getUsers() {
const response = await fetch("/users");
return response.json();
}
}
Der Geschäftskodex hängt von der Schnittstelle ab:
UserRepository
nicht im Transport:
fetch()
axios()
graphqlClient()
Das ist wichtig, wenn sich die Quelle ändert: von REST zu GraphQL, die Hinzufügung eines IndexedDB-Caches oder ein in-Memory-Modell für Tests. Beachten Sie, dass response.json() einen untypisierten Wert zurückgibt, weshalb das Repository auch der richtige Ort ist, um Antworten zu validieren.
Komposition statt Vererbung
In der Frontend-Entwicklung ist Komposition wichtiger als jedes auf Vererbung basierende Muster. Anstelle eines Components, das alles beherrscht:
MegaComponent
├── Authentication
├── Table
├── Filters
├── Modal
├── Notifications
├── API calls
└── Business logic
Teilen Sie die Verantwortlichkeiten in fokussierte Aufgaben auf:
Dashboard
├── Header
├── Sidebar
├── FilterPanel
├── DataTable
└── NotificationPanel
In React besteht dies einfach darin, Komponenten zu verschachteln:
<Dashboard>
<Header />
<Sidebar />
<MainContent />
</Dashboard>
Jedes Element kann für sich allein verstanden, getestet und ersetzt werden. Weitere Informationen zur Aufteilung überdimensionierter Komponenten finden Sie unter die Behebung der React-Prop-Überlastung mit Composition und Slots.
Modellierung des Zustands mit dem Typensystem
Von nun an ist TypeScript selbst das architektonische Werkzeug.
Diskriminierte Unionen für den Anfragezustand
Eine Anfrage durchläuft Phasen, die unterschiedliche Daten enthalten. Eine diskriminierte Union verleiht jeder Phase ihre eigene Struktur, die durch ein literales status-Feld miteinander verbunden ist:
type RequestState =
| {
status: "idle";
}
| {
status: "loading";
}
| {
status: "success";
data: User[];
}
| {
status: "error";
error: string;
};
Durch Aktivierung des Diskriminanten wird die Art in jedem Ast eingegrenzt:
function render(state: RequestState) {
switch (state.status) {
case "idle":
return "Nothing started";
case "loading":
return "Loading...";
case "success":
return state.data;
case "error":
return state.error;
}
}
Der Compiler verfolgt, welche Felder nach jeder Überprüfung vorhanden sind:
status = "success"
↓
data exists
status = "error"
↓
error exists
Vergleichen Sie das gängige Boolean-and-Optionals-Modell:
interface State {
loading: boolean;
data?: User[];
error?: string;
}
Nichts hindert daran, einen Zustand zu beschreiben, der niemals eintreten sollte:
{
loading: true,
data: [...],
error: "Something failed"
}
Der Union macht solche Kombinationen sehr schwer auszudrücken. Modellieren Sie die gültigen Zustände, anstatt optionale Eigenschaften überall zu verteilen und darauf zu vertrauen, dass jeder sie richtig kombiniert.
Ergebnistypen bei explizitem Versagen
Viele Operationen haben genau zwei Ergebnisse:
Success
OR
Failure
Ein Result-Typ wird vollständig mit einem booleschen Diskriminanten angegeben:
type Result<T, E> =
| {
success: true;
data: T;
}
| {
success: false;
error: E;
};
Eine Funktion, die es zurückgibt:
function getUser(): Result<User, string> {
return {
success: true,
data: user
};
}
Der Aufrufer muss success überprüfen, bevor er data oder error abruft:
const result = getUser();
if (result.success) {
console.log(result.data);
} else {
console.error(result.error);
}
Service
│
▼
Result<T, E>
/ \
/ \
▼ ▼
Success Failure
│ │
data error
Dies bezieht sich auf erwartete Geschäftsfehler wie „E-Mail bereits registriert“. Ausnahmen gelten weiterhin für echte Fehler und Infrastrukturprobleme; die Ergebnistypen sorgen lediglich dafür, dass vorhergesehene Fehler in der Signatur sichtbar werden.
Generics und andere typbezogene Werkzeuge
Generics bewahren die Verbindung zwischen Eingabe und Ausgabe
Die Verwendung von any führt dazu, dass Informationen verloren gehen:
function identity(value: any): any {
return value;
}
Ein Typparameter bewahrt ihn auf:
function identity<T>(value: T): T {
return value;
}
Das abgeleitete Ergebnis folgt dem Argument:
const a = identity("hello");
// string
const b = identity(100);
// number
Die Beziehung zwischen Eingang und Ausgang bleibt bestehen:
Input T
│
▼
Function<T>
│
▼
Output T
Generics machen gemeinsame Verträge außerdem wiederverwendbar. Ein Antwortumschlag:
interface ApiResponse<T> {
data: T;
status: number;
message: string;
}
beschreibt viele Payloads:
type UserResponse =
ApiResponse<User>;
type ProductResponse =
ApiResponse<Product>;
Einschränkungen: Form erforderlich
Das lässt sich nicht kompilieren:
function getId<T>(item: T) {
return item.id;
}
Denn nichts gibt TypeScript mit, dass T eine id hat. Eine Einschränkung liefert diese Garantie:
function getId<T extends { id: string }>(
item: T
) {
return item.id;
}
Jedes Objekt mit einem String id wird akzeptiert, einschließlich zusätzlicher Felder:
getId({
id: "123",
name: "Hareesh"
});
Lesen Sie die Einschränkung wie folgt:
T can be anything
BUT
T must have id: string
keyof und indizierter Zugriff
Gegeben sei eine Schnittstelle:
interface User {
id: string;
name: string;
age: number;
}
keyof gibt die Vereinigung seiner Eigennamen zurück:
type UserKey = keyof User;
"id" | "name" | "age"
Die Kombination von keyof mit einem zweiten Typparameter sowie dem indizierten Zugriffstyp T[K] ergibt einen Zugriffsfunktor, dessen Rückgabetyp zur Schlüsselwertart passt:
function getProperty<T, K extends keyof T>(
object: T,
key: K
): T[K] {
return object[key];
}
const user = {
id: "1",
name: "Hareesh",
age: 30
};
getProperty(user, "name");
Eine echte Schlüsselkompilation:
getProperty(user, "name");
Ein fehlender Schlüssel ist ein Kompilierfehler:
getProperty(user, "salary");
Die Stärke entsteht durch die gemeinsame Wirkung von drei Werkzeugen:
Generics
+
keyof
+
Indexed Access
Kartierte Typen: Transformation eines vorhandenen Typs
Kartierte Typen durchlaufen die Schlüssel eines Typs, um einen neuen Typ zu erstellen. Ausgehend von:
interface User {
id: string;
name: string;
email: string;
}
Man kann eine vollständig optionale Version erstellen:
type OptionalUser = {
[K in keyof User]?: User[K];
};
konzeptionell gleichbedeutend mit Schreiben:
{
id?: string;
name?: string;
email?: string;
}
So werden eingebettete Konstrukte wie Partial definiert.
Bedingte Typen: Entscheidungen auf Typenebene
Bedingte Typen wählen zwischen zwei Typen auf der Grundlage der Zuweisbarkeit aus:
T extends U ? X : Y
Diese Funktion entpackt die Array-Elementtypen und lässt alles andere unberührt:
type Flatten<T> =
T extends Array<infer U>
? U
: T;
type A = Flatten<string[]>;
// string
type B = Flatten<number>;
// number
Zu diesem Zeitpunkt verhält sich das Typsystem wie eine kleine Sprache zur Kompilierzeit, was Zurückhaltung erfordert.
infer: Teil eines Typs extrahieren
Innerhalb eines bedingten Typs deklariert infer eine Typvariable, die von TypeScript durch Abgleich gefüllt wird. Dadurch wird der eingebaute ReturnType neu implementiert:
type MyReturnType<T> =
T extends (...args: any[]) => infer R
? R
: never;
Kombinieren Sie es mit typeof, um einen Typ aus einer vorhandenen Funktion abzuleiten, sodass er niemals von der Implementierung abweichen kann:
function getUser() {
return {
id: "1",
name: "Hareesh"
};
}
type User = MyReturnType<typeof getUser>;
Die Definitionen der Bibliothekstypen sind stark davon abhängig.
Zuerst die eingebauten Hilfstypen
Bevor man clevere Hilfsfunktionen entwickelt, sollte man wissen, was bereits mit der Sprache geliefert wird:
Partial
Required
Readonly
Pick
Omit
Record
Exclude
Extract
NonNullable
ReturnType
Parameters
InstanceType
Awaited
Das Entfernen eines sensiblen Feldes bedeutet beispielsweise nur eine Zeile anstelle einer duplizierten Schnittstelle, die aus dem Gleichklang gerät:
interface User {
id: string;
name: string;
email: string;
password: string;
}
type PublicUser =
Omit<User, "password">;
Der Leitfaden zu TypeScripts eingebauten Hilfs-Typen behandelt jeden dieser Typen ausführlich.
Record mit einer geschlossenen Menge an Schlüsseln
Record eignet sich für Abfragen und Konfiguration, insbesondere dann, wenn die Schlüssel aus einer literalen Union stammen:
type Permission =
"read" |
"write" |
"delete";
type PermissionMap =
Record<Permission, boolean>;
const permissions: PermissionMap = {
read: true,
write: false,
delete: false
};
Falls eine Berechtigung weggelassen wird, meldet TypeScript den fehlenden Schlüssel. Ein breiter Schlüsseltyp verliert diese Garantie:
const permissions: Record<string, boolean>
Weil Record<string, boolean> praktisch jede Zeichenketten-Schlüssel akzeptiert, bleiben Auslassungen und Tippfehler unentdeckt.
Typsicherheit für Identifikatoren und unzuverlässige Daten
Markierte Typen für Domänenidentifikatoren
Zwei Identifikatoren können beide Zeichenketten sein, aber unterschiedliche Bedeutungen haben:
const userId: string;
const productId: string;
Strukturell kann TypeScript sie nicht voneinander unterscheiden. Das Überschneiden eines string-Typs mit einer Phantom-Eigenschaft erzeugt unterschiedliche Typen:
type UserId =
string & {
readonly __brand: "UserId";
};
type ProductId =
string & {
readonly __brand: "ProductId";
};
Funktionen können anschließend die richtige Art von ID verlangen:
function getUser(id: UserId) {}
function getProduct(id: ProductId) {}
Das Übergeben eines ProductId, wo eigentlich ein UserId erwartet wird, führt zu einem Kompilierfehler. Die Marke existiert zur Laufzeit nie; Sie erstellen markenspezifische Werte mithilfe eines kleinen Konstruktors oder einer Validierungsfunktion, die an einer einzigen Stelle die Typumwandlung vornimmt. Konzeptionell gesehen:
string
│
├── UserId
├── ProductId
├── OrderId
└── TransactionId
Das ist von Vorteil in großen Systemen, in denen Dutzende von Identifikatoren denselben primitiven Typ teilen.
Typschutz für unknown-Eingaben
Daten aus dem Netzwerk, der Speicherung oder vom Benutzer sollten als unknown eintreffen:
const data: unknown = await response.json();
Ein Cast ist der verlockende Abkürzungsweg:
const user = data as User;
Verengen Sie den Wert stattdessen mit einem vom Benutzer definierten Type Guard; seine Rückgabetypangabe value is User teilt dem Compiler mit, was ein true-Ergebnis beweist:
function isUser(
value: unknown
): value is User {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
"name" in value
);
}
if (isUser(data)) {
console.log(data.name);
}
Denken Sie daran, dass Typen zur Laufzeit gelöscht werden. Eine solche Schnittstelle:
interface User {
id: string;
}
Es wird nichts von dem, was die API sendet, überprüft. Die obige Überprüfung prüft lediglich, ob Schlüssel vorhanden sind, nicht jedoch ihre Typen; daher sollte bei unzuverlässigen Eingaben TypeScript mit einem Laufzeit-Schema-Validator verwendet werden.
Ausführliche Überprüfung mit never
Eine der wirksamsten Kombinationen in dieser Sprache:
Discriminated Union
+
never
+
switch
Nehmen Sie eine Status-Union:
type Status =
| "loading"
| "success"
| "error";
und ein Hilfsprogramm, das nur never akzeptiert:
function assertNever(
value: never
): never {
throw new Error(
`Unexpected value: ${value}`
);
}
Wenn jedes Mitglied verarbeitet wurde, erhält die Standardspur den Typ never, sodass der Aufruf kompiliert:
function render(status: Status) {
switch (status) {
case "loading":
return "Loading";
case "success":
return "Success";
case "error":
return "Error";
default:
return assertNever(status);
}
}
Nun erweitert ein Teamkollege die Vereinigung:
Now imagine someone adds:"cancelled" to Status.
Die Standard-Branch erhält den Wert "cancelled", der nicht mit never vergleichbar ist, wodurch der Build fehlschlägt, bis der Fall behandelt wird. Der Compiler fungiert dabei wie ein Design-Prüfer, der sich an jeden Verbraucher erinnert.
Template-Literal-Typen
TypeScript kann String-Literal-Typen aus anderen Typen zusammensetzen:
type Entity =
"user" |
"order" |
"product";
type Event =
`${Entity}:created` |
`${Entity}:updated` |
`${Entity}:deleted`;
Die resultierende Vereinigung enthält jede Kombination:
user:created
user:updated
user:deleted
order:created
order:updated
order:deleted
product:created
product:updated
product:deleted
Praktische Anwendungen sind unter anderem:
- Eventnamen
- Analytics-Event-Schlüssel
- Rechtlichkeitsketten
- Route-Muster
- Feature-Flag-Names
- Design-System-Token
as const bei Werten, die als einzige Quelle der Wahrheit gelten
Ein Array-Literal aus Zeichenketten wird erweitert:
const roles = [
"admin",
"editor",
"viewer"
];
Ihre Typ ist string[]. Durch Hinzufügen von as const entsteht eine nur zum Lesen zugängliche Tupel aus Literalen:
const roles = [
"admin",
"editor",
"viewer"
] as const;
daraus folgt direkt ein Union-Typ:
type Role =
typeof roles[number];
becomes:
"admin" |
"editor" |
"viewer"
Definieren Sie die Werte einmal und führen Sie niemals manuell eine parallele Vereinigung durch.
satisfies für überprüfte Konfiguration
satisfies prüft einen Ausdruck gegenüber einem Typ, ohne ihn auf diesen Typ zu erweitern:
type Config = {
retries: number;
environment:
| "development"
| "production";
};
const config = {
retries: 3,
environment: "production"
} satisfies Config;
Das Objekt wird mit Config verglichen, doch config.environment behält den wörtlichen Typ "production" bei, was eine einfache Annotation nicht erreichen würde. Es eignet sich für:
- Route-Konfigurationen
- Feature-Flags
- Design-Tokens
- Statische Einstellungsobjekte
- Rechtlichkeitskarten
Abhängigkeitsinjektion ohne Framework
Das Erstellen von Abhängigkeiten innerhalb einer Klasse verbindet diese direkt mit ihr:
class UserService {
private api = new ApiClient();
}
Durch die Übernahme über den Konstruktor bleibt die Klasse agnostisch:
class UserService {
constructor(
private readonly api: ApiClient
) {}
}
Die Produktion wird an den echten Kunden übergeben:
const service =
new UserService(apiClient);
und prüft eine Mock-Instanz:
const service =
new UserService(mockApiClient);
UserService
▲
│
Dependency
│
┌────────┴────────┐
▼ ▼
ApiClient MockApiClient
Production Testing
Es handelt sich um einen der günstigsten Wege zu testbarem Code, und Konstruktorparameter sind alles, was dafür benötigt wird.
Ähnliche Muster voneinander unterscheiden
State-Muster oder diskriminierte Union?
Angenommen, es gibt einen Lebenszyklus einer Bestellung:
Draft
Paid
Shipped
Cancelled
Ein diskriminierter Verband könnte genau das sein, was Sie brauchen:
type Order =
| { status: "draft" }
| { status: "paid" }
| { status: "shipped" }
| { status: "cancelled" };
Aber wenn jeder Zustand ein erhebliches Verhalten aufweist:
Draft
├── edit()
├── submit()
Paid
├── refund()
├── ship()
Shipped
├── track()
└── deliver()
Ein Zustandsmuster, bei dem jedes Zustandsobjekt die zulässigen Operationen implementiert, könnte besser passen. Entscheiden Sie danach, wie viel Komplexität jeder Zustand aufweist – nicht nach der Terminologie.
Strategie oder Zustand?
Ein häufiges Interviewthema. Bei der Strategiemethode wählt der Client den Algorithmus aus:
Order
│
▼
Strategy
├── CreditCard
├── PayPal
└── UPI
Durch State ändert das Objekt sein eigenes Verhalten, während es seinen Lebenszyklus durchläuft:
Order
│
├── Draft
├── Paid
└── Shipped
Die Strategie bestimmt, wie eine Aufgabe ausgeführt wird; der Zustand bestimmt, was ein Objekt in seiner aktuellen Phase tut.
Fabrik oder Strategie?
Eine Fabrik beantwortet die Frage "welches Objekt soll erstellt werden?":
PaymentFactory.create("stripe");
Eine Strategie beantwortet die Frage „Welches Verhalten soll ausgeführt werden?“:
new Order(discountStrategy);
Sie ergänzen sich natürlich – eine Fabrik stellt die Strategie her, die die Arbeit erledigt:
Factory
↓
creates
↓
Strategy
↓
executes behavior
Anwendung von Mustern in React
Variant-Props anstelle von booleschen Flaggen
Unabhängige Boolesche Werte ermöglichen Unsinn wie einen Button, der gleichzeitig primär und gefährlich ist:
interface ButtonProps {
primary?: boolean;
danger?: boolean;
loading?: boolean;
}
Eine Vereinigung von Varianten ermöglicht es jedem, seine eigenen Anforderungen zu definieren:
type ButtonProps =
| {
variant: "primary";
loading?: boolean;
}
| {
variant: "danger";
confirmationRequired: boolean;
};
Die gefährliche Variante muss nun confirmationRequired angeben.
Komponenten-APIs, die Widersprüche ablehnen
Eine Komponente, die entweder einen Link oder eine Schaltfläche mit optionalen href und onClick darstellt, würde sowohl beides als auch keines zulassen. Der folgende Auszug zeigt die lockere Version, die differenzierte Alternative sowie eine gültige Verwendung:
{
href?: string;
onClick?: () => void;
}
use:
type ActionProps =
| {
type: "link";
href: string;
}
| {
type: "button";
onClick: () => void;
};
Now:
<Action
type="link"
href="/users"
/>
Eine Link-Variante, bei der anstelle eines href ein Klick-Handler bereitgestellt wird, wird abgelehnt:
<Action
type="link"
onClick={...}
/>
Die unterstützten Modi, klar dargestellt:
Link
Button
TypeScript ist nun Teil der Komponentenarchitektur und nicht mehr nur Dokumentation, die veraltet wird.
Ein typsicherer Event-Bus
Beginnen Sie mit einer Zuordnung von Ereignisnamen zu Payload-Typen:
type Events = {
"user:created": User;
"user:deleted": UserId;
"order:created": Order;
};
Ein Bus-Objekt auf dieser Karte verbindet jeden Namen mit seinem Inhalt mithilfe von keyof und T[K]:
class EventBus<T extends Record<string, unknown>> {
on<K extends keyof T>(
event: K,
handler: (data: T[K]) => void
) {
// implementation
}
emit<K extends keyof T>(
event: K,
data: T[K]
) {
// implementation
}
}
Die korrekte Payload wird kompiliert:
bus.emit("user:created", user);
Die falsche Version tut nicht Folgendes:
bus.emit("user:created", order);
Mehrere Tools treffen sich hier:
Generics
+
keyof
+
Indexed Access
+
Mapped Type thinking
Typen in der gesamten API-Schicht
Ein ausgereifter Frontend-Teil strukturiert den Datenzugriff oft wie folgt:
Component
↓
Hook
↓
Service
↓
Repository
↓
API Client
↓
HTTP
mit Typen, die in jedem Schritt beibehalten werden. Ein Repository kann markierte IDs akzeptieren und einen Result zurückgeben:
type ApiResponse<T> = {
data: T;
status: number;
};
interface UserRepository {
getUser(id: UserId):
Promise<Result<User, ApiError>>;
}
Der Komponente kümmert sich nicht mehr auf jeder Ebene darum:
any
Die Signaturen selbst zeigen an, was erfolgreich sein kann, was fehlschlagen kann und mit welchen Typen.
Anti-Muster, die vermieden werden sollten
any überall
function process(data: any) {}
Der Parameter any deaktiviert die Überprüfung für alles, was er berührt. Besser ist:
function process(data: unknown) {}
und vor der Verwendung verengen.
Aussagen als Validierung
const user =
response.data as User;
Ein as-Cast überprüft nichts; er bittet den Compiler lediglich, Ihnen zu vertrauen. Validieren Sie unzuverlässige Daten zur Laufzeit.
Generics um ihrer selbst willen
Vermeiden Sie Signaturen wie:
function transform<
T,
U,
V,
R
>(...) {}
es sei denn, jeder Typparameter drückt eine echte Beziehung aus. Ein einmal verwendeter Typparameter ist in der Regel überflüssig.
Enorme Unionen
Eine diskriminierte Union mit hundert Mitgliedern ist schwer zu durchforsten und weiterzuentwickeln. Der Anwendungsbereich benötigt möglicherweise eine andere Abstraktion, wie z. B. verschachtelte Unionen oder die Verlegung der Variationen in die Daten.
Kluge Lösungen auf Typebene
Wenn ein Typ schwieriger zu verstehen ist als die von ihm beschriebene Geschäftslogik, gehen Sie einen Schritt zurück:
Type complexity
│
▼
Developer complexity
│
▼
Maintenance cost
Typsicherheit hat ihren Preis. Streben Sie nach der nützlichsten Sicherheit pro Komplexitätsstufe, nicht nach den ausgefeiltesten Typen.
Ein Entscheidungsbaum zur Auswahl
Dieser Baum ordnet gängige Anforderungen den entsprechenden Musterkandidaten zu:
PROBLEM
│
├── Need to create objects?
│ ├── Simple creation → Constructor
│ ├── Complex creation → Builder
│ ├── Multiple implementations → Factory
│ └── Families of objects → Abstract Factory
│
├── Need to integrate another system?
│ └── Adapter
│
├── Complex subsystem?
│ └── Facade
│
├── Add behavior without modifying object?
│ └── Decorator
│
├── Multiple interchangeable algorithms?
│ └── Strategy
│
├── Subscribers react to changes?
│ └── Observer
│
├── Need actions/history/undo?
│ └── Command
│
├── Data-access abstraction?
│ └── Repository
│
├── Complex state lifecycle?
│ └── State / Discriminated Union
│
└── Type-level problem?
├── Reuse → Generics
├── Transform → Mapped Types
├── Decision → Conditional Types
├── Extract → infer
├── Property safety → keyof
├── Literal safety → as const
├── Contract validation → satisfies
└── Domain safety → Branded Types
Betrachten Sie es als Ausgangspunkt für Diskussionen; „einfach anfangen“ gilt weiterhin in jedem Fall.
Was man zuerst lernen sollte
Für Frontend-Entwickler, die sich auf Führungspositionen vorbereiten, ist das Auswendiglernen aller Gang-of-Four-Muster eine schlechte Zeitnutzung. Eine praktische Reihenfolge:
Das Wesentliche:
Discriminated Unions
Generics
Type Guards
keyof
Mapped Types
Utility Types
Composition
Strategy
Repository
Factory
Sehr empfohlen als Nächstes:
Result Type
Branded Types
Conditional Types
infer
Exhaustive Checking
Dependency Injection
Adapter
Facade
Observer
Decorator
Es lohnt sich, das konzeptionell zu verstehen:
Builder
Command
State
Abstract Factory
Singleton
Die tägliche Arbeit am Frontend stützt sich weitaus stärker auf diese Kombination:
Generics
+
Discriminated Unions
+
Composition
+
Strategy
+
Repository
im Vergleich zu jedem Lehrbuch zur Abstrakt-Fabrik-Methode.
Wie sich das Urteilsvermögen mit der Erfahrung verändert
Zu Beginn fragen Ingenieure: „Welches Muster soll ich hier verwenden?“ Mit Erfahrung in großen Systemen wird die Frage zu: „Welche minimale Abstraktion löst dieses Problem, ohne das System unverständlicher zu machen?“ Der werkseitige Ansatz mit den Mustern sieht so aus:
Problem
↓
Pattern
↓
More classes
↓
More abstractions
Der problemorientierte Arbeitsablauf sieht so aus:
Problem
↓
Understand volatility
↓
Identify boundary
↓
Start simple
↓
Introduce abstraction only where repetition/change justifies it
Gute Muster entstehen, sobald die Belastungen der Architektur klar werden; sie werden nicht im Voraus aufgezwungen.
Interviewfragen, die ein echtes Verständnis prüfen
„Was ist das Factory-Muster?“ prüft das Gedächtnis. Diese Fragen prüfen das Urteilsvermögen.
Architektur:
- Angesichts einer 2.000-Zeilen-React-Komponente: Wie wählen Sie aus, welche Abstraktion eingeführt werden soll?
- Wann lehnen Sie ein Muster ab, das scheinbar passt?
- Wie erkennen Sie, dass eine Abstraktion vorzeitig ist?
- Wann ist Komposition vorzuziehen statt Vererbung?
- Wie verhindern Sie, dass ein Singleton zu einem globalen, veränderbaren Zustand wird?
Grundlagen von TypeScript:
- Wie würden Sie eine Anfrage mit Zuständen wie Laden, Erfolg, Fehler und Wiederholung modellieren?
- Wie verhindern Sie ungültige Kombinationen von React-Props?
- Wie unterscheiden sich
unknown,anyundnevervoneinander? - Wann würde man
typestattinterfacewählen und umgekehrt? - Wie interagiert
keyofmit Generics?
Fortgeschrittener TypeScript:
- Was ist ein konkreter Anwendungsfall für bedingte Typen?
- Was bewirkt
infer? - Was ist ein mappierter Typ?
- Was sind distributive bedingte Typen?
- Wann lohnen sich branded Types?
- Welches Problem löst
satisfies? - Wie verändert
as constdie Inferenz?
Reale Designaufgaben:
- Entwerfen Sie einen typsicheren Event-Bus.
- Entwerfen Sie einen typsicheren API-Client.
- Entwerfen Sie ein Berechtigungssystem mit TypeScript.
- Entwerfen Sie eine Zahlungsabstraktion, die Stripe, PayPal und einen dritten Anbieter unterstützt.
ProductId übergeben wird, wo eigentlich ein UserId erforderlich ist?Eine aufschlussreiche Frage lautet: „Beschreiben Sie einen Moment, in dem Sie absichtlich auf ein Designmuster verzichtet haben.“ Eine gute Antwort erläutert den Kompromiss: Da es nur eine einzige Implementierung gibt und keine wahrscheinlichen Änderungen vorzusehen sind, würde die Indirektheit die Komplexität nicht verringern, sodass der Code einfach blieb, bis ein weiterer Anwendungsfall dies rechtfertigte.
Ein letztes mentales Modell
Beginnen Sie mit dem Geschäftsproblem, identifizieren Sie, wo die Komplexität liegt, trennen Sie verhaltensbezogene von strukturellen Änderungen und lassen Sie anschließend die Typsicherheit das Ergebnis straffen.
BUSINESS PROBLEM
│
▼
Identify Complexity
│
▼
What is likely to change?
│
┌──────────┴──────────┐
▼ ▼
Behavior Structure
│ │
Strategy/State Adapter/Facade
│ │
└──────────┬──────────┘
▼
Type Safety
│
┌───────────────┼────────────────┐
▼ ▼ ▼
Generics Unions Utilities
│ │ │
▼ ▼ ▼
keyof Result Type Mapped Types
infer State Model Conditional
satisfies Exhaustive Record
│ │ │
└───────────────┼────────────────┘
▼
SIMPLEER CODE
Muster als gemeinsames Vokabular
Muster funktionieren am besten als gemeinsames Vokabular: „Das ist ein Adapter“, „Diese Verhaltensweisen sind austauschbar“, „Diese Zustände gehören zu einer Union“, „Markieren Sie diese IDs“ – und am wichtigsten: „Diese Abstraktion hat ihre Komplexität noch nicht verdient.“ Gutes TypeScript wird danach beurteilt, ob das System diese Eigenschaften aufweist, und nicht danach, wie raffiniert seine Typen sind:
Invalid states
↓
become difficult to represent
Changing implementations
↓
don't break consumers
Business rules
↓
are visible in the types
Shared behavior
↓
is reusable without duplication
Complexity
↓
is isolated behind clear boundaries
- Wählen Sie Muster nach der Komplexität aus, die sie beseitigen, und nicht nach Vertrautheit.
- Ziehen Sie Unionen,
Result-Typen sowie ausführliche Überprüfungen vor Optionalfeldern und Hoffnung. - Validieren Sie Daten an den Grenzen der Laufzeit; allein Typen schützen Sie dort nicht.
- Wählen Sie die einfachste Abstraktion, die die tatsächlich erwarteten Änderungen handhaben kann.
Verwandte Literatur
- Steering AI Coding Agents with Four Named React Architecture Patterns: Lernen Sie vier React-Muster (Section Composition, Custom Hooks, polymorphe Komponenten, Reducer) sowie, wie das Benennen dieser Muster in Anweisungen zu saubererem von KI generiertem Code führt.