Zum Inhalt springen

TypeScript Generics: von der Verwirrung zur Sicherheit

Ein praktischer Leitfaden zu TypeScript Generics: von einfachen Typparametern zu Conditional Types und Mustern mit Utility Types.

4 Min. Lesezeit
TypeScript-Code-Editor mit generischen Typdefinitionen und Autovervollständigung

Generics sind der Punkt, an dem das TypeScript-Wissen der meisten Entwickler an eine Wand stößt. Das Grundkonzept ist einfach — „eine Funktion, die mit jedem beliebigen Typ funktioniert" — aber in der Praxis eskaliert die Nutzung schnell zu einer Suppe aus spitzen Klammern, die sich wie eine Fremdsprache liest. Der Frust ist berechtigt. Die Generics-Syntax ist dicht. Aber die Alternative — Code für jeden Typ zu duplizieren oder auf any auszuweichen — ist schlimmer.

Der Schlüssel zum Verständnis von Generics liegt darin, sie als Funktionen zu begreifen, die auf Typen statt auf Werten operieren. Sobald dieses mentale Modell einmal sitzt, werden selbst komplexe Generics lesbar.

Die Grundlagen: Typparameter

Ein Generic ist eine Typvariable. Es erlaubt dir, Code zu schreiben, der mit unterschiedlichen Typen funktioniert und dabei die Typinformationen über die gesamte Operation hinweg bewahrt.

tstypescript
// ❌ Using 'any' — loses type information
function first(arr: any[]): any {
  return arr[0];
}
const x = first([1, 2, 3]); // x: any — useless
 
// ✅ Generic — preserves the input type
function first<T>(arr: T[]): T | undefined {
  return arr[0];
}
const x = first([1, 2, 3]);     // x: number | undefined
const y = first(["a", "b"]);    // y: string | undefined

TypeScript leitet T aus dem Argument ab — du musst es nur selten explizit angeben. Der Typ fließt durch die Funktion und bewahrt so den Vertrag zwischen Eingabe und Ausgabe.

Generics einschränken

Generics ohne Einschränkung akzeptieren jeden beliebigen Typ, was begrenzt, was du innerhalb der Funktion damit anfangen kannst. Einschränkungen (extends) grenzen den Typ ein, ohne dass er dabei aufhört, generisch zu sein.

tstypescript
// ❌ No constraint — can't access .length
function longest<T>(a: T, b: T): T {
  return a.length > b.length ? a : b; // Error: T has no .length
}
 
// ✅ Constrained — T must have a length property
function longest<T extends { length: number }>(a: T, b: T): T {
  return a.length > b.length ? a : b;
}
 
longest("hello", "hi");     // OK — strings have .length
longest([1, 2], [1, 2, 3]); // OK — arrays have .length
longest(10, 20);             // Error — numbers don't have .length

Einschränkungen bewahren den konkreten Typ und garantieren gleichzeitig ein Mindestmaß an Fähigkeiten:

tstypescript
// Extract a property from an object — safely
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}
 
const user = { name: "Wilfredo", age: 30, admin: true };
const name = getProperty(user, "name");   // string
const age = getProperty(user, "age");     // number
const bad = getProperty(user, "email");   // Error: "email" not in keyof user

Die Einschränkung keyof T bedeutet, dass K nur ein gültiger Schlüssel von T sein kann. Der Rückgabetyp T[K] ist der Typ genau dieser Eigenschaft — keine Union aller Eigenschaftstypen.

Generische Interfaces und Klassen

Generics in Interfaces definieren Verträge, die Aufrufer mit konkreten Typen ausfüllen.

tstypescript
interface Repository<T> {
  findById(id: string): Promise<T | null>;
  findAll(filter: Partial<T>): Promise<T[]>;
  create(data: Omit<T, 'id' | 'createdAt'>): Promise<T>;
  update(id: string, data: Partial<T>): Promise<T>;
  delete(id: string): Promise<void>;
}
 
interface User {
  id: string;
  name: string;
  email: string;
  createdAt: Date;
}
 
class UserRepository implements Repository<User> {
  async findById(id: string): Promise<User | null> {
    return db.query('SELECT * FROM users WHERE id = $1', [id]);
  }
 
  async create(data: Omit<User, 'id' | 'createdAt'>): Promise<User> {
    // data has type: { name: string; email: string }
    return db.query(
      'INSERT INTO users (name, email) VALUES ($1, $2) RETURNING *',
      [data.name, data.email]
    );
  }
 
  // ... implement remaining methods
}

Jede Repository<T>-Implementierung erhält typsicheren Zugriff auf die Struktur der Entität. Fügt man User ein Feld hinzu, aktualisiert sich der Parametertyp der create-Methode automatisch.

Utility Types: eingebaute Generics

TypeScript liefert Utility Types mit, die selbst Generics-Muster sind, die es sich zu verstehen lohnt.

tstypescript
// Partial<T> — all properties optional
type UserUpdate = Partial<User>;
// { id?: string; name?: string; email?: string; createdAt?: Date }
 
// Required<T> — all properties required
type StrictConfig = Required<Config>;
 
// Pick<T, K> — select specific properties
type UserPreview = Pick<User, 'id' | 'name'>;
// { id: string; name: string }
 
// Omit<T, K> — remove specific properties
type CreateUser = Omit<User, 'id' | 'createdAt'>;
// { name: string; email: string }
 
// Record<K, V> — object with keys of type K and values of type V
type UserMap = Record<string, User>;

Diese lassen sich auf natürliche Weise kombinieren:

tstypescript
// API response wrapper — reusable across all endpoints
interface ApiResponse<T> {
  data: T;
  meta: {
    timestamp: string;
    requestId: string;
  };
}
 
type UserResponse = ApiResponse<User>;
type UserListResponse = ApiResponse<User[]>;
type PaginatedResponse<T> = ApiResponse<T[]> & {
  pagination: { page: number; total: number; hasMore: boolean };
};

Conditional Types

Conditional Types wählen basierend auf einer Bedingung zwischen zwei Typen. Sie folgen dem ternären Muster: T extends U ? X : Y.

tstypescript
// Extract the element type from an array, or keep as-is
type Unwrap<T> = T extends Array<infer U> ? U : T;
 
type A = Unwrap<string[]>;    // string
type B = Unwrap<number[][]>;  // number[]
type C = Unwrap<boolean>;     // boolean
 
// Exclude null/undefined from a type
type NonNullable<T> = T extends null | undefined ? never : T;
 
type D = NonNullable<string | null>;  // string

Das Schlüsselwort infer führt innerhalb der Bedingung eine Typvariable ein — es „erfasst" einen Teil des geprüften Typs:

tstypescript
// Extract the return type of a function
type ReturnOf<T> = T extends (...args: any[]) => infer R ? R : never;
 
type E = ReturnOf<() => string>;           // string
type F = ReturnOf<(x: number) => boolean>; // boolean
 
// Extract the resolved type from a Promise
type Awaited<T> = T extends Promise<infer R> ? Awaited<R> : T;
 
type G = Awaited<Promise<string>>;           // string
type H = Awaited<Promise<Promise<number>>>;  // number (recursive!)

Praktische Generics-Muster

Typsicherer Event-Emitter

tstypescript
type EventMap = {
  'user:created': { id: string; name: string };
  'user:deleted': { id: string };
  'order:placed': { orderId: string; total: number };
};
 
class TypedEmitter<T extends Record<string, unknown>> {
  private listeners = new Map<keyof T, Set<Function>>();
 
  on<K extends keyof T>(event: K, handler: (payload: T[K]) => void): void {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, new Set());
    }
    this.listeners.get(event)!.add(handler);
  }
 
  emit<K extends keyof T>(event: K, payload: T[K]): void {
    this.listeners.get(event)?.forEach((handler) => handler(payload));
  }
}
 
const emitter = new TypedEmitter<EventMap>();
emitter.on('user:created', (data) => {
  // data is { id: string; name: string } — fully typed
  console.log(data.name);
});
emitter.emit('user:created', { id: '1', name: 'Wilfredo' }); // ✅
emitter.emit('user:created', { id: '1' }); // ❌ Error: missing 'name'

Builder-Pattern mit Generics

tstypescript
type BuilderState<T> = {
  [K in keyof T]?: T[K];
};
 
class QueryBuilder<T> {
  private conditions: string[] = [];
  private params: unknown[] = [];
 
  where<K extends keyof T & string>(
    field: K,
    op: '=' | '>' | '<' | 'LIKE',
    value: T[K]
  ): this {
    this.params.push(value);
    this.conditions.push(`${field} ${op} $${this.params.length}`);
    return this;
  }
 
  build(): { sql: string; params: unknown[] } {
    const where = this.conditions.length
      ? `WHERE ${this.conditions.join(' AND ')}`
      : '';
    return { sql: where, params: this.params };
  }
}
 
const query = new QueryBuilder<User>()
  .where('name', 'LIKE', '%Wilfredo%')
  .where('age', '>', 25) // ❌ Error if age is string in User
  .build();

Die wichtigsten Erkenntnisse

  1. Generics sind Funktionen auf Typebene — sie transformieren Typen genauso, wie normale Funktionen Werte transformieren
  2. Einschränkungen (extends) sind dein Sicherheitsnetz — sie garantieren Mindestfähigkeiten, ohne an Spezifität zu verlieren
  3. Lass TypeScript ableiten — explizite Typargumente sind selten nötig, wenn die Inferenz funktioniert
  4. Kombiniere Utility Types — Pick, Omit, Partial und Record bauen aus einfachen Typen komplexe auf
  5. Conditional Types ermöglichen Logik auf Typebene — nutze infer, um Typen aus komplexen Strukturen zu extrahieren
  6. Fang einfach an und füge Generics hinzu, sobald Duplikation auftritt — verfrühte Abstraktion bei Typen schadet genauso wie im Code
Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX