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?
answer
- finite key set versus open key set
- one required property per union member
- the build break is the feature
- an index signature requires nothing
- assertions and Partial both switch it off
basics
~20 sA 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 linestype 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
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.
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.
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.
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