Nicht jede Datenstruktur hat ein festes Schema mit benannten Properties. Übersetzungstabellen, Konfigurations-Maps, Caches oder Form-Error-Objekte tragen Keys, die erst zur Laufzeit feststehen — und genau für diese Fälle bietet TypeScript die Index Signature: { [key: string]: T }. Sie öffnet das Schema kontrolliert, ohne den Typ auf any zu degradieren. Dieser Artikel zeigt die Syntax, die Konsistenz-Regeln zwischen Index-Wert und explizit getypten Properties, den Unterschied zu Record<K, V>, das verpflichtende Strict-Flag noUncheckedIndexedAccess sowie Symbol-Keys, Praxis-Patterns für i18n und Form-Errors und die typischen Anti-Patterns, in die man mit zu lockeren Signaturen tappt.
Was eine Index Signature ist
Eine Index Signature beschreibt, welcher Wert-Typ zu lesen ist, wenn auf ein Objekt mit einem beliebigen Key des erlaubten Key-Typs zugegriffen wird. Erlaubt sind als Key-Typ ausschließlich string, number, symbol, Template-Literal-Pattern und Unions ausschließlich dieser. Pro Signature gibt es genau einen Wert-Typ — das ist die wichtigste Einschränkung gegenüber freier Map-Modellierung in JavaScript.
// Beliebige String-Keys, alle Werte sind number
interface ScoreBoard {
[spielerName: string]: number;
}
const punkte: ScoreBoard = {};
punkte["Anna"] = 42; // OK
punkte.Bea = 17; // OK — Property-Notation ist äquivalent
punkte["Carl"] = "x"; // Error: Type 'string' not assignable to 'number'.
// Der Lookup liefert (ohne noUncheckedIndexedAccess) immer den Wert-Typ:
const p = punkte["Dana"]; // p: number — auch wenn "Dana" nie gesetzt wurde!Der Bezeichner zwischen den Klammern (spielerName) ist reine Dokumentation — er ist nicht im Scope sichtbar und kann beliebig benannt werden. Üblich sind kurze Namen wie key, index oder id. Wichtig ist die Semantik: Die Signature sagt nicht „diese Properties existieren", sondern „falls eine Property existiert, hat sie diesen Typ".
String- vs. Number-Index-Signatures
JavaScript hat eine historische Eigenheit: Objekt-Keys sind intern immer Strings (oder Symbols). Schreibt man obj[1], wird der number-Key zu "1" konvertiert, bevor der Lookup passiert. TypeScript respektiert das und erlaubt beide Index-Typen — aber mit einer strengen Konsistenz-Regel.
interface Mixed {
[num: number]: string; // numerischer Lookup
[key: string]: string; // string-Lookup
}
const m: Mixed = { 0: "a", "foo": "b" };
const x = m[0]; // string — number-Index trifft
const y = m["0"]; // string — string-Index trifft (gleiche Property!)Die Regel: Der Wert-Typ der number-Signature muss Subtyp des Wert-Typs der string-Signature sein. Hintergrund: Ein numerischer Lookup wird zur Laufzeit als String-Lookup ausgeführt — wäre der String-Lookup-Typ enger, käme zur Laufzeit ein Wert raus, der nicht in den deklarierten Number-Typ passt.
class Animal { name = ""; }
class Dog extends Animal { breed = ""; }
interface Konsistent {
[n: number]: Dog; // enger
[s: string]: Animal; // weiter — OK, Dog ist Subtyp von Animal
}
interface Inkonsistent {
[n: number]: Animal;
//^^^^^^^^^^^^^^^^^^^^ Error: numeric index type 'Animal' is not
// assignable to string index type 'Dog'.
[s: string]: Dog;
}Praxisrelevant ist die number-Signature fast nur für Array-ähnliche Strukturen. In den meisten Dictionary-Cases reicht [key: string]: T — der Zugriff obj[42] funktioniert dann ebenfalls, weil JS den Number zu "42" konvertiert und die String-Signature greift.
Index Signature mit bekannten Properties
Index Signatures koexistieren mit konkret deklarierten Properties — wenn deren Typ zum Wert-Typ der Signature passt. Das ist keine Schikane, sondern eine logische Folge: obj.foo ist nichts anderes als obj["foo"], also muss foo den Index-Typ erfüllen.
// Schlägt fehl: 'name' ist string, Index verlangt number
interface Bad {
[key: string]: number;
length: number; // OK
name: string;
//^^^^ Error: Property 'name' of type 'string' is not assignable
// to 'string' index type 'number'.
}
// Lösung: Union als Index-Wert-Typ
interface Good {
[key: string]: number | string;
length: number; // OK
name: string; // OK
}Das wirkt restriktiv, ist aber genau richtig: Wer eine Index Signature deklariert, verspricht, dass jeder Lookup diesen Typ liefert. Ausnahmen würden das Versprechen brechen. In der Praxis ist das oft der Punkt, an dem man merkt: Vielleicht ist die Index Signature gar nicht das passende Modell, sondern ein Discriminated Union oder eine konkrete Property-Liste wäre ehrlicher.
Index Signature vs. Record<K, V>
Record<K, V> ist ein Utility-Type, der konzeptionell dasselbe macht — aber generisch und mit einem entscheidenden Mehrwert: Bei einer Union als Key erzwingt der Compiler Vollständigkeit.
// Index Signature: Keys sind beliebige Strings
interface Dict { [key: string]: number; }
const a: Dict = {}; // OK — leer
// Record mit String: praktisch äquivalent
type Dict2 = Record<string, number>;
const b: Dict2 = {}; // OK — leer
// Record mit Literal-Union: ALLE Keys verpflichtend
type Ampel = Record<"rot" | "gelb" | "gruen", number>;
const c: Ampel = { rot: 5, gelb: 1 };
// ^ Error: Property 'gruen' is missing.
const d: Ampel = { rot: 5, gelb: 1, gruen: 3 }; // OKFaustregel:
| Situation | Empfehlung |
|---|---|
| Keys sind eine bekannte, endliche Menge | Record<"a" | "b" | "c", T> — Vollständigkeit wird geprüft |
| Keys sind dynamisch und unbekannt | Index Signature { [key: string]: T } oder Record<string, T> |
Map-ähnliches Verhalten zur Laufzeit (Iteration, delete, Größe) | Echte Map<K, V> — kein Plain-Object |
| Schema kombiniert bekannte + dynamische Keys | Interface mit expliziten Properties und Index Signature |
Intern verhalten sich Record<string, T> und { [key: string]: T } weitgehend gleich. Der Hauptunterschied ist die Lesbarkeit und die Fähigkeit von Record, Literal-Unions als Pflicht-Keys zu modellieren.
noUncheckedIndexedAccess — der sichere Lookup
Ohne dieses Flag lügt TypeScript bei jedem Index-Lookup: Der Compiler tut so, als wäre die Property garantiert vorhanden, obwohl die Index Signature nur Wert-Typen möglicher Properties beschreibt. Das ist die häufigste Quelle für Off-by-One- und Typo-Bugs in TypeScript-Code mit Dictionaries.
// tsconfig: "noUncheckedIndexedAccess": false (Default)
interface Env { [key: string]: string; NODE_ENV: string; }
declare const env: Env;
const a = env.NODE_ENV; // string
const b = env.UNKNOWN_VAR; // string <- LUEGT! Kann undefined sein.
const len = b.length; // Compiler-OK, Laufzeit: TypeErrorMit "noUncheckedIndexedAccess": true macht der Compiler jeden Lookup, der nicht explizit deklariert ist, automatisch zu T | undefined. Das zwingt zur Behandlung — meist via ??, Optional Chaining oder Type Guard.
// tsconfig: "noUncheckedIndexedAccess": true
const a = env.NODE_ENV; // string — explizit deklariert, KEIN undefined
const b = env.UNKNOWN_VAR; // string | undefined — durch Index Signature
const len1 = b.length; // Error: Object is possibly 'undefined'.
const len2 = b?.length; // OK — number | undefined
const len3 = (b ?? "").length; // OK — numberDas Flag wirkt auch auf Arrays: arr[3] ist mit dem Flag T | undefined, weil der Index zur Laufzeit außerhalb der Länge liegen kann. Das beendet eine ganze Klasse von Bugs:
const xs: number[] = [10, 20];
// Ohne Flag: xs[5] ist number — lügt.
// Mit Flag: xs[5] ist number | undefined — ehrlich.
const x = xs[5];
// for-Schleife: i ist garantiert in-bounds, aber TS weiß das nicht.
for (let i = 0; i < xs.length; i++) {
const n: number = xs[i]; // Mit Flag: Error — xs[i] ist number | undefined
}
// Praxis: for-of statt for-i, oder nicht-null-Assertion an dieser Stelle:
for (const n of xs) { /* n: number */ }Empfehlung: Für neue Projekte aktivieren. Der Migrationsaufwand in Bestandscode ist nicht trivial, weil viele bestehende For-i-Loops und Index-Zugriffe Anpassungen brauchen — der Gewinn an Robustheit ist es aber praktisch immer wert.
Symbol als Index-Key
Seit TypeScript 4.4 sind auch Symbols als Index-Key erlaubt. Das ist relevant für Bibliotheken, die Symbol-Properties als private oder kollisionsfreie Marker verwenden (z. B. React-internals, RxJS, Iterator-Protokolle).
interface Tagged {
[tag: symbol]: string;
}
const KIND = Symbol("kind");
const ID = Symbol("id");
const obj: Tagged = {
[KIND]: "user",
[ID]: "u-42",
};
const v = obj[KIND]; // stringSymbol-, String- und Number-Signatures können gleichzeitig existieren — jede mit eigenem Wert-Typ, solange die String/Number-Konsistenz-Regel aus Abschnitt 02 erfüllt bleibt. Symbols sind davon unabhängig, weil sie nicht zu Strings konvertiert werden.
Praxis: Dictionary für i18n
Ein klassischer Use-Case: Übersetzungs-Maps. Pro Sprache ein Objekt mit String-Keys, optional erweitert um bekannte Pflicht-Keys für häufig genutzte Strings.
// Variante A: rein dynamisch — alle Keys optional
interface Translations {
[key: string]: string;
}
const de: Translations = {
greeting: "Hallo",
farewell: "Tschüss",
login: "Anmelden",
};
// Mit noUncheckedIndexedAccess: t(key) muss Fallback haben
function t(key: string): string {
return de[key] ?? `[?${key}]`; // sichtbarer Marker statt undefined
}
// Variante B: Pflicht-Keys + freie Erweiterung
interface CoreTranslations {
greeting: string; // garantiert vorhanden
farewell: string; // garantiert vorhanden
[key: string]: string; // beliebige zusätzliche Strings
}
const en: CoreTranslations = {
greeting: "Hello",
farewell: "Bye",
customCta: "Sign up", // OK — Index Signature
};
en.greeting; // string — nicht undefined (deklariert!)
en.unknown; // string | undefined (mit Flag)Variante B ist oft die ehrlichste Form: Sie sichert die Kern-Strings vertraglich ab und lässt Spielraum für Modul-spezifische Erweiterungen. Wer garantierte Vollständigkeit über alle Sprachen will, sollte stattdessen Record<TranslationKey, string> mit einer Literal-Union aller Keys verwenden — dann wird jeder fehlende String zum Compile-Fehler.
Praxis: Form-Errors
Bei Formularen entsteht häufig eine Map Feldname → Fehlertext. Hier zahlt sich noUncheckedIndexedAccess besonders aus: Der Zugriff auf einen Feld-Error ist von Natur aus optional („vielleicht ist dieses Feld fehlerfrei").
type FormErrors = { [field: string]: string };
function validate(values: { email: string; password: string }): FormErrors {
const errors: FormErrors = {};
if (!values.email.includes("@")) errors.email = "Ungültige E-Mail.";
if (values.password.length < 8) errors.password = "Mind. 8 Zeichen.";
return errors;
}
const errors = validate({ email: "x", password: "1234" });
// Mit noUncheckedIndexedAccess: errors.email ist string | undefined
if (errors.email) {
console.log(errors.email.toUpperCase()); // OK — narrowed auf string
}
// Render-Helfer mit Default
const emailMsg = errors.email ?? ""; // string
const pwMsg = errors.password ?? ""; // stringOhne das Strict-Flag wäre errors.email fälschlich string — und ein .toUpperCase() direkt darauf wuerde mit TypeError: Cannot read properties of undefined (reading 'toUpperCase') zur Laufzeit knallen, sobald das Feld fehlerfrei ist. Das ist exakt das Bug-Pattern, das die Index Signature in der losen Variante traditionell verursacht hat.
Anti-Pattern: Index Signature als Fallback
Wenn das Schema „eigentlich strukturiert, aber halt manchmal kommt da noch was rein" ist, wird die Index Signature gerne als Notausgang missbraucht. Das deaktiviert große Teile der Typprüfung und ist meist ein Symptom dafür, dass das Modell falsch gewählt wurde.
// Schlecht — Index Signature als Catch-All
interface User {
id: string;
email: string;
[extra: string]: unknown; // "vielleicht hat das Backend noch Felder"
}
const u: User = { id: "1", email: "a@b", typo: 42, foo: "x" };
u.idd; // unknown — Typo wird nicht erkannt!
u.role; // unknown — gibt es nicht, Compiler sagt nichtsBesser: Schema vollständig modellieren, optional via Union erweitern oder unbekannte Backend-Felder bewusst ignorieren statt im Haupttyp zu fuehren:
// Gut — strenges Schema, unbekannte Felder bleiben außen vor
interface User {
id: string;
email: string;
role?: "admin" | "user";
}
// Wenn dynamische Erweiterungen wirklich nötig sind:
interface UserWithMeta {
id: string;
email: string;
meta: { [key: string]: string }; // eingegrenzt auf ein Feld
}Eingegrenzte Dynamik (eigenes meta-Feld) trennt das stabile Schema sichtbar von den lockeren Daten — beim Lesen, beim Testen und beim Refactoring.
keyof auf Index Signatures
keyof liefert die Menge aller erlaubten Keys eines Typs. Bei Interfaces mit konkreten Properties ist das Ergebnis eine Literal-Union. Bei reinen Index Signatures dagegen ergibt sich der Index-Key-Typ selbst — und an dieser Stelle wird die JS-Eigenheit aus Abschnitt 02 sichtbar.
type K1 = keyof { a: 1; b: 2 };
// ^? "a" | "b"
type K2 = keyof { [key: string]: number };
// ^? string | number <- string UND number!
type K3 = keyof { [key: number]: number };
// ^? number
type K4 = keyof Record<"x" | "y", number>;
// ^? "x" | "y"Das string | number bei K2 ist kein Bug — es spiegelt, dass obj[1] und obj["1"] denselben Slot treffen. Wer eine generische Funktion über alle Keys eines Dictionaries schreiben will, stößt genau hier auf die Krux: Eine als keyof T typisierte Variable lässt sich nicht ohne weiteres als String verwenden, weil sie zusätzlich number umfasst.
function logKeys<T extends object>(obj: T) {
for (const k in obj) {
// k ist string (for-in liefert immer string), nicht keyof T
console.log(k, (obj as Record<string, unknown>)[k]);
}
}
// Strenger Helper mit Object.keys
function entriesOf<V>(obj: Record<string, V>): [string, V][] {
return Object.keys(obj).map(k => [k, obj[k] as V]);
}Merksatz: Object.keys(obj) liefert immer string[], nicht (keyof T)[] — auch dann nicht, wenn das Schema konkret ist. Das ist Absicht: Zur Laufzeit können Properties existieren, die im statischen Typ nicht stehen (z. B. via Prototype, via Object.assign mit fremdem Objekt). Wer eine engere Typisierung braucht, muss explizit casten und übernimmt die Verantwortung.
Häufige Stolperfallen
Index Signature ist kein Freifahrtschein — sie verwässert das Typ-System.
Sobald eine Index Signature im Interface steht, kann der Compiler bei jedem unbekannten Property-Zugriff nur noch "sie passt zum Index-Typ" prüfen, nicht aber "diese Property ist sinnvoll im Modell". Tippfehler wie obj.usre bleiben unbemerkt, weil die Signature die fehlende Property still abdeckt. Vor jedem [key: string]: T sollte daher die Frage stehen: Sind die Keys wirklich dynamisch — oder wäre eine konkrete Property-Liste ehrlicher?
obj[key] ohne noUncheckedIndexedAccess lügt.
Der Default-Lookup auf eine Index Signature liefert den deklarierten Wert-Typ — auch wenn der Key nicht existiert. arr[999] oder dict.tippfeler sind dann Compile-OK, knallen aber zur Laufzeit. Das Flag noUncheckedIndexedAccess macht solche Zugriffe ehrlich (T | undefined) und erzwingt Behandlung — eine der wirkungsvollsten Strict-Optionen überhaupt.
Numerische Keys werden zu Strings — eine JS-Eigenheit, keine TS-Sache.
obj[1] und obj["1"] greifen auf denselben Slot zu. TypeScript modelliert das, indem es die number-Index-Signature an die string-Variante koppelt (Subtyp-Regel). Echte number-Keys gibt es nur bei Map<number, V>; ein Plain-Object hat sie nicht.
Explizite Properties müssen zum Index-Wert-Typ kompatibel sein.
Steht [key: string]: number im Interface, darf keine deklarierte Property einen anderen Typ tragen — denn obj.foo ist gleichbedeutend mit obj["foo"]. Die üblichen Workarounds: den Index-Typ als Union deklarieren (number | string) oder die Index Signature aufgeben.
Record vs. Index Signature — Vollständigkeit ist der Unterschied.
Record<string, T> verhält sich praktisch wie { [key: string]: T }. Der echte Mehrwert von Record liegt bei Literal-Unions als Key: Record<"a" | "b", T> erzwingt, dass a und b beide gesetzt sind. Bei dynamischen Keys ist der Unterschied vor allem stilistisch.
keyof auf Index Signatures verhält sich asymmetrisch.
keyof Record<"a" | "b", T> ist "a" | "b". keyof { [key: string]: T } dagegen ist string | number — weil JS numerische Keys zu Strings konvertiert und beide Lookup-Formen äquivalent sind. In generischem Code muss man oft keyof T & string einsetzen, um die Number-Variante auszublenden.
delete obj[key] ist typsicher, aber Runtime-relevant.
TypeScript akzeptiert das delete auf einer Index Signature ohne weiteres. Zur Laufzeit verwirft V8 dabei die Hidden-Class-Optimierung, was bei häufig veränderten Dictionaries sichtbar Performance kostet. Bei wirklich großen oder iterations-intensiven Maps ist eine echte Map<K, V> der bessere Container — auch typseitig (kein undefined bei Lookups, klar getrenntes has/get).
Mehrere Index Signatures müssen konsistente Wert-Typen tragen.
Wer parallel [n: number]: A und [s: string]: B deklariert, muss A als Subtyp von B halten — sonst meldet der Compiler einen Konflikt. Symbol-Signatures sind unabhängig und können einen eigenen Wert-Typ tragen.
readonly macht den Lookup mutation-sicher.
readonly [key: string]: T verbietet obj[k] = v und schützt vor versehentlichen Schreibvorgängen — wichtig für Konfigurations-Objekte, Frozen-State (Redux/Zustand) und Funktions-Parameter, die nicht modifiziert werden sollen. Das Schlüsselwort wirkt nur statisch; Object.freeze ist die Laufzeit-Variante.
JSON.parse-Ergebnisse sind selten ein Fall für Index Signature.
Wer JSON.parse(input) aufruft, bekommt any zurück — eine Index Signature darauf zu stülpen, simuliert Sicherheit, die nicht da ist. Besser: das Ergebnis als unknown typisieren und mit einem Validator (Zod, Valibot, ArkType) gegen ein echtes Schema prüfen. Erst nach der Validierung steht ein präzises, statisch überprüfbares Modell zur Verfügung.
Weiterführende Ressourcen
Externe Quellen
- Index Signatures – TypeScript Handbook
- noUncheckedIndexedAccess – tsconfig
- Record Utility – TypeScript Handbook