TypeScript hat ein reiches Typsystem, aber man muss nicht alles davon verstehen, um produktiv zu sein. Die Realität ist, dass 90% der Arbeit von einer Handvoll Funktionen erledigt wird, die man täglich verwendet. Dies ist die praktische Referenz — Primitives, Unions, Generics, Utility-Typen und die Narrowing-Muster, die alles klar machen. Keine abstrakte Theorie; jedes Beispiel ist die Art von Code, den man in einem echten Projekt schreibt. Wenn man JavaScript gut kennt und etwas TypeScript gemacht hat, aber sein mentales Modell festigen möchte, ist dies der richtige Artikel. Die vollständige Geschichte findet sich im TypeScript Handbuch — dieser Artikel ist die 80/20-Version.

Primitives und Literale

TypeScripts primitive Typen sind direkt auf JavaScripts Laufzeittypen abgebildet: string, number, boolean, null, undefined, bigint und symbol. Die ersten drei werden ständig verwendet; der Rest taucht in spezifischen Kontexten auf.

ts
// Primitives — the foundation
let userId: number = 42;
let username: string = 'alice';
let isActive: boolean = true;
let deletedAt: Date | null = null;     // nullable pattern
let refreshToken: string | undefined;  // optional pattern

Interessant wird TypeScript bei Literal-Typen. Anstatt nur zu sagen, dass ein Wert ein string ist, kann man sagen, er muss einer aus einer bestimmten Menge von Strings sein. Dies ist weit nützlicher als eine einfache string-Annotation, weil TypeScript ungültige Werte zur Kompilierungszeit abfangen kann:

ts
type Direction = 'north' | 'south' | 'east' | 'west';
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
type StatusCode = 200 | 201 | 400 | 401 | 403 | 404 | 500;

function navigate(dir: Direction) {
  console.log(`Moving ${dir}`);
}

navigate('north');  // ✅
navigate('up');     // ❌ Argument of type '"up"' is not assignable to type 'Direction'

// Combine with string for "known values plus free-form"
type EventName = 'click' | 'focus' | 'blur' | (string & {});
Der (string & {})-Trick am Ende lässt die Autovervollständigung für bekannte Werte funktionieren und akzeptiert trotzdem jeden String. Es ist ein häufiges Muster in Design-System-Bibliotheken.

interface vs type — Wann welches verwenden

Das ist die Frage, die jeder TypeScript-Anfänger stellt. Die praktische Antwort: interface für Objektformen verwenden — besonders solche, die öffentliche API-Verträge darstellen oder von anderen Typen erweitert werden. type für Unions, Schnittmengen, gemappte Typen und alles, das keine reine Objektform ist. In der Praxis sind sie für Objektformen weitgehend austauschbar — eine Konvention wählen und innerhalb einer Codebasis konsequent bleiben.

ts
// interface — object shapes, extensible via extends
interface User {
  id: number;
  email: string;
  createdAt: Date;
}

interface AdminUser extends User {
  role: 'admin';
  permissions: string[];
}

// type — unions, intersections, aliases for anything
type ID = string | number;
type Nullable<T> = T | null;

// Intersection — combine two shapes (common for mixins / HOC props)
type WithTimestamps = {
  createdAt: Date;
  updatedAt: Date;
};

type UserRecord = User & WithTimestamps;

// type is required here — interface can't express a union of shapes
type ApiResponse<T> =
  | { status: 'success'; data: T }
  | { status: 'error'; error: string; code: number };

Ein bedeutender Unterschied: Interfaces unterstützen Deklarationszusammenführung — man kann dasselbe Interface zweimal deklarieren und TypeScript führt die Definitionen zusammen. So erweitern Bibliotheken globale Typen (z.B. Eigenschaften zu Window hinzufügen). Types werden nicht zusammengeführt; ein Type neu zu deklarieren ist ein Fehler.

Union und Schnittmengen-Typen

Union-Typen (A | B) sagen „dieser Wert ist entweder A oder B". Schnittmengen-Typen (A & B) sagen „dieser Wert ist sowohl A als auch B gleichzeitig". Unions sind überall im echten Code — das mächtigste Muster, das sie ermöglichen, ist die diskriminierte Union, die es erlaubt, Daten zu modellieren, die in verschiedenen Zuständen sein können, ohne überall optionale Felder verwenden zu müssen.

ts
// Discriminated union — model API response states cleanly
type FetchState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: string; retryable: boolean };

// TypeScript narrows the type inside each branch
function render<T>(state: FetchState<T>) {
  switch (state.status) {
    case 'idle':    return '<p>Not started</p>';
    case 'loading': return '<p>Loading...</p>';
    case 'success': return `<pre>${JSON.stringify(state.data)}</pre>`;
    case 'error':   return `<p>Error: ${state.error}</p>`;
  }
}

// Intersection — common in React/Angular for composing prop types
type ButtonBaseProps = {
  label: string;
  disabled?: boolean;
};

type IconButtonProps = ButtonBaseProps & {
  icon: string;
  iconPosition: 'left' | 'right';
};

Das Muster der diskriminierten Union beruht auf einem Diskriminanten — einer Literal-Eigenschaft (hier status), die für jede Variante eindeutig ist. TypeScript verwendet diese Eigenschaft, um den vollständigen Typ in bedingten Zweigen einzugrenzen und gibt einem typsiicheren Zugriff auf variantenspezifische Felder wie data oder error.

Generics — Der Teil, der alle stolpert

Das motivierende Problem: Man möchte eine Funktion, die mit mehreren Typen funktioniert, aber any zu verwenden verwirft alle Typinformationen. Generics lösen dies — sie sind Typparameter, die in spitzen Klammern geschrieben werden und es einer Funktion oder einem Interface ermöglichen, über eine Familie von Typen zu arbeiten und dabei volle Typsicherheit beizubehalten.

ts
// Without generics — any loses all information
function first(arr: any[]): any {
  return arr[0];
}
const x = first([1, 2, 3]); // x is 'any' — TypeScript can't help you here

// With generics — T is inferred from the argument
function first<T>(arr: T[]): T | undefined {
  return arr[0];
}
const n = first([1, 2, 3]);       // n: number | undefined ✅
const s = first(['a', 'b', 'c']); // s: string | undefined ✅

Die <T>-Syntax deklariert einen Typparameter. Man kann ihn alles benennen — T ist nur die Konvention für einen einzelnen generischen Typ. Beim Aufruf der Funktion leitet TypeScript T aus den Argumenten ab, sodass man es selten explizit schreiben muss. Generische Interfaces sind ebenso nützlich für die Modellierung von API-Formen:

ts
// Generic interface — the API wrapper pattern every codebase has
interface ApiResponse<T> {
  data: T;
  meta: {
    page: number;
    totalPages: number;
    totalItems: number;
  };
}

interface User {
  id: number;
  name: string;
  email: string;
}

// The response type is fully typed — no casting needed
async function fetchUsers(): Promise<ApiResponse<User[]>> {
  const res = await fetch('/api/users');
  return res.json();
}

const response = await fetchUsers();
response.data[0].email; // ✅ TypeScript knows this is a string
response.meta.totalPages; // ✅ TypeScript knows this is a number

// Constrained generics — T must have an 'id' field
function findById<T extends { id: number }>(items: T[], id: number): T | undefined {
  return items.find(item => item.id === id);
}

const user = findById(users, 42); // T inferred as User
Wann Generics verwenden: wenn man dieselbe Funktionssignatur zweimal mit verschiedenen Typen schreibt, oder mit as castet, um den gewünschten Rückgabetyp zu erhalten. Dies sind die beiden Zeichen, dass ein Generic sauberer wäre.

Utility-Typen, die man tatsächlich verwenden wird

TypeScript wird mit einer Reihe von integrierten Utility-Typen geliefert, die bestehende Typen in neue umwandeln. Sie eliminieren die Notwendigkeit, Typdefinitionen manuell zu duplizieren oder anzupassen. Hier sind diejenigen, die in echten Codebasen ständig auftauchen:

ts
interface User {
  id: number;
  name: string;
  email: string;
  role: 'admin' | 'user';
  createdAt: Date;
}

// Partial<T> — all properties become optional
// Perfect for PATCH/update payloads
type UpdateUserPayload = Partial<User>;
// { id?: number; name?: string; email?: string; ... }

async function updateUser(id: number, payload: Partial<User>) {
  return fetch(`/api/users/${id}`, {
    method: 'PATCH',
    body: JSON.stringify(payload)
  });
}

// Required<T> — all properties become required (opposite of Partial)
interface Config {
  apiUrl?: string;
  timeout?: number;
  retries?: number;
}
type ResolvedConfig = Required<Config>;
// { apiUrl: string; timeout: number; retries: number }

// Pick<T, K> — keep only specified properties
type UserSummary = Pick<User, 'id' | 'name' | 'email'>;
// Use in list views where you only need the display fields

// Omit<T, K> — exclude specified properties
type PublicUser = Omit<User, 'role' | 'createdAt'>;
// Safe to expose in API responses

// Readonly<T> — prevents mutation (great for config and frozen state)
const config: Readonly<ResolvedConfig> = {
  apiUrl: 'https://api.example.com',
  timeout: 5000,
  retries: 3
};
// config.timeout = 10000; // ❌ Cannot assign to 'timeout' because it is a read-only property

// Record<K, V> — typed dictionary
type UserCache = Record<number, User>;
const cache: UserCache = {};
cache[42] = { id: 42, name: 'Alice', email: '[email protected]', role: 'user', createdAt: new Date() };

// Common pattern: mapping string keys to a known value shape
type FeatureFlags = Record<string, { enabled: boolean; rolloutPct: number }>;

// ReturnType<T> — infer the return type of a function
function createSession(userId: number) {
  return {
    token: crypto.randomUUID(),
    userId,
    expiresAt: new Date(Date.now() + 86_400_000)
  };
}

type Session = ReturnType<typeof createSession>;
// { token: string; userId: number; expiresAt: Date }
// — no need to define the type separately and keep it in sync

unknown vs any vs never

Diese drei Typen verwirren die meisten Entwickler, bis sie verstehen, welches Problem jeder löst. any ist der Ausweg — er deaktiviert die Typprüfung für diesen Wert vollständig. Er ist nützlich beim Migrieren von JavaScript zu TypeScript, aber zu häufige Verwendung untergräbt den Zweck von TypeScript. unknown ist die typsichere Alternative: der Wert könnte alles sein, aber man muss ihn einengen, bevor man etwas damit machen kann. never ist ein Typ, der nie auftreten kann — der Boden der Typhierarchie.

ts
// any — type checking disabled, use sparingly
function dangerousTransform(input: any) {
  return input.toUpperCase(); // TypeScript won't warn even if this crashes at runtime
}

// unknown — safe alternative, forces you to check before using
function safeTransform(input: unknown): string {
  if (typeof input === 'string') {
    return input.toUpperCase(); // ✅ narrowed to string inside this block
  }
  if (typeof input === 'number') {
    return input.toFixed(2);    // ✅ narrowed to number
  }
  throw new Error(`Cannot transform value of type ${typeof input}`);
}

// Common use: parsing untrusted data (API responses, localStorage, user input)
function parseConfig(raw: unknown): ResolvedConfig {
  if (
    typeof raw === 'object' &&
    raw !== null &&
    'apiUrl' in raw &&
    typeof (raw as any).apiUrl === 'string'
  ) {
    return raw as ResolvedConfig;
  }
  throw new Error('Invalid config format');
}

// never — the exhaustive switch pattern
type Shape = { kind: 'circle'; radius: number } | { kind: 'square'; side: number };

function area(shape: Shape): number {
  switch (shape.kind) {
    case 'circle': return Math.PI * shape.radius ** 2;
    case 'square': return shape.side ** 2;
    default:
      // If you add a new Shape variant and forget to handle it here,
      // TypeScript will error: Type 'NewShape' is not assignable to type 'never'
      const _exhaustive: never = shape;
      throw new Error(`Unhandled shape: ${_exhaustive}`);
  }
}

Typ-Narrowing in der Praxis

Typ-Narrowing ist, wie TypeScript einen breiten Typ (string | number, unknown, eine diskriminierte Union) auf einen spezifischen Typ in einem bedingten Block verfeinert. Die TypeScript-Narrowing-Dokumentation deckt jede Guard-Funktion ausführlich ab — hier sind die Muster, die man täglich schreiben wird.

ts
// typeof — primitive narrowing
function formatValue(val: string | number | boolean): string {
  if (typeof val === 'string')  return val.trim();
  if (typeof val === 'number')  return val.toLocaleString();
  return val ? 'Yes' : 'No';
  // TypeScript knows val must be boolean here
}

// instanceof — class/object narrowing
function handleError(err: unknown): string {
  if (err instanceof Error)   return err.message;
  if (typeof err === 'string') return err;
  return 'An unknown error occurred';
}

// in operator — property existence check
type Cat = { meow(): void };
type Dog = { bark(): void };

function makeNoise(animal: Cat | Dog) {
  if ('meow' in animal) {
    animal.meow(); // TypeScript narrows to Cat
  } else {
    animal.bark(); // TypeScript narrows to Dog
  }
}

// Discriminated union narrowing (most common in component state)
type LoadState<T> =
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; message: string };

function renderState<T>(state: LoadState<T>) {
  if (state.status === 'success') {
    console.log(state.data);    // ✅ data is accessible
  } else if (state.status === 'error') {
    console.error(state.message); // ✅ message is accessible
  }
}

// Type predicates — custom type guards
function isUser(obj: unknown): obj is User {
  return (
    typeof obj === 'object' &&
    obj !== null &&
    'id' in obj &&
    'email' in obj
  );
}

// After isUser() returns true, TypeScript knows obj is User
function processPayload(payload: unknown) {
  if (isUser(payload)) {
    console.log(payload.email); // ✅ fully typed
  }
}

Der typeof-Operator ist das grundlegendste Narrowing-Werkzeug. In Kombination mit instanceof, dem in-Operator und Diskriminanten-Eigenschaftsprüfungen kann man praktisch jedes Narrowing-Szenario behandeln, ohne any oder unsichere Casts zu verwenden.

Zusammenfassung

TypeScripts Typsystem lohnt es sich, die Grundlagen gut zu lernen, anstatt obskure Funktionen auswendig zu lernen. Literal-Typen und diskriminierte Unions eliminieren ganze Kategorien von Laufzeitfehlern. Generics ermöglichen das Schreiben wiederverwendbarer, typsicherer Abstraktionen. Utility-Typen wie Partial, Pick, Omit und ReturnType halten die Typen DRY. Und unknown mit Narrowing gibt Typsicherheit, auch an den Grenzen, wo Daten von außen hereinkommen. Der beste Weg, all dies zu festigen, ist interaktives Experimentieren — der TypeScript Playground ermöglicht das Einfügen beliebiger Snippets und das sofortige Sehen der abgeleiteten Typen, ohne jede Einrichtung. Wenn man mit JSON-Daten in TypeScript-Projekten arbeitet, können der JSON Formatter und das JSON to TypeScript-Tool auf dieser Seite automatisch Interface-Definitionen aus echten API-Nutzlasten generieren.