TypeScript Generics anhand praktischer Beispiele

1. Übersicht

Generics sind ein Werkzeug, das dir hilft, wiederverwendbaren Code zu schreiben und dabei type safety zu bewahren. Anstatt UserRepository, OrderRepository, ProductRepository immer wieder zu schreiben, schreibst du ein gemeinsames Repository<T>. Dieser Artikel behandelt praktische Beispiele: generische Funktionen, generische Klassen, Constraints, mehrere Typparameter und die Kombination mit Utility Types. Nach dem Lesen wirst du selbstbewusst Generics in die Service-, Repository- und API-Response-Schichten deines TypeScript-Projekts integrieren können.

2. Was sind Generics und warum brauchst du sie?

Generics ermöglichen es dir, den Typ zu „parametrisieren”, ähnlich wie eine Funktion Parameter akzeptiert, aber auf der Typebene.

// Ohne Generics: muss für jeden Typ neu geschrieben werden
function firstString(arr: string[]): string {
  return arr[0];
}
function firstNumber(arr: number[]): number {
  return arr[0];
}

// Mit Generics: eine Funktion für alle Typen
function first<T>(arr: T[]): T {
  return arr[0];
}

const name = first(["Alice", "Bob"]); // T = string, automatisch hergeleitet
const age = first([30, 25]);          // T = number

<T> ist ein Typparameter — eine Variable, die den tatsächlichen Typ darstellt, der beim Aufrufen übergeben wird. TypeScript leitet T auf Basis des Arguments ab (infer), du brauchst es nicht manuell anzugeben.

Hinweis: T, U, K, V sind nur Namenskonventionen, keine besonderen Bedeutungen. Bei großen Codebasen sind aussagekräftigere Namen wie TItem, TResponse viel leichter zu lesen.

3. Generische Funktionen in der Praxis: die groupBy-Funktion

Ein häufiges Beispiel bei der Datenverarbeitung: Objekte nach einem Feld gruppieren.

interface Order {
  id: number;
  status: "pending" | "paid" | "cancelled";
  customerId: number;
}

function groupBy<T, K extends string | number>(
  items: T[],
  keyFn: (item: T) => K
): Record<K, T[]> {
  const result = {} as Record<K, T[]>;
  for (const item of items) {
    const key = keyFn(item);
    (result[key] ??= []).push(item);
  }
  return result;
}

const orders: Order[] = [
  { id: 1, status: "paid", customerId: 10 },
  { id: 2, status: "pending", customerId: 11 },
  { id: 3, status: "paid", customerId: 10 },
];

const byStatus = groupBy(orders, (o) => o.status);
  • T ist der Typ des Elements im Array (Order).
  • K extends string | number begrenzt den Schlüssel auf string oder number — da Objektschlüssel keine anderen Typen sein können.
  • Das Ergebnis byStatus hat den Typ Record<"pending" | "paid" | "cancelled", Order[]>, TypeScript kennt genau, welche Schlüssel möglich sind.
byStatus.paid       -> Order[] (2 Elemente)
byStatus.pending    -> Order[] (1 Element)
byStatus.cancelled  -> Order[] (kann undefined sein, wenn strict)

4. Generische Constraints mit extends

Constraints begrenzen T auf eine bestimmte Form und ermöglichen dir, sicher auf Eigenschaften innerhalb der generischen Funktion zuzugreifen.

interface HasId {
  id: number;
}

function findById<T extends HasId>(items: T[], id: number): T | undefined {
  return items.find((item) => item.id === id);
}

interface Customer extends HasId {
  name: string;
  email: string;
}

const customers: Customer[] = [
  { id: 1, name: "An", email: "an@example.com" },
  { id: 2, name: "Bình", email: "binh@example.com" },
];

const found = findById(customers, 1); // Customer | undefined

Ohne extends HasId würde die Zeile item.id einen Fehler verursachen, da TypeScript nicht weiß, ob T die Eigenschaft id hat.

error TS2339: Property 'id' does not exist on type 'T'.

Das ist der häufigste Fehler bei der Einführung in Generics: du vergisst das Constraint und greifst willkürlich auf Eigenschaften von T zu.

5. Generische Klasse: das Repository<T>-Beispiel

Das ist ein klassischer Anwendungsfall — ein generisches Repository für viele Entities.

interface Entity {
  id: number;
}

class Repository<T extends Entity> {
  private items: Map<number, T> = new Map();

  save(item: T): T {
    this.items.set(item.id, item);
    return item;
  }

  findById(id: number): T | undefined {
    return this.items.get(id);
  }

  findAll(): T[] {
    return Array.from(this.items.values());
  }

  deleteById(id: number): boolean {
    return this.items.delete(id);
  }
}

interface Product extends Entity {
  name: string;
  price: number;
}

const productRepo = new Repository<Product>();
productRepo.save({ id: 1, name: "Bàn phím", price: 500_000 });
productRepo.save({ id: 2, name: "Chuột", price: 200_000 });

const all = productRepo.findAll(); // Product[]
  • Repository<T extends Entity> stellt sicher, dass jede verwaltete Entity ein id hat, daher ist es sicher, id als Schlüssel in Map<number, T> zu verwenden.
  • Wenn du new Repository<Product>() deklarierst, haben alle Rückgabemethoden den exakten Typ Product — die IDE ergänzt name, price automatisch.

Hinweis: Wenn du Repository nur für eine einzige Entity in deinem gesamten Projekt verwendest, sind Generics eine Überengineering. Schreib zuerst eine konkrete Klasse, generalisiere sie nur, wenn du die Logik an ≥ 2 Stellen teilst.

6. Mehrere Typparameter: das ApiResponse<TData, TError>-Beispiel

Wenn du mehr als einen Typ parametrisieren musst, verwendest du mehrere Typparameter gleichzeitig.

interface ApiResponse<TData, TError = string> {
  success: boolean;
  data?: TData;
  error?: TError;
}

function fetchSuccess<T>(data: T): ApiResponse<T> {
  return { success: true, data };
}

function fetchFailure<T, E = string>(error: E): ApiResponse<T, E> {
  return { success: false, error };
}

interface User {
  id: number;
  username: string;
}

const okResponse: ApiResponse<User> = fetchSuccess({ id: 1, username: "nonladev" });

const errResponse: ApiResponse<User, { code: number; message: string }> = fetchFailure({
  code: 404,
  message: "Không tìm thấy user",
});

TError = string in der Interface-Deklaration ist ein default generic type — wenn du keinen zweiten Typ übergibst, verwendet TypeScript automatisch string. Deshalb ist ApiResponse<User> gültig, obwohl nur 1 Typ-Argument vorhanden ist.

7. Generics mit Utility Types kombinieren

Utility Types wie Partial, Pick, Omit sind tatsächlich generische Typen, die in TypeScript vordefiniert sind. Ihre Kombination mit deinen eigenen Generics ist sehr effektiv für Update-Funktionen.

interface Customer {
  id: number;
  name: string;
  email: string;
  vip: boolean;
}

function updateEntity<T extends { id: number }>(
  original: T,
  patch: Partial<Omit<T, "id">>
): T {
  return { ...original, ...patch };
}

const customer: Customer = { id: 1, name: "An", email: "an@a.com", vip: false };

const updated = updateEntity(customer, { vip: true });
// updated: Customer { id: 1, name: "An", email: "an@a.com", vip: true }
  • Partial<Omit<T, "id">> bedeutet: Nimm alle Felder von T außer id und mache alle diese Felder optional.
  • Dadurch erlaubt updateEntity nicht, id zu ändern, und der Aufrufer muss nur die Felder übergeben, die er ändern möchte — ähnlich wie das PATCH-Pattern in REST API.

Wenn du versuchst, { id: 2 } in patch zu übergeben, meldet TypeScript sofort einen Fehler, da id durch Omit aus dem erlaubten Typ entfernt wurde:

error TS2345: Object literal may only specify known properties,
and 'id' does not exist in type 'Partial<Omit<Customer, "id">>'.

8. Generics mit bedingtem Typ: wann du infer brauchst

Ein weiter fortgeschrittener Schritt: Automatisch den Rückgabetyp einer Funktion ermitteln, um manuelle Duplikate zu vermeiden.

type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;

async function fetchCustomer(id: number): Promise<Customer> {
  return { id, name: "An", email: "an@a.com", vip: false };
}

type FetchResult = UnwrapPromise<ReturnType<typeof fetchCustomer>>;
// FetchResult = Customer, nicht Promise<Customer>
  • infer U lässt TypeScript den Typ innerhalb von Promise<...> „erraten” und ihn U zuweisen.
  • Du musst nicht im Voraus wissen, was fetchCustomer zurückgibt — der Typ wird automatisch abgeleitet, und wenn sich die Signatur von fetchCustomer ändert, aktualisiert sich FetchResult automatisch.

Hinweis: Bedingte Typen mit infer sind sehr mächtig, können aber Code schwer lesbar machen. Verwende sie nur, wenn du ein Typ-Utility mehrmals wiederverwenden musst (wie UnwrapPromise), nicht für Logik, die nur einmal verwendet wird.

9. Tests für generische Funktionen schreiben

Generics beeinflussen die Typprüfung zur Compile-Zeit, aber das Verhalten zur Laufzeit muss wie üblich getestet werden. Installiere Vitest:

<!-- package.json (dependencies-Bereich, nicht pom.xml) -->
npm install -D vitest typescript
// repository.test.ts
import { describe, it, expect } from "vitest";

interface Entity {
  id: number;
}

class Repository<T extends Entity> {
  private items: Map<number, T> = new Map();
  save(item: T): T {
    this.items.set(item.id, item);
    return item;
  }
  findById(id: number): T | undefined {
    return this.items.get(id);
  }
}

interface Product extends Entity {
  name: string;
}

describe("Repository<Product>", () => {
  it("speichert und findet das richtige Produkt nach ID", () => {
    const repo = new Repository<Product>();
    repo.save({ id: 1, name: "Bàn phím" });

    const found = repo.findById(1);

    expect(found).toBeDefined();
    expect(found?.name).toBe("Bàn phím");
  });

  it("gibt undefined zurück, wenn die ID nicht existiert", () => {
    const repo = new Repository<Product>();

    const found = repo.findById(999);

    expect(found).toBeUndefined();
  });
});

Führe npx vitest run aus, erwartetes Ergebnis:

✓ Repository<Product> > speichert und findet das richtige Produkt nach ID
✓ Repository<Product> > gibt undefined zurück, wenn die ID nicht existiert

Test Files  1 passed (1)
     Tests  2 passed (2)

Dieser Test prüft nicht den Typ (den hat der TypeScript-Compiler bereits zur Build-Zeit geprüft), sondern die Runtime-Logik der generischen Klasse — er stellt sicher, dass Map unabhängig von T korrekt funktioniert.

10. Häufige Fehler und wann du Generics nicht verwenden solltest

  • Überflüssige Generics (over-generic): du erstellst Repository<T>, aber dein gesamtes Projekt hat nur eine Entity. In diesem Fall ist eine konkrete Klasse CustomerRepository leichter zu lesen und zu warten.
  • Constraint vergessen: Wenn du function getProp<T>(obj: T, key: string) schreibst und dann auf obj[key] zugreifst, erhältst du einen Fehler vom Typ any oder einen Index-Fehler. Verwende stattdessen <T, K extends keyof T>(obj: T, key: K): T[K].
  • any statt Generic verwenden: function identity(x: any): any hebt type safety vollständig auf — TypeScript meldet gar keine Fehler mehr. Bevorzuge immer <T>(x: T): T.
  • Zu viele Typparameter: Wenn eine Funktion 4-5 Typparameter hat, wird der Code sehr schwer lesbar. Erwäge, die Typen in einer einzigen Konfigurationsschnittstelle zusammenzufassen.
// Schwer zu lesen
function merge<A, B, C, D>(a: A, b: B, c: C, d: D) { /* ... */ }

// Leichter zu lesen
interface MergeInput<TBase, TExtra> {
  base: TBase;
  extra: TExtra;
}
function merge<TBase, TExtra>(input: MergeInput<TBase, TExtra>) { /* ... */ }

Hinweis: Wenn ein Typparameter nur genau einmal in der gesamten Funktionssignatur verwendet wird, brauchst du wahrscheinlich kein Generic — ein konkreter Typ oder unknown reicht aus.

11. Fazit

Generics ermöglichen dir, wiederverwendbaren Code zu schreiben, ohne type safety aufzugeben: ein Repository<T>, ein ApiResponse<TData, TError> statt Dutzender duplizierter Klassen/Schnittstellen. Der Schlüssel liegt darin, drei Dinge zu beherrschen — grundlegende Typparameter, Constraints mit extends und die Kombination mit vordefinierten Utility Types (Partial, Omit, Pick). Generalisiere nicht alles gleich am Anfang; extrahiere Generics nur, wenn du siehst, dass die Logik an mindestens zwei verschiedenen Stellen in der Codebasis wiederholt wird.

typescriptgenericsjavascripttype-safetyfrontend