skip to content

In TypeScript, why does a lookup object typed `Record<Status, string>` — where `Status` is a union of string literals — start failing to compile when a new member is added to `Status`, while `Record<string, string>` keeps compiling?

level: middleimportance: must knowfreq 62%

answer

  1. finite key set versus open key set
  2. one required property per union member
  3. the build break is the feature
  4. an index signature requires nothing
  5. assertions and Partial both switch it off

basics

~20 s

A union of literals is a finite key set, so Record generates one required property per member. Adding a member generates one more required property, and every existing lookup object is now missing it. Record<string, string> is an open index signature that requires no key at all, so nothing can be missing.

solid answer

~50 s

`Record<K, V>` expands to `{ [P in K]: V }`. When `K` is a union of string literals the compiler knows every key by name, so it emits one **required** property per member — `Record<'draft' | 'live', string>` really is `{ draft: string; live: string }`. Add `'archived'` to the union and the generated type grows a third required property, so every object literal annotated with it now reports "Property 'archived' is missing". That build break is the point: the type is doing exhaustiveness enforcement for you, and it fires at every table in the codebase at once. `Record<string, string>` has no finite key set, so it expands to an index signature where nothing is required and nothing is forbidden — the compiler has no way to notice a case you forgot. That is the whole reason to spend a literal union as the key type rather than `string`.

code

typescript · 20 lines
typescript
type Status = 'draft' | 'live' | 'archived';

// Annotated: a missing member is a compile error
const labels: Record<Status, string> = {
  draft: 'Draft',
  live: 'Live',
  archived: 'Archived',
};

// satisfies: still exhaustive, but values keep their literal types
const routes = {
  draft: '/drafts',
  live: '/live',
  archived: '/archive',
} satisfies Record<Status, string>;

const r: '/drafts' = routes.draft;

// Open key type: nothing is ever reported as missing
const loose: Record<string, string> = { draft: 'Draft' };

go deeper

for a junior

Know that a Record keyed by a union of literals requires an entry for every member, so forgetting one is a compile error, and that a string-keyed Record requires nothing at all.

for a middle

Derive the behaviour from the definition: the mapped type generates one required property per union member. Explain that adding a member breaks every table at once, and that extra keys are rejected too.

for a senior

Show that you use this deliberately as a change-detection mechanism, and name the ways it gets switched off in practice — an assertion on the literal, a Partial wrapper, a key type that widened to string.

for a principal

Own the call about where compile-time exhaustiveness earns its keep versus a runtime registry: which key sets are stable enough to encode, and how much build breakage across teams a union edit should be allowed to cause.

## The mechanism `Record` is defined as `type Record<K extends keyof any, T> = { [P in K]: T }`. The mapped type iterates the key type. What the compiler can do with that depends on whether `K` is *finite*: ```ts type Status = 'draft' | 'live'; type Labels = Record<Status, string>; // expands to { draft: string; live: string } type Loose = Record<string, string>; // expands to { [x: string]: string } ``` A union of literals is finite, so each member becomes a named property. The mapped type adds no `?`, so each one is **required**. That single fact produces the exhaustiveness behaviour. ## What happens when the union grows ```ts type Status = 'draft' | 'live' | 'archived'; // one member added const labels: Record<Status, string> = { draft: 'Draft', live: 'Live', }; // Error: Property 'archived' is missing in type // '{ draft: string; live: string; }' but required in type 'Record<Status, string>' ``` The compiler flags **every** site annotated with `Record<Status, ...>` — the label table, the colour table, the handler map, the icon map. A single edit to the union produces a work list of exactly the places that need a decision. This is the type-level analogue of a switch that fails when a case is unhandled, and it is why teams reach for a union key deliberately rather than settling for `string`. The open version cannot do this: ```ts const loose: Record<string, string> = { draft: 'Draft', live: 'Live', }; // still compiles after 'archived' is added — nothing was ever required ``` An index signature is a statement about which keys are *permitted*, not which are *present*. There is no key the compiler could name as missing. ## The other half: keys you cannot invent Exhaustiveness has a mirror image. Because the generated type lists the legal property names, an object literal with a key outside the union is rejected by the excess-property check: ```ts const labels: Record<Status, string> = { draft: 'Draft', live: 'Live', archived: 'Archived', deleted: 'Deleted', // Error: 'deleted' does not exist in type 'Record<Status, string>' }; ``` So a literal-keyed `Record` catches both directions of drift: a member you forgot to handle and a stale key left behind after a member was removed. `Record<string, string>` catches neither. ## Ways to lose the guarantee by accident **A type assertion.** `as` is not a check; it only asks whether the two types are comparable in either direction. Since a three-property `Record<Status, string>` is assignable to a two-property literal type, the assertion is permitted and the missing key goes unreported: ```ts const labels = { draft: 'Draft', live: 'Live' } as Record<Status, string>; // no error ``` Annotate the variable instead of asserting the literal — that is the difference between the compiler checking your object and you telling it not to bother. **Wrapping in `Partial`.** `Partial<Record<Status, string>>` adds `?` to every generated property. That is the right type for a genuinely sparse map, but it discards exhaustiveness entirely: nothing is required any more, and each read becomes `string | undefined`. Choose it because the map really is optional, never to silence a missing-key error. **Widening the key.** `Record<string, string>`, or a key type that is really `string` after inference, gives you the open index signature again. ## Keeping the literal value types A plain annotation checks the object but also widens what you read out of it. If you want both the exhaustiveness check *and* the narrow value types of the literal you wrote, use `satisfies`: ```ts const routes = { draft: '/drafts', live: '/live', archived: '/archive', } satisfies Record<Status, string>; // routes.draft is '/drafts', not string — and a missing Status member is still an error ``` `satisfies` checks the expression against the type without changing the expression's inferred type, so you keep the literal types while the missing-key error still fires. ## Where the key set comes from The key union does not have to be hand-written. `Record<keyof Config, Validator>` forces a validator for every field of `Config`; a string enum works the same way, since its members are a finite set. Deriving the key set from the type that actually changes is what makes the guarantee self-maintaining — nobody has to remember to update a second list. ## What it does not do All of this is compile time. The generated type is erased, so a `Record<Status, string>` built from parsed JSON has whatever keys the JSON had; the annotation asserts completeness rather than establishing it. When the data crosses a runtime boundary, the exhaustiveness argument only applies to tables you write in source.

  • Why does `{ draft: 'D', live: 'L' } as Record<Status, string>` not report the missing member?
    Because `as` is an assertion, not a check. TypeScript permits it whenever either type is assignable to the other, and the fuller `Record` type is assignable to the two-property literal type, so the comparison succeeds. No missing-property check ever runs. Annotate the variable — `const labels: Record<Status, string> = { … }` — so the object is checked against the type instead.
  • How do you keep the exhaustiveness check but avoid widening the values to `string`?
    Use `satisfies` instead of an annotation: `const routes = { … } satisfies Record<Status, string>`. The expression is checked against the type, so a missing `Status` member is still an error, but the variable keeps its inferred literal types — `routes.draft` stays `'/drafts'` rather than widening to `string`.
  • When is `Partial<Record<Status, T>>` the right type rather than a mistake?
    When the map is legitimately sparse — an override table, a per-status customisation where most statuses use a default. It is a mistake when it is reached for to silence a missing-key error, because it turns off the exact guarantee the union key was bought for and makes every read `T | undefined` at the same time.
  • Can the key union be derived rather than written out?
    Yes, and that is usually better. `Record<keyof Config, Validator>` forces one validator per field of `Config`, and a string enum's members work the same way. Deriving the key set means the single source of truth is the type that actually changes, so nobody has to remember to update a parallel list.

saying these in an interview costs you the question

  • Claiming Record<string, T> also catches a forgotten key
  • Using an assertion to silence the missing-property error
  • Reaching for Partial just to make the error go away
  • Thinking the check happens at runtime on the data
  • Believing extra keys are allowed on a literal-keyed Record

context