Open keys or enforced completeness: two tools, two fundamentally different guarantees
Both constructs type objects that hold several values of the same shape, yet they hand the compiler completely different promises. Treating { [key: string]: T } and Record<K, V> as interchangeable results in either overly loose types that wave through every typo, or overly strict types that break against runtime reality. This article walks through syntax, semantics, and a concrete decision rule for everyday work.
Table of Contents
- 1. Two Ways to Type an Object
- 2. Index Signatures: Syntax and Semantics
- 3. Record
: The Mapped Type from lib.es5.d.ts - 4. The Core Difference: Open Keys vs. Enforced Completeness
- 5. The keyof typeof Pattern
- 6. noUncheckedIndexedAccess: Safety on Index Access
- 7. Mapped Types: Record as a Special Case
- 8. Rule of Thumb: Which Tool, When?
- 9. Best Practices and Conclusion
- 10. Summary
- 11. FAQ
1. Two Ways to Type an Object
As soon as an object has more than a handful of fixed, named properties, or its keys are only known at runtime, a plain interface listing each field individually stops being enough. TypeScript offers two distinct tools for that situation: the index signature and the mapped type Record<K, V>. Both let you assign a value type T to an entire class of keys instead of naming every property one by one.
The decisive difference is not syntax, which looks superficially similar, but semantics: an index signature describes an open set of keys, where additional keys the compiler has never seen are silently allowed. A Record with a concrete union type as its key describes a closed set instead, where every single key must be present and unknown keys are rejected.
That distinction sounds academic at first, but it has very concrete consequences for bugs: picking the wrong construct either lets typos in property names slip through unnoticed, or forces a codebase into unnecessarily strict completeness requirements where flexibility was actually needed.
// Superficially similar, semantically very different
type Dictionary = { [key: string]: string }; // open keys
type StatusLabels = Record<"open" | "closed" | "pending", string>; // closed keys
const d: Dictionary = { hello: "Hello", anything: "ok" }; // any key allowed
const s: StatusLabels = { open: "Open", closed: "Closed", pending: "Pending" };
// s without "pending" would be a compile error, d.anyKey is always allowed
2. Index Signatures: Syntax and Semantics
The classic index signature { [key: string]: T } tells the compiler that an object may hold arbitrarily many properties whose names are of type string and whose values all match type T. Similarly, [index: number]: T allows numeric indices, the kind array-like structures need. Worth noting: in JavaScript object keys are internally always treated as strings, so a numeric index signature is a stricter, additional view on the same underlying mechanism, not a fundamentally different storage form.
Since TypeScript 4.4, index signatures are no longer limited to string and number. Template literal types and unions built from them are also permitted, plus symbol as a key type. That makes it possible to precisely type, for example, every key that starts with a given prefix, while other key shapes are still rejected.
One notable detail: multiple index signatures may coexist within a single object type, as long as their value types are compatible with one another, for example a string signature next to a number signature. Additional, explicitly named properties must match the value type of the applicable index signature, otherwise the compiler reports an error.
// string index signature
interface Translations {
[key: string]: string;
}
// number index signature (typically for array-like structures)
interface SparseArray {
[index: number]: string;
}
// Since TS 4.4: template literal and union index signatures
type CssVariables = {
[key: `--${string}`]: string; // only custom properties like "--primary-color"
};
type EventKeys = {
[key: `on${string}`]: (event: Event) => void; // only "onClick", "onSubmit", etc.
};
// symbol as a key type
type SymbolIndexed = {
[key: symbol]: unknown;
};
const vars: CssVariables = { "--primary-color": "#0ea5e9" }; // ok
// const bad: CssVariables = { color: "red" }; // error: "color" doesn't match the pattern
3. Record: The Mapped Type from lib.es5.d.ts
Record<K, V> is not a built-in language feature in the strict sense, but a mapped type shipped in lib.es5.d.ts. Its definition is remarkably compact: type Record<K extends keyof any, T> = { [P in K]: T }. The compiler iterates over every key in the union K and produces a distinct, named property of type T for each one.
The constraint K extends keyof any means that K must be a string, number, or symbol, since those are exactly the three types JavaScript allows as object keys. In practice, K is almost always a literal union type like "open" | "closed" | "pending", or a type derived via keyof.
Because Record iterates internally via [P in K], every value in the union K results in a concrete, named property, not a generic index signature. That is the technical reason why Record and index signatures behave differently under completeness checking and under noUncheckedIndexedAccess, as the following sections show.
// Simplified definition from lib.es5.d.ts
type Record<K extends keyof any, T> = {
[P in K]: T;
};
// Typical use: a known, finite set of keys
type FeatureFlags = Record<"darkMode" | "betaCheckout" | "newSearch", boolean>;
const flags: FeatureFlags = {
darkMode: true,
betaCheckout: false,
newSearch: true,
// every key in the union MUST be present
};
// Record<string, T> is also valid, but then behaves essentially
// like an index signature (see the next section)
type LooseDictionary = Record<string, number>;
4. The Core Difference: Open Keys vs. Enforced Completeness
The most practically important difference appears as soon as K in Record<K, V> is a concrete literal union type, such as "a" | "b" | "c": the compiler then enforces completeness. Every single key in the union must appear in the object literal, or the assignment fails. At the same time, TypeScript rejects any key that is not part of the union, a classic case of an excess property check combined with the structure the mapped type produces.
An index signature behaves in exactly the opposite way: it only describes an upper bound on allowed keys and their value type, but never enforces that any particular key actually be present. An empty object {}, for instance, is valid for { [key: string]: string }, whereas the same empty object is rejected for Record<"a" | "b", string>, since neither a nor b is present.
Using Record<string, T> instead of a concrete union makes that distinction largely disappear: string as a key type is not itself a finite, enumerable set, so TypeScript cannot enforce completeness here. Record<string, T> behaves structurally almost identically to { [key: string]: T }, which is exactly why many codebases prefer Record<string, T> as a more readable alternative to the classic index signature syntax.
type OpenDict = { [key: string]: number };
type ClosedRecord = Record<"a" | "b", number>;
const open1: OpenDict = {}; // ok, no keys required
const open2: OpenDict = { anything: 1 }; // ok, unknown key allowed
// const closed1: ClosedRecord = {}; // error: "a" and "b" are missing
const closed2: ClosedRecord = { a: 1, b: 2 }; // ok, complete
// const closed3: ClosedRecord = { a: 1, b: 2, c: 3 }; // error: "c" is unknown
// Record<string, T> approaches the index signature again
type NearlyOpen = Record<string, number>;
const near: NearlyOpen = {}; // ok, string is not a finite set
5. The keyof typeof Pattern
In practice, a key set often already exists as a concrete object literal, such as a configuration or an enum-like object, before a matching type is even written. Instead of maintaining the union of keys manually and redundantly, it can be derived directly from the object using the keyof typeof pattern: typeof obj yields the concrete object type with all literal property names, and keyof extracts the union of those names from it.
This pattern is particularly valuable for keeping a Record key type in sync with an already existing data source. If the source object changes, say a new status is added, the derived union type updates automatically, with no need to keep two places in the code in manual sync.
One thing to keep in mind: as const on the source object matters when the values themselves should also retain their literal types, but for pure key derivation via keyof typeof it is not strictly required, since keyof only ever looks at property names, not their value types.
// Starting point: an object literal defines the source of truth
const orderStatus = {
pending: "Pending",
shipped: "Shipped",
delivered: "Delivered",
cancelled: "Cancelled",
} as const;
// keyof typeof derives the union of keys automatically
type OrderStatusKey = keyof typeof orderStatus;
// equals: "pending" | "shipped" | "delivered" | "cancelled"
// A matching Record can be built that necessarily stays
// in sync with the source object
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: Safety on Index Access
By default, TypeScript treats access through an index signature as though the key were guaranteed to exist: dict[someKey] resolves to type T, even though at runtime the result may well be undefined if the key is actually missing. The compiler flag noUncheckedIndexedAccess closes exactly that gap: when enabled, every access through an index signature resolves to T | undefined instead of plain T, forcing the code to explicitly handle the undefined case.
The key point to understand: noUncheckedIndexedAccess affects every access through an index signature, regardless of whether it was written with the classic { [key: string]: T } syntax or with Record<string, T>, since both produce structurally the same index signature. Access such as Record becomes T | undefined just like access on an object with a classic index signature.
It behaves differently for Record with a concrete literal union as its key: accessing a known, literal key such as statusColors.pending or statusColors["pending"] is treated by TypeScript as access on a named, guaranteed property, not as an index signature access. The type therefore stays T here, even with noUncheckedIndexedAccess enabled, because the mapped type structurally produces a concrete property rather than a generic index signature. Indexing with a non-literal variable of type OrderStatusKey, such as statusColors[variableKey], however, brings the same safeguard back into play, since TypeScript conservatively treats an indexed access on a union type as potentially unsafe.
// tsconfig.json: { "compilerOptions": { "noUncheckedIndexedAccess": true } }
const dict: { [key: string]: number } = { a: 1, b: 2 };
const v1 = dict["a"]; // type: number | undefined
const recordLoose: Record<string, number> = { a: 1, b: 2 };
const v2 = recordLoose["a"]; // type: number | undefined, same behavior as above
const recordStrict: Record<"a" | "b", number> = { a: 1, b: 2 };
const v3 = recordStrict.a; // type: number, literal key = named property
const v4 = recordStrict["a"]; // type: number, same behavior
function readDynamic(key: "a" | "b") {
return recordStrict[key]; // type: number, key is narrowed to the union
}
7. Mapped Types: Record as a Special Case
Record is ultimately just the simplest, pre-built expression of a more general concept: the mapped type. The syntax { [P in K]: T } can be extended arbitrarily, with readonly and optional modifiers, with value types that depend on the respective key P itself, or with key remapping via the as clause, introduced in TypeScript 4.1.
While Record<K, T> assigns the same fixed type T to every key, a hand-written mapped type can compute the value type per key individually, for example with { [P in K]: SomeType[P] } to derive it from an existing type, which is exactly what utility types like Partial, Readonly, or Pick do internally. These utility types are themselves nothing more than specialized mapped types that use the same underlying mechanism as Record, just with different modifiers and source expressions.
In practice this means: once a plain Record<K, T> is no longer enough, because different keys need different value types or keys need to be renamed, the next step is not a break with the existing model, but simply writing out the same mapped type syntax with a few extra capabilities.
// Record is the simplest form of a mapped type
type Simple = Record<"a" | "b", number>;
// is exactly equal to:
type SimpleExplicit = { [P in "a" | "b"]: number };
// Mapped types can do more: readonly, optional, key remapping (TS 4.1+)
type ReadonlyPartial<K extends string, T> = {
readonly [P in K]?: T;
};
// Deriving the value type per key individually (what utility types do internally)
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 with the "as" clause
type Getters<T> = {
[P in keyof T as `get${Capitalize<string & P>}`]: () => T[P];
};
// yields for Product: { getName: () => string; getPrice: () => number }
8. Rule of Thumb: Which Tool, When?
The practical decision boils down to a single question: is the set of keys known and finite at compile time, or only at runtime? For a dynamic, compile-time unknown key set, such as a translation dictionary loaded from a JSON file, a cache with arbitrary cache keys, or the query parameters of a URL, an index signature or Record<string, T> is the right tool. The compiler cannot meaningfully check completeness here anyway, since the keys simply do not exist in the type system.
For a known, finite key set, such as a status enum, the values in a feature flag system, the allowed HTTP methods, or configuration options per feature, Record<K, V> with a concrete literal union is the better choice. The decisive win is completeness checking: if a developer forgets a status while building a new configuration object, the compiler flags it immediately, instead of it surfacing later as a missing entry at runtime.
A useful sanity check in borderline cases: would a new, unknown key showing up in code more likely be read as a feature (more translations, more cache entries) or as a bug (a status missing from the enum)? In the first case the open index signature fits, in the second the closed Record does.
// Dynamic, unknown keys: translations from a JSON file
type Translations = { [key: string]: string };
// or equivalently: Record<string, string>
async function loadTranslations(locale: string): Promise<Translations> {
const res = await fetch(`/i18n/${locale}.json`);
return res.json();
}
// Known, finite keys: feature flags per feature
type FeatureFlags = Record<"darkMode" | "newCheckout" | "betaSearch", boolean>;
const flags: FeatureFlags = {
darkMode: true,
newCheckout: false,
betaSearch: true,
// a forgotten flag would immediately be a compile error
};
9. Best Practices and Conclusion
A common compromise in mature codebases is Record<string, T> as a more readable, consistent alternative to the classic index signature syntax, without losing anything semantically, since both forms are structurally equivalent. For new configuration objects, status mappings, and similar cases, reaching for a concrete literal union via Record is almost always worth it, since completeness checking catches an entire class of bugs already at compile time.
noUncheckedIndexedAccess should be enabled in every new TypeScript project as soon as index signatures or Record<string, T> appear anywhere, because without the flag, potential undefined accesses hide behind a seemingly safe type T. The checks it forces, whether via optional chaining or explicit existence checks, are not overhead, they make implicit assumptions about data completeness visible in the code.
In short: index signatures model uncertainty about the key set honestly, and Record with a concrete union type models certainty and enforces it. Applying both tools according to this guideline, rather than treating them as interchangeable, buys real bug prevention exactly where it counts, at compile time, and loses no expressiveness where genuine flexibility is required.
// Rule of thumb summarized as a code comment
// 1. Keys dynamic/unknown at compile time -> index signature / Record<string, T>
// 2. Keys known/finite -> Record<"a" | "b" | "c", T> (enforces completeness)
// 3. Enable noUncheckedIndexedAccess ALWAYS once index signatures are involved
// 4. Need a different value type per key -> write a custom mapped type
interface TsConfigExcerpt {
compilerOptions: {
strict: true;
noUncheckedIndexedAccess: true;
};
}
| Trait | Index Signature | Record |
Typical Use |
|---|---|---|---|
| Unknown extra keys | Allowed, accepted silently | Rejected, compile error | Dictionary vs. enum mapping |
| Completeness enforced | No, empty object is valid | Yes, every union key is required | Feature flags per feature |
| Access under noUncheckedIndexedAccess | Always T | undefined | T for a literal key, T | undefined for a union variable | Safe access to caches |
| Origin in the type system | Built-in language feature | Mapped type from lib.es5.d.ts | Both available since early TS versions |
| Generalization | None, fixed base form | Special case of { [P in K]: T } | Custom mapped types with an as clause |
| Fits keyof typeof | Rarely useful | Ideal, union derived directly from an object | Status mappings from configuration |
Mironsoft
TypeScript migration, type safety, and team onboarding
A JavaScript codebase without type safety, but no time for a full migration?
We migrate existing JavaScript projects to TypeScript step by step, set up strict compiler settings cleanly, and bring teams to the same type-safety level with code reviews and style guides.
Migration Roadmap
Plan and execute a gradual JS-to-TS migration without big-bang risk.
Strict Mode Rollout
Set up tsconfig.json, ESLint rules, and CI checks for lasting type safety.
Team Onboarding
Bring developers up to speed on TypeScript best practices with workshops and reviews.
10. Summary
Index Signatures vs. Record
Open keys
Index signatures silently accept any additional string keys
Enforced completeness
Record
noUncheckedIndexedAccess
Turns every index signature access into T | undefined, literal Record keys excepted
Rule of thumb
Known, finite keys: Record. Dynamic, unknown keys: index signature