Offene Schlüssel oder erzwungene Vollständigkeit: zwei Werkzeuge, zwei völlig unterschiedliche Garantien
Beide Konstrukte typisieren Objekte mit mehreren gleichartigen Werten, doch sie geben dem Compiler grundlegend verschiedene Versprechen. Wer { [key: string]: T } und Record<K, V> für austauschbar hält, produziert entweder zu laxe Typen, die jeden Tippfehler durchwinken, oder zu strenge Typen, die an der Laufzeitrealität scheitern. Dieser Artikel zeigt Syntax, Semantik und die konkrete Entscheidungshilfe für den Alltag.
Inhaltsverzeichnis
- 1. Zwei Wege, ein Objekt zu typisieren
- 2. Index Signatures: Syntax und Semantik
- 3. Record
: Der Mapped Type aus lib.es5.d.ts - 4. Der Kernunterschied: offene Keys vs. erzwungene Vollständigkeit
- 5. Das keyof typeof Pattern
- 6. noUncheckedIndexedAccess: Sicherheit bei Indexzugriffen
- 7. Mapped Types: Record ist nur ein Spezialfall
- 8. Faustregel: Wann welches Werkzeug?
- 9. Best Practices und Fazit
- 10. Zusammenfassung
- 11. FAQ
1. Zwei Wege, ein Objekt zu typisieren
Sobald ein Objekt mehr als eine Handvoll fest benannter Properties hat oder seine Schlüssel erst zur Laufzeit feststehen, reicht ein klassisches Interface mit einzeln aufgezählten Feldern nicht mehr aus. TypeScript bietet dafür zwei unterschiedliche Werkzeuge: die Index Signature und den Mapped Type Record<K, V>. Beide erlauben es, einen Wertetyp T für eine ganze Klasse von Schlüsseln festzulegen, statt jede Property einzeln zu benennen.
Der entscheidende Unterschied liegt nicht in der Syntax, die sich oberflächlich ähnelt, sondern in der Semantik: Eine Index Signature beschreibt eine offene Menge von Schlüsseln, bei der zusätzliche, dem Compiler unbekannte Keys stillschweigend erlaubt sind. Ein Record mit einem konkreten Union-Typ als Schlüssel beschreibt dagegen eine geschlossene Menge, bei der jeder einzelne Schlüssel vorhanden sein muss und unbekannte Schlüssel abgelehnt werden.
Diese Unterscheidung wirkt zunächst akademisch, hat aber sehr konkrete Konsequenzen für Bugs: Ein falsch gewähltes Konstrukt lässt entweder Tippfehler in Property-Namen unbemerkt durch, oder es zwingt eine Codebasis zu unnötig strikten Vollständigkeitsanforderungen, wo eigentlich Flexibilität gebraucht wird.
// Oberflächlich ähnlich, semantisch grundverschieden
type Dictionary = { [key: string]: string }; // offene Keys
type StatusLabels = Record<"open" | "closed" | "pending", string>; // geschlossene Keys
const d: Dictionary = { hello: "Hallo", anything: "ok" }; // beliebige Keys erlaubt
const s: StatusLabels = { open: "Offen", closed: "Geschlossen", pending: "Ausstehend" };
// s ohne "pending" wäre ein Compile-Fehler, d.irgendeinKey ist immer erlaubt
2. Index Signatures: Syntax und Semantik
Die klassische Index Signature { [key: string]: T } teilt dem Compiler mit, dass ein Objekt beliebig viele Properties besitzen kann, deren Namen vom Typ string sind und deren Werte alle dem Typ T entsprechen. Analog dazu erlaubt [index: number]: T numerische Indizes, wie sie etwa Array-artige Strukturen brauchen. Wichtig dabei: In JavaScript werden Objektschlüssel intern ohnehin als Strings behandelt, eine numerische Index Signature ist also eine zusätzliche, striktere Sicht auf denselben Mechanismus, keine grundsätzlich andere Speicherform.
Seit TypeScript 4.4 sind Index Signatures nicht mehr auf string und number beschränkt. Erlaubt sind jetzt auch Template-Literal-Typen sowie Unions aus solchen Typen, und zusätzlich symbol als Schlüsseltyp. Damit lassen sich etwa alle Schlüssel, die mit einem bestimmten Präfix beginnen, gezielt typisieren, während andere Schlüsselformen weiterhin abgelehnt werden.
Eine Besonderheit: Innerhalb eines Objekttyps dürfen mehrere Index Signatures koexistieren, solange ihre Wertetypen miteinander kompatibel sind, etwa eine string-Signature und eine number-Signature nebeneinander. Zusätzlich definierte, konkret benannte Properties müssen dem Wertetyp der passenden Index Signature entsprechen, sonst meldet der Compiler einen Fehler.
// string-Index-Signature
interface Translations {
[key: string]: string;
}
// number-Index-Signature (typischerweise für Array-ähnliche Strukturen)
interface SparseArray {
[index: number]: string;
}
// Seit TS 4.4: Template-Literal- und Union-Index-Signatures
type CssVariables = {
[key: `--${string}`]: string; // nur Custom-Properties wie "--primary-color"
};
type EventKeys = {
[key: `on${string}`]: (event: Event) => void; // nur "onClick", "onSubmit" usw.
};
// symbol als Schlüsseltyp
type SymbolIndexed = {
[key: symbol]: unknown;
};
const vars: CssVariables = { "--primary-color": "#0ea5e9" }; // ok
// const bad: CssVariables = { color: "red" }; // Fehler: "color" passt nicht zum Pattern
3. Record: Der Mapped Type aus lib.es5.d.ts
Record<K, V> ist kein eingebautes Sprachfeature im engeren Sinn, sondern ein in lib.es5.d.ts mitgelieferter Mapped Type. Seine Definition ist denkbar kompakt: type Record<K extends keyof any, T> = { [P in K]: T }. Der Compiler iteriert dabei über jeden Schlüssel der Union K und erzeugt für jeden davon eine eigene, benannte Property vom Typ T.
Der Constraint K extends keyof any bedeutet, dass K ein string, number oder symbol sein muss, da genau diese drei Typen als Objektschlüssel in JavaScript zulässig sind. In der Praxis wird K fast immer ein Literal-Union-Typ wie "open" | "closed" | "pending" oder ein via keyof abgeleiteter Typ sein.
Weil Record intern über [P in K] iteriert, entstehen für jeden Wert der Union K jeweils konkrete, benannte Properties, keine generische Index Signature. Das ist der technische Grund dafür, dass sich Record und Index Signature bei der Vollständigkeitsprüfung und bei noUncheckedIndexedAccess unterschiedlich verhalten, wie die folgenden Abschnitte zeigen.
// Vereinfachte Definition aus lib.es5.d.ts
type Record<K extends keyof any, T> = {
[P in K]: T;
};
// Typische Anwendung: bekannte, endliche Schlüsselmenge
type FeatureFlags = Record<"darkMode" | "betaCheckout" | "newSearch", boolean>;
const flags: FeatureFlags = {
darkMode: true,
betaCheckout: false,
newSearch: true,
// jeder Schlüssel der Union MUSS vorhanden sein
};
// Record<string, T> ist ebenfalls gültig, verhält sich dann aber
// im Kern wie eine Index Signature (siehe nächster Abschnitt)
type LooseDictionary = Record<string, number>;
4. Der Kernunterschied: offene Keys vs. erzwungene Vollständigkeit
Der praktisch wichtigste Unterschied zeigt sich, sobald K in Record<K, V> ein konkreter Literal-Union-Typ ist, etwa "a" | "b" | "c": Der Compiler erzwingt dann Vollständigkeit. Jeder einzelne Schlüssel der Union muss im Objektliteral vorkommen, sonst schlägt die Zuweisung fehl. Gleichzeitig lehnt TypeScript jeden Schlüssel ab, der nicht Teil der Union ist, ein klassischer Fall von Excess Property Check in Kombination mit der Struktur des Mapped Types.
Eine Index Signature verhält sich diametral entgegengesetzt: Sie beschreibt lediglich eine obere Grenze für erlaubte Schlüssel und deren Werttyp, erzwingt aber nie, dass ein bestimmter Schlüssel tatsächlich vorhanden ist. Ein leeres Objekt {} ist beispielsweise gültig für { [key: string]: string }, während dasselbe leere Objekt für Record<"a" | "b", string> abgelehnt wird, weil weder a noch b vorhanden sind.
Wird Record<string, T> statt eines konkreten Unions verwendet, verschwindet dieser Unterschied fast vollständig: string als Schlüsseltyp ist selbst keine endliche, aufzählbare Menge, weshalb TypeScript hier keine Vollständigkeit erzwingen kann. Record<string, T> verhält sich strukturell praktisch identisch zu { [key: string]: T }, das ist auch der Grund, warum viele Codebasen Record<string, T> als lesbarere Alternative zur klassischen Index-Signature-Syntax bevorzugen.
type OpenDict = { [key: string]: number };
type ClosedRecord = Record<"a" | "b", number>;
const open1: OpenDict = {}; // ok, keine Schlüssel erforderlich
const open2: OpenDict = { anything: 1 }; // ok, unbekannter Key erlaubt
// const closed1: ClosedRecord = {}; // Fehler: "a" und "b" fehlen
const closed2: ClosedRecord = { a: 1, b: 2 }; // ok, vollständig
// const closed3: ClosedRecord = { a: 1, b: 2, c: 3 }; // Fehler: "c" ist unbekannt
// Record<string, T> nähert sich wieder der Index Signature an
type NearlyOpen = Record<string, number>;
const near: NearlyOpen = {}; // ok, string ist keine endliche Menge
5. Das keyof typeof Pattern
In der Praxis existiert eine Schlüsselmenge häufig schon als konkretes Objektliteral, etwa eine Konfiguration oder ein Enum-artiges Objekt, bevor überhaupt ein passender Typ dafür geschrieben wird. Statt die Union der Schlüssel manuell und redundant zu pflegen, lässt sie sich mit dem keyof typeof Pattern direkt aus dem Objekt ableiten: typeof obj liefert den konkreten Objekttyp mit allen literalen Property-Namen, keyof extrahiert daraus die Union dieser Namen.
Dieses Pattern ist besonders wertvoll, um einen Record-Key-Typ synchron zu einer bereits existierenden Datenquelle zu halten. Ändert sich das Ausgangsobjekt, etwa weil ein neuer Status hinzukommt, aktualisiert sich der abgeleitete Union-Typ automatisch mit, ohne dass an zwei Stellen im Code manuell nachgezogen werden muss.
Wichtig ist dabei as const auf dem Ausgangsobjekt, sofern die Werte selbst als Literal-Typen erhalten bleiben sollen, für die reine Schlüssel-Ableitung über keyof typeof ist as const jedoch nicht zwingend erforderlich, da keyof ohnehin nur die Property-Namen betrachtet, nicht deren Werttypen.
// Ausgangspunkt: ein Objektliteral definiert die Quelle der Wahrheit
const orderStatus = {
pending: "Ausstehend",
shipped: "Versendet",
delivered: "Zugestellt",
cancelled: "Storniert",
} as const;
// keyof typeof leitet die Union der Schlüssel automatisch ab
type OrderStatusKey = keyof typeof orderStatus;
// entspricht: "pending" | "shipped" | "delivered" | "cancelled"
// Damit lässt sich ein passender Record bauen, der zwangsläufig
// synchron zum Ausgangsobjekt bleibt
type StatusColor = Record<OrderStatusKey, string>;
const statusColors: StatusColor = {
pending: "amber",
shipped: "blue",
delivered: "green",
cancelled: "red",
};
function label(key: OrderStatusKey): string {
return orderStatus[key];
}
6. noUncheckedIndexedAccess: Sicherheit bei Indexzugriffen
Standardmäßig behandelt TypeScript den Zugriff über eine Index Signature so, als sei der Schlüssel garantiert vorhanden: dict[someKey] liefert den Typ T, obwohl zur Laufzeit durchaus undefined herauskommen kann, wenn der Schlüssel tatsächlich fehlt. Das Compiler-Flag noUncheckedIndexedAccess schließt genau diese Lücke: Ist es aktiv, liefert jeder Zugriff über eine Index Signature den Typ T | undefined statt nur T, und zwingt den Code dazu, den undefined-Fall explizit zu behandeln.
Entscheidend für das Verständnis: noUncheckedIndexedAccess betrifft grundsätzlich jeden Zugriff über eine Index Signature, unabhängig davon, ob diese über die klassische { [key: string]: T } Syntax oder über Record<string, T> geschrieben wurde, denn beide erzeugen strukturell dieselbe Index Signature. Der Zugriff Record wird also ebenso zu T | undefined wie der Zugriff auf ein Objekt mit klassischer Index Signature.
Anders verhält es sich bei Record mit einem konkreten Literal-Union als Schlüssel: Der Zugriff auf einen bekannten, literalen Schlüssel wie statusColors.pending oder statusColors["pending"] wird von TypeScript wie der Zugriff auf eine benannte, garantiert vorhandene Property behandelt, nicht wie ein Index-Signature-Zugriff. Deshalb bleibt der Typ hier T, auch bei aktivem noUncheckedIndexedAccess, denn strukturell entsteht durch den Mapped Type ja eine konkrete Property und keine generische Index Signature. Wird jedoch mit einer nicht literalen Variable vom Typ OrderStatusKey indexiert, etwa statusColors[variableKey], greift dieselbe Absicherung wieder, da TypeScript hier konservativ mit einem indizierten Zugriff auf einen Union-Typ umgeht.
// tsconfig.json: { "compilerOptions": { "noUncheckedIndexedAccess": true } }
const dict: { [key: string]: number } = { a: 1, b: 2 };
const v1 = dict["a"]; // Typ: number | undefined
const recordLoose: Record<string, number> = { a: 1, b: 2 };
const v2 = recordLoose["a"]; // Typ: number | undefined, gleiches Verhalten wie oben
const recordStrict: Record<"a" | "b", number> = { a: 1, b: 2 };
const v3 = recordStrict.a; // Typ: number, literaler Key = benannte Property
const v4 = recordStrict["a"]; // Typ: number, gleiches Verhalten
function readDynamic(key: "a" | "b") {
return recordStrict[key]; // Typ: number, key ist auf die Union eingeschränkt
}
7. Mapped Types: Record ist nur ein Spezialfall
Record ist letztlich nur die einfachste, vorgefertigte Ausprägung eines allgemeineren Konzepts: dem Mapped Type. Die Syntax { [P in K]: T } lässt sich beliebig erweitern, etwa um readonly und optionale Modifier, um Werttypen, die selbst vom jeweiligen Schlüssel P abhängen, oder um Key-Remapping über die as-Klausel, eingeführt in TypeScript 4.1.
Während Record<K, T> jedem Schlüssel denselben festen Typ T zuweist, kann ein selbst geschriebener Mapped Type den Werttyp pro Schlüssel individuell berechnen, etwa mit { [P in K]: SomeType[P] } zur Ableitung aus einem bestehenden Typ, wie es Utility-Types wie Partial, Readonly oder Pick intern tun. Diese Utility-Types sind selbst nichts anderes als spezialisierte Mapped Types, die dieselbe Grundmechanik wie Record nutzen, nur mit anderen Modifiern und Quellausdrücken.
Für die Praxis bedeutet das: Sobald ein einfacher Record<K, T> nicht mehr ausreicht, weil unterschiedliche Schlüssel unterschiedliche Werttypen brauchen oder weil Schlüssel umbenannt werden müssen, ist der nächste Schritt kein Bruch mit dem bisherigen Modell, sondern lediglich das Ausschreiben derselben Mapped-Type-Syntax mit zusätzlichen Fähigkeiten.
// Record ist die einfachste Form eines Mapped Type
type Simple = Record<"a" | "b", number>;
// entspricht exakt:
type SimpleExplicit = { [P in "a" | "b"]: number };
// Mapped Types können mehr: readonly, optional, Key-Remapping (TS 4.1+)
type ReadonlyPartial<K extends string, T> = {
readonly [P in K]?: T;
};
// Werttyp pro Schlüssel individuell ableiten (wie Utility-Types es intern tun)
type FieldValidators<T> = {
[P in keyof T]: (value: T[P]) => boolean;
};
interface Product {
name: string;
price: number;
}
const validators: FieldValidators<Product> = {
name: (value) => value.length > 0,
price: (value) => value > 0,
};
// Key-Remapping mit der "as"-Klausel
type Getters<T> = {
[P in keyof T as `get${Capitalize<string & P>}`]: () => T[P];
};
// ergibt für Product: { getName: () => string; getPrice: () => number }
8. Faustregel: Wann welches Werkzeug?
Die praktische Entscheidung lässt sich auf eine einzige Frage reduzieren: Ist die Menge der Schlüssel zur Compile-Zeit bekannt und endlich, oder erst zur Laufzeit? Bei einer dynamischen, zur Compile-Zeit unbekannten Schlüsselmenge, etwa einem Übersetzungs-Dictionary, das aus einer JSON-Datei geladen wird, einem Cache mit beliebigen Cache-Keys, oder Query-Parametern einer URL, ist eine Index Signature oder Record<string, T> das passende Werkzeug. Der Compiler kann hier ohnehin keine sinnvolle Vollständigkeit prüfen, weil die Schlüssel schlicht nicht im Typsystem existieren.
Bei einer bekannten, endlichen Schlüsselmenge, etwa einem Status-Enum, den Werten eines Feature-Flag-Systems, den erlaubten HTTP-Methoden oder den Konfigurationsoptionen pro Feature, ist Record<K, V> mit einem konkreten Literal-Union die bessere Wahl. Der entscheidende Gewinn ist die Vollständigkeitsprüfung: Vergisst ein Entwickler einen Status beim Anlegen eines neuen Konfigurationsobjekts, meldet der Compiler den Fehler sofort, statt dass er erst zur Laufzeit als fehlender Eintrag auffällt.
Eine nützliche Kontrollfrage in Grenzfällen: Würde ein neuer, unbekannter Schlüssel im Code eher als Feature (mehr Übersetzungen, mehr Cache-Einträge) oder als Bug (ein Status, der im Enum fehlt) gewertet? Im ersten Fall passt die offene Index Signature, im zweiten Fall der geschlossene Record.
// Dynamische, unbekannte Keys: Uebersetzungen aus einer JSON-Datei
type Translations = { [key: string]: string };
// oder äquivalent: Record<string, string>
async function loadTranslations(locale: string): Promise<Translations> {
const res = await fetch(`/i18n/${locale}.json`);
return res.json();
}
// Bekannte, endliche Keys: Feature-Flags pro Feature
type FeatureFlags = Record<"darkMode" | "newCheckout" | "betaSearch", boolean>;
const flags: FeatureFlags = {
darkMode: true,
newCheckout: false,
betaSearch: true,
// ein vergessener Flag wäre sofort ein Compile-Fehler
};
9. Best Practices und Fazit
Ein häufiger Kompromiss in gewachsenen Codebasen ist Record<string, T> als lesbarere, konsistentere Alternative zur klassischen Index-Signature-Syntax, ohne dass dabei semantisch etwas verloren geht, da beide Formen strukturell äquivalent sind. Für neue Konfigurationsobjekte, Status-Mappings und Ähnliches lohnt sich fast immer der Griff zum konkreten Literal-Union über Record, die Vollständigkeitsprüfung fängt eine ganze Klasse von Bugs bereits beim Kompilieren ab.
noUncheckedIndexedAccess sollte in jedem neuen TypeScript-Projekt aktiviert werden, sobald irgendwo Index Signatures oder Record<string, T> im Spiel sind, denn ohne das Flag verstecken sich potenzielle undefined-Zugriffe hinter einem vermeintlich sicheren Typ T. Die dadurch erzwungenen Prüfungen, etwa via optional Chaining oder expliziten Existenz-Checks, sind kein Overhead, sondern machen implizite Annahmen über Datenvollständigkeit im Code sichtbar.
Zusammengefasst: Index Signatures modellieren Unsicherheit über die Schlüsselmenge ehrlich, Record mit konkretem Union-Typ modelliert Gewissheit und erzwingt sie auch. Wer beide Werkzeuge nach dieser Leitlinie einsetzt, statt sie austauschbar zu behandeln, gewinnt an Stellen, an denen es zählt, nämlich beim Kompilieren, echte Fehlerprävention und verliert an Stellen, an denen echte Flexibilität nötig ist, keine Ausdrucksstärke.
// Faustregel als Code-Kommentar zusammengefasst
// 1. Schlüssel dynamisch/unbekannt zur Compile-Zeit -> Index Signature / Record<string, T>
// 2. Schlüssel bekannt/endlich -> Record<"a" | "b" | "c", T> (erzwingt Vollständigkeit)
// 3. noUncheckedIndexedAccess IMMER aktivieren, sobald Index Signatures im Spiel sind
// 4. Braucht es pro Schlüssel einen unterschiedlichen Werttyp -> eigener Mapped Type
interface TsConfigExcerpt {
compilerOptions: {
strict: true;
noUncheckedIndexedAccess: true;
};
}
| Merkmal | Index Signature | Record |
Typischer Einsatz |
|---|---|---|---|
| Unbekannte Zusatz-Keys | Erlaubt, stillschweigend akzeptiert | Abgelehnt, Compile-Fehler | Dictionary vs. Enum-Mapping |
| Vollständigkeit erzwungen | Nein, leeres Objekt ist gültig | Ja, jeder Union-Key ist Pflicht | Feature-Flags pro Feature |
| Zugriff mit noUncheckedIndexedAccess | Immer T | undefined | T bei literalem Key, T | undefined bei Union-Variable | Sichere Zugriffe auf Caches |
| Herkunft im Typsystem | Eingebautes Sprachfeature | Mapped Type aus lib.es5.d.ts | Beide seit frühen TS-Versionen |
| Verallgemeinerung | Keine, feste Grundform | Spezialfall von { [P in K]: T } | Custom Mapped Types mit as-Klausel |
| Passt zu keyof typeof | Selten sinnvoll | Ideal, Union direkt aus Objekt ableitbar | Status-Mappings aus Konfiguration |
Mironsoft
TypeScript-Migration, Typsicherheit und Team-Onboarding
JavaScript-Codebasis ohne Typsicherheit, aber keine Zeit für eine Rundum-Migration?
Wir migrieren bestehende JavaScript-Projekte schrittweise zu TypeScript, richten strikte Compiler-Einstellungen sauber ein und bringen Teams mit Code-Reviews und Style-Guides auf denselben Typsicherheits-Stand.
Migrations-Fahrplan
Schrittweise JS-zu-TS-Migration ohne Big-Bang-Risiko planen und umsetzen.
Strict-Mode-Einführung
tsconfig.json, ESLint-Regeln und CI-Checks für dauerhafte Typsicherheit aufsetzen.
Team-Onboarding
Entwickler mit Workshops und Code-Reviews in TypeScript-Best-Practices einarbeiten.
10. Zusammenfassung
Index Signatures vs. Record
Offene Keys
Index Signature akzeptiert beliebige zusätzliche String-Keys stillschweigend
Erzwungene Vollständigkeit
Record
noUncheckedIndexedAccess
Macht jeden Index-Signature-Zugriff zu T | undefined, literale Record-Keys ausgenommen
Faustregel
Bekannte, endliche Keys: Record. Dynamische, unbekannte Keys: Index Signature