Mapped Types und Conditional Types in TypeScript

1. Überblick

In TypeScript-Projekten schreibst du oft viele ähnliche Typen: Order, dann ReadonlyOrder, OrderUpdate, OrderGetters… Das ist zeitaufwändig und fehleranfällig. Wenn sich Order ändert, müssen alle anderen manuell angepasst werden.

Mapped Types und Conditional Types lösen genau dieses Problem. Sie ermöglichen es dir, neue Typen aus bestehenden Typen zu generieren. Nach diesem Artikel kannst du eigene Utility Types wie Partial, PickByType und UnwrapPromise schreiben. Du wirst auch wissen, wie man sie testet und wann man stoppen sollte.

2. Vorbereitung

Du brauchst TypeScript 5.x mit aktiviertem strict-Flag. Der Artikel nutzt Vitest für Typ-Tests:

npm install -D typescript vitest
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "noEmit": true
  }
}

strict: true aktiviert auch strictNullChecks. Einige Ergebnisse in diesem Artikel – etwa ob undefined zu string passt – hängen von diesem Flag ab.

Die Beispiele nutzen diesen gemeinsamen Typ:

export interface Order {
  id: string;
  customerId: string;
  total: number;
  paid: boolean;
  note?: string;
}

3. Mapped Types

3.1. Grundlegende Syntax

Ein Mapped Type iteriert über eine Menge von Keys und erstellt eine Eigenschaft für jeden Key:

type ReadonlyOrder = {
  readonly [K in keyof Order]: Order[K];
};

const order: ReadonlyOrder = {
  id: "o-1",
  customerId: "c-1",
  total: 250_000,
  paid: false,
};

order.paid = true; // Kompilierungsfehler

Erklärung:

  • keyof Order ist die Union "id" | "customerId" | "total" | "paid" | "note".
  • [K in ...] iteriert über jeden Key in dieser Union.
  • Order[K] ist ein Indexed Access Type. Er gibt den Typ der Eigenschaft K zurück.
  • Ein Mapped Type der Form [K in keyof T] wird als homomorphic bezeichnet. Diese Form bewahrt die ursprünglichen Modifier, daher bleibt note optional.

Die letzte Zuweisungszeile führt zu einem Fehler:

error TS2540: Cannot assign to 'paid' because it is a read-only property.

Das obige Beispiel ist noch hart an Order gekoppelt. Um es für jeden Typ wiederzuverwenden, fügt man einen generischen Parameter hinzu:

type MyReadonly<T> = { readonly [K in keyof T]: T[K] };
type MyPartial<T> = { [K in keyof T]?: T[K] };

type OrderUpdate = MyPartial<Order>;
// { id?: string; customerId?: string; total?: number; paid?: boolean; note?: string }

So definiert die Standard-Bibliothek Readonly<T> und Partial<T>.

3.2. Modifier hinzufügen und entfernen

Du kannst + oder - vor readonly und ? setzen. Das +-Zeichen ist Standard und wird normalerweise weggelassen. Das --Zeichen wird verwendet, um Modifier zu entfernen:

type Mutable<T> = { -readonly [K in keyof T]: T[K] };
type Concrete<T> = { [K in keyof T]-?: T[K] };

type CompleteOrder = Concrete<Order>;

const draft: CompleteOrder = {
  id: "o-2",
  customerId: "c-9",
  total: 99_000,
  paid: true,
  note: "Lieferung während Geschäftszeiten",
};

Mit Concrete<Order> wird die Eigenschaft note obligatorisch. Ihr Typ ist string, nicht länger string | undefined. Die Standard-Bibliothek bietet Required<T> für genau diesen Zweck.

3.3. Keys mit as umbenennen

Seit TypeScript 4.1 kannst du Keys mit einer as-Klausel umbenennen. Dies wird oft mit Template Literal Types kombiniert:

interface Customer {
  id: string;
  name: string;
  email: string;
}

type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

type CustomerGetters = Getters<Customer>;
// {
//   getId: () => string;
//   getName: () => string;
//   getEmail: () => string;
// }

Der Teil string & K ist erforderlich. keyof T kann number und symbol enthalten, während Capitalize nur string akzeptiert. Die Typ-Schnittmenge filtert Nicht-String-Keys heraus.

Du kannst auch as zum Filtern von Keys verwenden. Wenn der Ausdruck never zurückgibt, wird dieser Key aus dem Ergebnis ausgeschlossen:

type WithoutId<T> = {
  [K in keyof T as Exclude<K, "id">]: T[K];
};

type NewOrder = WithoutId<Order>;
// { customerId: string; total: number; paid: boolean; note?: string }

Exclude ist hier ein Conditional Type. Das ist das Thema des nächsten Abschnitts.

4. Conditional Types

4.1. Syntax mit extends ? :

Ein Conditional Type funktioniert wie ein ternärer Operator, aber auf Typ-Ebene:

type IsString<T> = T extends string ? true : false;

type A = IsString<"hello">; // true
type B = IsString<42>;      // false

T extends string bedeutet “Kann T string zugewiesen werden?”. Dies ist keine Klassenvererbung.

4.2. Distributivität

Wenn T ein nackter (naked) generischer Parameter ist und du eine Union übergibst, wendet TypeScript die Bedingung auf jedes Mitglied der Union an:

type ToArray<T> = T extends unknown ? T[] : never;
type ToArrayNonDist<T> = [T] extends [unknown] ? T[] : never;

type C = ToArray<string | number>;        // string[] | number[]
type D = ToArrayNonDist<string | number>; // (string | number)[]

Die Ergebnisse sind völlig unterschiedlich. Das Einwickeln von T in ein Tuple [T] deaktiviert die Distributivität.

Distributivität ist die Grundlage von Exclude und Extract:

type MyExclude<T, U> = T extends U ? never : T;

type Status = "pending" | "paid" | "cancelled";
type ActiveStatus = MyExclude<Status, "cancelled">; // "pending" | "paid"

TypeScript prüft jedes Mitglied. "cancelled" wird zu never, und never verschwindet von selbst aus der Union.

Hinweis: Ein distributiver Conditional Type, der never erhält, gibt never zurück, da never eine leere Union ist. Zum Beispiel ist ToArray<never> never, nicht never[]. Um never zu erkennen, nutze die Wrapper-Form: [T] extends [never] ? ... : ....

4.3. Typen mit infer extrahieren

infer ermöglicht es dir, einen Teil eines Typs in der Konditions-Klausel zu “erfassen”:

type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type ElementOf<T> = T extends readonly (infer E)[] ? E : never;

async function fetchOrder(id: string): Promise<Order> {
  return { id, customerId: "c-1", total: 0, paid: false };
}

type FetchResult = ReturnType<typeof fetchOrder>; // Promise<Order>
type FetchedOrder = UnwrapPromise<FetchResult>;   // Order
type Item = ElementOf<Order[]>;                    // Order

Was jeder Teil bedeutet:

  • Promise<infer U> erklärt eine Typ-Variable U und lässt TypeScript ihren Wert ableiten.
  • Wenn die Bedingung erfüllt ist, kann der true-Zweig U verwenden. Ist sie nicht erfüllt, gibt UnwrapPromise T unverändert zurück.
  • ReturnType und Parameters in der Standard-Bibliothek sind auch mit infer geschrieben.

In der Praxis solltest du den eingebauten Awaited<T> verwenden. Er verarbeitet auch verschachtelte Promises wie Promise<Promise<Order>>.

5. Beide Techniken kombinieren

5.1. Properties nach Werttyp auswählen

Wenn du einen Conditional Type in eine as-Klausel setzt, kannst du Keys nach ihrem Werttyp filtern:

type PickByType<T, V> = {
  [K in keyof T as T[K] extends V ? K : never]: T[K];
};

type OrderNumbers = PickByType<Order, number>; // { total: number }
type OrderStrings = PickByType<Order, string>; // { id: string; customerId: string }

Beachte, dass note nicht in OrderStrings erscheint. Der Typ von Order["note"] ist string | undefined, und dieser Typ ist nicht string zuweisbar, wenn strictNullChecks aktiviert ist. Hier ist T[K] kein nackter Parameter, also ist die Bedingung nicht distributiv. Die Union wird als Ganzes geprüft.

5.2. Handler für typsichere Events

Dies ist ein näher an der Realität angelehntes Beispiel. Du hast eine Event-Tabelle für Bestellungen und möchtest einen Typ für Event-Handler generieren:

type OrderEvents = {
  created: { orderId: string; total: number };
  paid: { orderId: string; paidAt: Date };
  cancelled: { orderId: string; reason: string };
};

type EventHandlers<E> = {
  [K in keyof E as `on${Capitalize<string & K>}`]: (payload: E[K]) => void;
};

const handlers: EventHandlers<OrderEvents> = {
  onCreated: (p) => console.log(`Bestellung erstellt ${p.orderId}: ${p.total}`),
  onPaid: (p) => console.log(`Bezahlt am ${p.paidAt.toISOString()}`),
  onCancelled: (p) => console.log(`Storniert wegen: ${p.reason}`),
};

handlers.onCreated({ orderId: "o-1", total: 250_000 });
Bestellung erstellt o-1: 250000

Der Parameter p in jedem Handler wird automatisch mit dem richtigen Typ hergeleitet, daher musst du ihn nicht erneut deklarieren. Wenn du ein refunded Event zu OrderEvents hinzufügst, wird TypeScript sofort einen Fehler melden, weil handlers den fehlenden onRefunded Handler hat.

5.3. Runtime-Implementierung für generierte Typen

Mapped Types existieren nur zur Kompilierungszeit. Um ein echtes Objekt zu haben, musst du die Logik immer noch selbst schreiben:

export function toGetters<T extends object>(obj: T): Getters<T> {
  const result: Record<string, () => unknown> = {};
  for (const key of Object.keys(obj) as (keyof T & string)[]) {
    const name = `get${key.charAt(0).toUpperCase()}${key.slice(1)}`;
    result[name] = () => obj[key];
  }
  return result as unknown as Getters<T>;
}

Warum ist ein Type-Cast nötig? TypeScript kann nicht beweisen, dass die Runtime-Schleife genau die Form Getters<T> erzeugt. Der Cast as unknown as ist, wo du dich selbst verpflichtest. Deshalb muss dieser Code getestet werden.

6. Typ-Tests mit Vitest

Vitest bietet expectTypeOf zum Überprüfen von Typen, zusammen mit normalen Runtime-Assertions:

import { describe, it, expect, expectTypeOf } from "vitest";
import type { Order } from "./order";
import { toGetters } from "./getters";

describe("utility types", () => {
  it("PickByType behält nur Properties mit passendem Typ", () => {
    expectTypeOf<PickByType<Order, number>>().toEqualTypeOf<{ total: number }>();
    expectTypeOf<PickByType<Order, string>>()
      .toEqualTypeOf<{ id: string; customerId: string }>();
  });

  it("UnwrapPromise packt Promise eine Ebene aus", () => {
    expectTypeOf<UnwrapPromise<Promise<Order>>>().toEqualTypeOf<Order>();
    expectTypeOf<UnwrapPromise<number>>().toEqualTypeOf<number>();
  });

  it("toGetters gibt Getter mit korrektem Werttyp zurück", () => {
    const getters = toGetters({ id: "c-1", name: "An", email: "an@example.com" });

    expectTypeOf(getters.getName).returns.toEqualTypeOf<string>();
    expect(getters.getName()).toBe("An");
    expect(getters.getEmail()).toBe("an@example.com");

    // @ts-expect-error Getter darf nicht existieren
    getters.getPhone;
  });
});

Tests mit Typ-Überprüfung ausführen:

npx vitest run --typecheck

Beachte folgende Punkte:

  • Zur Laufzeit macht expectTypeOf nichts. Die echte Überprüfung erfolgt, wenn der Compiler die Datei analysiert. Du brauchst also das --typecheck Flag oder zusätzlich npx tsc --noEmit in deiner CI.
  • // @ts-expect-error dient für negative Tests. Wenn die nächste Zeile keinen Fehler mehr hat – zum Beispiel, weil jemand den Typ zu permissiv machte – wird dieser Kommentar selbst einen Fehler verursachen.
  • Runtime-Tests für toGetters helfen dir, Fälle zu erfassen, in denen die Runtime-Logik vom versprochenen Typ abweicht.

7. Häufige Fehler und wann man es lieber sein lässt

Vergessen von string & K bei Template Literals. Das Schreiben von `get${Capitalize<K>}` führt zu einem Fehler, da K number | symbol sein könnte.

Distributivität übersehen. Du schreibst IsString<string | number> und erwartest false, aber das Ergebnis ist boolean, also true | false. Um die ganze Union als einen Block zu prüfen, wickle sie in ein Tuple.

extends mit any verwechseln. any führt dazu, dass die Bedingung die Vereinigung beider Zweige zurückgibt. Zum Beispiel ist IsString<any> boolean.

Modifier verlieren bei nicht-homomorphen Mappings. Ein Mapped Type [K in "id" | "total"]: Order[K] bewahrt nicht die readonly oder ? Modifier von Order. Nur [K in keyof T] bewahrt die Modifier. Wenn du einige Keys auswählen und Modifier behalten möchtest, nutze Pick.

Zu tiefe Rekursion. Typen wie DeepPartial<T>, die rekursiv auf verschachtelte Objekte angewendet werden, können den Fehler Type instantiation is excessively deep and possibly infinite verursachen. Sie verlangsamen auch den Editor merklich. Außerdem sind Date, Map und Arrays auch object, daher erfasst die Bedingung T[K] extends object sie oft fälschlicherweise.

Hinweis: Verwende Mapped Types und Conditional Types nicht nur um “intelligent” auszusehen. Wenn ein Typ nur an einer Stelle benutzt wird und sich selten ändert, ist ein manuell geschriebenes Interface leicht zu lesen. Fehler aus 3–4 Ebenen tief verschachtelten Typen sind für deine Kollegen sehr verwirrend. Bevorzuge eingebaute Utilities (Partial, Pick, Omit, Record, Exclude, ReturnType, Awaited) vor eigenen Implementierungen.

Glauben, dass Typen Runtime schützen. Mapped Types überprüfen keine Daten von einer API. Wenn JSON ein Feld auslässt, ist Getters<Customer> immer noch “korrekt” zur Kompilierungszeit, aber falsch zur Laufzeit. Bei Daten von außen brauchst du einen zusätzlichen Validierungsschritt zur Laufzeit.

8. Fazit

Der Artikel hat zwei Kernwerkzeuge zur Typ-Generierung in TypeScript vorgestellt:

  • Mapped Types, mit [K in keyof T], ermöglichen es dir, über Keys zu iterieren, readonly/? hinzuzufügen oder zu entfernen, Keys umzubenennen und sie mit as zu filtern.
  • Conditional Types, mit T extends U ? X : Y, ermöglichen es dir, Typ-Level-Branches zu erstellen. Sie verteilen sich auf Unions und extrahieren Teil-Typen mit infer.

Wenn du beide Techniken kombinierst, kannst du Typen wie PickByType oder EventHandlers schreiben. Diese Typen aktualisieren sich automatisch, wenn sich der Basistyp ändert. Begleite deine Typen immer mit Tests mittels expectTypeOf und @ts-expect-error. Führe --typecheck in deiner CI aus. Und halte deine Typen einfach genug, dass andere sie verstehen können.

typescriptmapped typesconditional typesgenericstype system