Wenn du TypeScript schon eine Weile schreibst, Generics aber immer noch wie etwas wirken, das du von Stack Overflow kopierst, ohne es wirklich zu verstehen — dann ist dieser Artikel die Lösung dafür.
Generics sind das Feature, das TypeScript von "JavaScript mit Typ-Annotationen" zu einem wirklich ausdrucksstarken Typsystem macht. Sobald es klickt, wirst du sie überall sehen — und natürlich danach greifen, statt bei kniffligen Situationen auf any zurückzufallen. Das
Generics-Kapitel im TypeScript Handbook
deckt den vollständigen Spec ab; dieser Artikel konzentriert sich auf die Muster, die du in echten Codebases tatsächlich verwendest.
Das Problem mit any
Generics existieren, um ein spezifisches Problem zu lösen: Du willst, dass eine Funktion oder Datenstruktur mit mehreren Typen arbeitet, ohne dabei Typinformationen zu verlieren.
Die naive Lösung ist any — und sie sieht gut aus, bis du merkst, was du aufgegeben hast:
// Mit any — TypeScript weiß nicht, was rauskommt
function identity(arg: any): any {
return arg;
}
const result = identity('hello');
// result ist als 'any' typisiert — du hast den string-Typ verloren
// TypeScript fängt das nicht:
result.toFixed(2); // kein Fehler zur Kompilierzeit, Absturz zur LaufzeitDie Typinformation geht rein, kommt aber nicht raus. Was auch immer du mit result machst, ist vollständig ungeprüft. Das ist keine TypeScript-Codebase — das ist JavaScript mit extra Schritten. Generics lösen das, indem sie dir erlauben zu sagen: "Ich kenne den genauen Typ noch nicht, aber was reingeht, soll mit demselben Typ rauskommen."
// Mit einem Generic — T fließt durch die Funktion
function identity<T>(arg: T): T {
return arg;
}
const result = identity('hello');
// result ist als 'string' typisiert ✅
result.toFixed(2); // ❌ TypeScript fängt das: Property 'toFixed' does not exist on type 'string'
const count = identity(42);
// count ist als 'number' typisiert ✅Das <T> deklariert einen Typparameter — stell dir ihn als Variable für Typen vor. TypeScript schließt auf den Wert von T aus dem Argument, das du übergibst, sodass du ihn fast nie explizit schreiben musst. identity<string>('hello') funktioniert, aber auch identity('hello') — TypeScript findet es selbst heraus.
Generische Funktionen in der Praxis
Die Identity-Funktion ist das kanonische Lehrbeispiel, aber nicht etwas, das du in der Produktion schreiben würdest. Hier sind die Arten von generischen Funktionen, die wirklich in echten Codebases auftauchen:
// Ein typensicherer Array-First/Last-Helper
function first<T>(arr: T[]): T | undefined {
return arr[0];
}
function last<T>(arr: T[]): T | undefined {
return arr[arr.length - 1];
}
const firstUser = first(users); // UserProfile | undefined
const lastOrder = last(orders); // Order | undefined
const firstNum = first([1, 2, 3]); // number | undefined
// Array-Elemente nach einem Key gruppieren
function groupBy<T, K extends string | number>(
items: T[],
getKey: (item: T) => K
): Record<K, T[]> {
return items.reduce((acc, item) => {
const key = getKey(item);
if (!acc[key]) acc[key] = [];
acc[key].push(item);
return acc;
}, {} as Record<K, T[]>);
}
// TypeScript schließt T als Order und K als string
const byStatus = groupBy(orders, order => order.status);
// byStatus: Record<string, Order[]>
// byStatus['pending'] ist Order[] ✅Beachte, wie TypeScript T und K automatisch aus der Art, wie du die Funktion aufrufst, ableitet. Du erhältst vollständige Typsicherheit beim Rückgabewert, ohne eine einzige explizite Typ-Annotation an der Aufrufstelle schreiben zu müssen. Das ist der Gewinn.
Generische Interfaces und Typen
Hier werden Generics für die tägliche Arbeit unverzichtbar. Jede Codebase, die mit einer API kommuniziert, braucht letztendlich eine Handvoll generischer Wrapper-Typen. Hier sind die, die du ständig siehst (und schreibst):
// API-Antwort-Wrapper — für jeden Endpunkt verwendet
interface ApiResponse<T> {
data: T;
status: number;
message: string;
timestamp: string;
}
// Paginierte Listen-Antwort
interface PaginatedResult<T> {
items: T[];
page: number;
pageSize: number;
totalItems: number;
totalPages: number;
}
// Mit konkreten Typen verwenden
interface UserProfile {
id: number;
name: string;
email: string;
avatarUrl: string;
}
interface Order {
id: number;
userId: number;
total: number;
status: 'pending' | 'shipped' | 'delivered';
}
// Die Rückgabetypen sind vollständig typisiert — kein Casting nötig
async function getUser(id: number): Promise<ApiResponse<UserProfile>> {
const res = await fetch(`/api/users/${id}`);
return res.json();
}
async function listOrders(page: number): Promise<PaginatedResult<Order>> {
const res = await fetch(`/api/orders?page=${page}`);
return res.json();
}
const response = await getUser(1);
response.data.email; // ✅ string
response.data.avatarUrl; // ✅ string
const result = await listOrders(1);
result.items[0].status; // ✅ 'pending' | 'shipped' | 'delivered'
result.totalPages; // ✅ numberUserProfile, Order) automatisch aus einer Beispiel-Antwort generieren. Du
steckst sie dann in deine generischen Wrapper.Generic Constraints
Manchmal möchtest du mehrere Typen akzeptieren, aber nicht absolut jeden Typ. Generic Constraints erlauben dir, anzugeben, was ein Typparameter haben muss. Die Syntax ist
T extends SomeType — was bedeutet "T muss SomeType zuweisbar sein":
// Ohne Constraint — TypeScript weiß nicht, dass T eine id-Eigenschaft hat
function findById<T>(items: T[], id: number): T | undefined {
return items.find(item => item.id === id); // ❌ Property 'id' does not exist on type 'T'
}
// Mit Constraint — T muss mindestens ein id: number-Feld haben
function findById<T extends { id: number }>(items: T[], id: number): T | undefined {
return items.find(item => item.id === id); // ✅
}
// Funktioniert mit jedem Typ, der id: number hat
const user = findById(users, 42); // UserProfile | undefined
const order = findById(orders, 101); // Order | undefined
// Ein weiterer häufiger Constraint — T muss ein Objekt sein (schließt Primitives aus)
function mergeDefaults<T extends object>(partial: Partial<T>, defaults: T): T {
return { ...defaults, ...partial };
}
// T muss name: string und email: string haben
function formatContact<T extends { name: string; email: string }>(contact: T): string {
return `${contact.name} <${contact.email}>`;
}
// Funktioniert mit jedem Objekt, das diese zwei Felder hat — UserProfile, Employee, was auch immer
formatContact({ name: 'Alice', email: '[email protected]', role: 'admin' }); // ✅Der Constraint sperrt T nicht auf genau { id: number } —
er bedeutet, dass T mindestens diese Form haben muss. Ein vollständiges
UserProfile mit zehn Feldern zu übergeben ist also in Ordnung. Das ist strukturelles Typing in Aktion, das eine der mächtigsten und
bestdokumentierten
Eigenschaften von TypeScript ist.
Der keyof-Constraint
Eines der nützlichsten generischen Muster in der TypeScript-Standardbibliothek und in echten Codebases ist die Kombination von Generics mit keyof. Es ermöglicht dir, Funktionen zu schreiben, die einen Property-Namen eines Objekttyps akzeptieren und garantieren, dass der Rückgabetyp dieser Property entspricht.
// keyof T ist die Union aller Schlüssel von T als String-Literale
// K extends keyof T bedeutet, K muss einer dieser Schlüssel sein
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user: UserProfile = {
id: 1,
name: 'Alice',
email: '[email protected]',
avatarUrl: 'https://example.com/avatar.png'
};
const name = getProperty(user, 'name'); // string ✅
const id = getProperty(user, 'id'); // number ✅
const email = getProperty(user, 'email'); // string ✅
getProperty(user, 'password'); // ❌ Argument of type '"password"' is not assignable to
// parameter of type 'keyof UserProfile'
// Echter Anwendungsfall: eine generische Sortierfunktion
function sortBy<T>(items: T[], key: keyof T): T[] {
return [...items].sort((a, b) => {
const av = a[key];
const bv = b[key];
return av < bv ? -1 : av > bv ? 1 : 0;
});
}
const byName = sortBy(users, 'name'); // ✅ nach Name sortiert
const byId = sortBy(users, 'id'); // ✅ nach id sortiert
sortBy(users, 'nonexistent'); // ❌ zur Kompilierzeit abgefangenStandard-Typparameter
Genauso wie Funktionsparameter Standardwerte haben können, können es auch Typparameter. Das ist nützlich, wenn du einen generischen Typ hast, der fast immer mit einem bestimmten Typ verwendet wird, du aber die Flexibilität für die Fälle behalten willst, wenn nicht.
// Standard-Typparameter — T nimmt string als Standard, wenn nicht angegeben
interface Cache<T = string> {
get(key: string): T | undefined;
set(key: string, value: T, ttlMs?: number): void;
delete(key: string): void;
clear(): void;
}
// Ohne T anzugeben — verwendet den Standard (string)
declare const stringCache: Cache;
const val = stringCache.get('theme'); // string | undefined
// Einen anderen T angeben
declare const userCache: Cache<UserProfile>;
const user2 = userCache.get('user:42'); // UserProfile | undefined
// Ein weiteres nützliches Beispiel: ein Event-Emitter mit typisiertem Payload
interface TypedEvent<TPayload = void> {
subscribe(handler: (payload: TPayload) => void): () => void;
emit(payload: TPayload): void;
}
// Events ohne Payload — Standard void hält die API sauber
const appReady: TypedEvent = { /* ... */ };
appReady.emit(); // kein Argument nötig
// Events mit Payload
const userLoggedIn: TypedEvent<{ userId: number; timestamp: Date }> = { /* ... */ };
userLoggedIn.emit({ userId: 42, timestamp: new Date() });Eingebaute generische Utility-Typen
TypeScript kommt mit einer Reihe von eingebauten Utility-Typen, die alle mit den Generics implementiert sind, die du oben gelernt hast. Zu verstehen, wie man sie verwendet, ist eine der praktischsten TypeScript-Fähigkeiten, die du haben kannst. Hier ist die Kernmenge mit echten Beispielen, die den Ausgabetyp zeigen:
interface UserProfile {
id: number;
name: string;
email: string;
role: 'admin' | 'user' | 'viewer';
passwordHash: string;
createdAt: Date;
}
// Partial<T> — alle Felder werden optional (gut für PATCH-Payloads)
type UpdateUserPayload = Partial<UserProfile>;
// { id?: number; name?: string; email?: string; role?: ...; ... }
// Required<T> — alle Felder werden Pflichtfelder (Umkehrung von Partial)
interface DraftConfig {
apiUrl?: string;
timeout?: number;
maxRetries?: number;
}
type ResolvedConfig = Required<DraftConfig>;
// { apiUrl: string; timeout: number; maxRetries: number }
// Pick<T, K> — nur die benannten Felder behalten
type UserSummary = Pick<UserProfile, 'id' | 'name' | 'email'>;
// { id: number; name: string; email: string }
// In Listenansichten verwenden — nur das schicken, was die UI braucht
// Omit<T, K> — die benannten Felder ausschließen
type PublicUserProfile = Omit<UserProfile, 'passwordHash'>;
// { id: number; name: string; email: string; role: ...; createdAt: Date }
// Sicher für API-Antworten
// Record<K, V> — typisiertes Dictionary / Map
type PermissionMap = Record<UserProfile['role'], string[]>;
// { admin: string[]; user: string[]; viewer: string[] }
const permissions: PermissionMap = {
admin: ['read', 'write', 'delete', 'admin'],
user: ['read', 'write'],
viewer: ['read']
};
// ReturnType<T> — ableiten, was eine Funktion zurückgibt (hält Typen automatisch synchron)
function buildUserSession(user: UserProfile) {
return {
token: crypto.randomUUID(),
userId: user.id,
role: user.role,
expiresAt: new Date(Date.now() + 3_600_000)
};
}
type UserSession = ReturnType<typeof buildUserSession>;
// { token: string; userId: number; role: 'admin' | 'user' | 'viewer'; expiresAt: Date }
// Ändere buildUserSession und UserSession aktualisiert sich automatisch ✅Wenn du bestehendes JavaScript zu TypeScript konvertierst und diese Typen von Grund auf aufbaust, kann das JS to TypeScript-Tool dir helfen, die initialen Typen zu platzieren, damit du anfangen kannst, Utility-Typen darüber zu schichten.
Ein realistisches End-to-End-Beispiel: Typisierter API-Client
Hier ist das Muster, das ich jedes Mal verwende, wenn ich eine typisierte API-Schicht baue. Es kombiniert generische Funktionen, generische Interfaces und Utility-Typen zu etwas, das dir End-to-End-Typsicherheit vom fetch-Aufruf bis zur Komponente gibt, die die Daten verbraucht:
// Das Antwort-Envelope — umhüllt jede API-Antwort
interface ApiResponse<T> {
data: T;
meta: {
requestId: string;
duration: number;
};
}
// Fehler-Envelope — was die API bei Fehlern sendet
interface ApiError {
code: string;
message: string;
details?: Record<string, string[]>;
}
// Der generische Fetch-Wrapper
async function fetchApi<T>(
url: string,
options?: RequestInit
): Promise<ApiResponse<T>> {
const res = await fetch(url, {
headers: { 'Content-Type': 'application/json', ...options?.headers },
...options
});
if (!res.ok) {
const err: ApiError = await res.json();
throw new Error(`[${err.code}] ${err.message}`);
}
return res.json();
}
// Typisierte Endpunktfunktionen — T wird an jeder Aufrufstelle gesetzt
async function getUser(id: number): Promise<UserProfile> {
const { data } = await fetchApi<UserProfile>(`/api/users/${id}`);
return data;
}
async function listOrders(userId: number, page = 1): Promise<PaginatedResult<Order>> {
const { data } = await fetchApi<PaginatedResult<Order>>(
`/api/users/${userId}/orders?page=${page}`
);
return data;
}
async function createOrder(
payload: Pick<Order, 'userId' | 'total'>
): Promise<Order> {
const { data } = await fetchApi<Order>('/api/orders', {
method: 'POST',
body: JSON.stringify(payload)
});
return data;
}
// Verwendung — vollständig typisiert, kein Casting irgendwo
const user = await getUser(42);
console.log(user.email); // string ✅
const orders = await listOrders(42);
orders.items[0].status; // 'pending' | 'shipped' | 'delivered' ✅
orders.totalPages; // number ✅
const newOrder = await createOrder({ userId: 42, total: 99.99 });
newOrder.id; // number ✅Häufige Fehler
Einige Muster, die du vermeiden solltest, sobald du dich mit Generics wohlfühlst:
anyinnerhalb eines Generics verwenden. Wenn dufunction wrap<T>(val: T): anyschreibst, hast du den Zweck verfehlt. Der ganze Punkt ist, dass der Typ durchfließt —anyals Rückgabetyp oder innerhalb der Implementierung zu verwenden bedeutet, dass TypeScript ihn nicht mehr verfolgen kann.- Mit spezifischen Typen übermäßig einschränken.
function process<T extends UserProfile>zu schreiben, wenn du nurT extends { id: number }brauchst, ist zu restriktiv. Verwende die minimale Einschränkung, die deine Implementierung zum Laufen bringt — so bleibt die Funktion über verschiedene Typen hinweg wiederverwendbar, die zufällig dieselbe Form haben. - Nach Generics greifen, wenn eine Union ausreicht. Wenn eine Funktion
stringodernumberakzeptiert und die Logik für jedes anders ist, istfunction f(arg: string | number)mit einem Type Guard innen sauberer als ein Generic. Generics glänzen, wenn dieselbe Logik auf alle Typvarianten angewendet wird. - Zu viele Typparameter. Wenn du
<T, U, V, W>schreibst, tritt einen Schritt zurück. Das ist normalerweise ein Zeichen, dass die Funktion zu viel tut, oder die Typen könnten als ein einzelnes Interface ausgedrückt werden. Der TypeScript-Compiler-Quellcode ist eine gute Referenz — selbst komplexe Utilities brauchen selten mehr als 2–3 Typparameter.
Zusammenfassung
Generics sind der Punkt, an dem TypeScript aufhört, "annotiertes JavaScript" zu sein, und zu einem echten Typsystem wird. Die Kernidee ist einfach: Erfasse den Typ als Parameter, sodass er durch deine Funktion oder Interface fließt, ohne Informationen zu verlieren. Von dort aus lassen Constraints einschränken, was akzeptabel ist, keyof gibt dir typensicheren Property-Zugriff, und die eingebauten Utility-Typen (Partial, Pick, Omit, Record) behandeln die Muster, die du sonst von Hand schreiben würdest. Der beste nächste Schritt ist, ein echtes Projekt zu öffnen, eine Funktion zu finden, wo du any verwendet hast, weil du keinen anderen Weg wusstest, und sie durch ein Generic zu ersetzen. Das
TypeScript Handbook über Generics
und die
Utility-Typen-Referenz
sind die zwei Seiten, die einen Bookmark wert sind. Und wenn du Interfaces aus echten API-Payloads aufbaust,
kann JSON to TypeScript die konkreten Typen generieren —
dann geht es nur noch darum, sie in deine generischen Utility-Typen wie
ApiResponse<T> und PaginatedResult<T> einzuwickeln, um vollständige
End-to-End-Typsicherheit zu bekommen.