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,Vsind nur Namenskonventionen, keine besonderen Bedeutungen. Bei großen Codebasen sind aussagekräftigere Namen wieTItem,TResponseviel 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);
Tist der Typ des Elements im Array (Order).K extends string | numberbegrenzt den Schlüssel aufstringodernumber— da Objektschlüssel keine anderen Typen sein können.- Das Ergebnis
byStatushat den TypRecord<"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 einidhat, daher ist es sicher,idals Schlüssel inMap<number, T>zu verwenden.- Wenn du
new Repository<Product>()deklarierst, haben alle Rückgabemethoden den exakten TypProduct— die IDE ergänztname,priceautomatisch.
Hinweis: Wenn du
Repositorynur 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 vonTaußeridund mache alle diese Felder optional.- Dadurch erlaubt
updateEntitynicht,idzu ä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 Ulässt TypeScript den Typ innerhalb vonPromise<...>„erraten” und ihnUzuweisen.- Du musst nicht im Voraus wissen, was
fetchCustomerzurückgibt — der Typ wird automatisch abgeleitet, und wenn sich die Signatur vonfetchCustomerändert, aktualisiert sichFetchResultautomatisch.
Hinweis: Bedingte Typen mit
infersind sehr mächtig, können aber Code schwer lesbar machen. Verwende sie nur, wenn du ein Typ-Utility mehrmals wiederverwenden musst (wieUnwrapPromise), 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 KlasseCustomerRepositoryleichter zu lesen und zu warten. - Constraint vergessen: Wenn du
function getProp<T>(obj: T, key: string)schreibst und dann aufobj[key]zugreifst, erhältst du einen Fehler vom Typanyoder einen Index-Fehler. Verwende stattdessen<T, K extends keyof T>(obj: T, key: K): T[K]. anystatt Generic verwenden:function identity(x: any): anyhebt 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
unknownreicht 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.