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:

ts
// 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 Laufzeit

Die 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."

ts
// 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:

ts
// 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):

ts
// 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;          // ✅ number
Wenn du diese Typen aus echten API-Payloads aufbaust, kann das JSON to TypeScript-Tool die inneren Typen (UserProfile, 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":

ts
// 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.

ts
// 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 abgefangen

Standard-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.

ts
// 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:

ts
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:

ts
// 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 ✅
Du kannst mit diesen Mustern direkt im TypeScript Playground experimentieren — füge den Code ein, hover über Variablen, um abgeleitete Typen zu sehen, und versuche Dinge zu brechen, um zu sehen, was der Compiler fängt. Es ist die schnellste Feedback-Schleife für das Lernen von Generics.

Häufige Fehler

Einige Muster, die du vermeiden solltest, sobald du dich mit Generics wohlfühlst:

  • any innerhalb eines Generics verwenden. Wenn du function wrap<T>(val: T): any schreibst, hast du den Zweck verfehlt. Der ganze Punkt ist, dass der Typ durchfließt — any als 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 nur T 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 string oder number akzeptiert und die Logik für jedes anders ist, ist function 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.