Index Signatures vs. Record Types: When to Use Which
AI generated
type
TypeScript · Type System
Index Signatures vs. Record Types: When to Use Which
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.

9 min read TypeScript Type System Mapped Types

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 instance[someString] 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 (Union K) 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 with union K requires every key and rejects unknown ones

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

11. FAQ: Index Signatures vs. Record

1Is Record the same as an index signature?
Structurally, essentially yes. As soon as Record's key type is a plain string, number, or symbol rather than a concrete literal union, the resulting type behaves like a classic index signature: arbitrary extra keys are allowed, completeness is not checked, and noUncheckedIndexedAccess applies to it the same way.
2Why does TypeScript reject an empty object for Record with a union key?
Because Record iterates internally over every single value in the union K and produces a distinct, named property for each one. An empty object does not satisfy those generated properties, so the compiler reports an error, exactly as it would for an interface with several required fields.
3Can I mix an index signature with named properties in the same type?
Yes. TypeScript allows explicitly named properties alongside an index signature, but requires their value type to be compatible with the index signature's value type. A property with an incompatible type causes a compile error.
4What exactly does noUncheckedIndexedAccess do to Record?
Every access through a dynamic or even a literal string key then resolves to T | undefined instead of just T, because Record structurally produces an index signature. That applies whether the access uses bracket notation or dot notation.
5Why doesn't accessing a literal key on Record<'a'|'b', T> yield undefined?
Because the mapped type produces a concrete, named property for each value in the union, not a generic index signature. TypeScript therefore treats access on a known literal key like access on a guaranteed property rather than an unsafe indexed access.
6What is the keyof typeof pattern used for?
It derives a union type from the keys of an existing object literal: typeof yields the concrete object type, and keyof extracts the union of its keys from that. This keeps a Record key type in sync with an existing data source without maintaining the keys twice.
7Do I need as const for keyof typeof?
Not strictly for pure key derivation, since keyof only looks at property names. as const becomes relevant when the object's values should also retain their literal types instead of being widened to generic types like string or number.
8Is Record a built-in TypeScript language feature?
No. Record is a predefined mapped type shipped in lib.es5.d.ts, defined as type Record = { [P in K]: T }. It uses the same mapped type syntax used for custom utility types like Partial or Readonly.
9When is a custom mapped type worth writing instead of Record?
As soon as different keys need different value types, modifiers like readonly or optional need to be controlled per key, or keys need to be renamed via an as clause. Record only covers the case where every key gets the same fixed value type T.
10Which tool fits a translation dictionary loaded from a JSON file?
An index signature, or equivalently Record, since the concrete keys are only known at runtime when the JSON file is loaded, and the compiler cannot check them in advance. Trying to enforce completeness here via a literal union would constantly produce inappropriate compile errors.