A TypeScript service reads `const rate = rates[currency]` from a value typed `Record<string, number>` and then crashes on `rate.toFixed(2)`. Why did the compiler allow that, and how would you type the lookup table so it cannot happen?
answer
- permitted keys, not present keys
- the checker trusts the index signature
- ergonomics bought with unsoundness
- put undefined in the value type
- narrow the key set at the boundary
basics
~20 sRecord<string, number> declares an open key set, so the compiler types every lookup as number whether or not the key exists — it models permitted keys, not present ones. Model absence explicitly instead: Record<string, number | undefined>, or a Partial Record over a finite key union.
solid answer
~50 s`Record<string, number>` expands to a string index signature, which says *any* string key is permitted with value `number`. It says nothing about which keys are actually present, and by default TypeScript reads that as a promise: `rates[currency]` is typed `number` even for a key that was never set, so `rate.toFixed(2)` type-checks and then blows up on `undefined`. This is a deliberate unsoundness — checking every index access would make ordinary dictionary code miserable. The fix is to put the absence into the type: declare the table as `Record<string, number | undefined>` so every read is `number | undefined` and the compiler forces a guard, or better, narrow the key set — `Record<CurrencyCode, number>` when the table must be complete, `Partial<Record<CurrencyCode, number>>` when it is genuinely sparse. Project-wide, the `noUncheckedIndexedAccess` compiler flag makes index reads yield `T | undefined` without touching each declaration.
code
typescript · 24 linesdeclare const currency: string;
// The bug: an open key set is trusted
type Loose = Record<string, number>;
declare const loose: Loose;
const bad = loose[currency]; // number, even if absent
// Fix A: model absence in the value type
type Honest = Record<string, number | undefined>;
declare const honest: Honest;
const v = honest[currency] ?? 0;
// Fix B: narrow the key set, validate once at the boundary
type CurrencyCode = 'USD' | 'EUR' | 'GBP';
const CODES = ['USD', 'EUR', 'GBP'] as const;
function isCurrencyCode(x: string): x is CurrencyCode {
return (CODES as readonly string[]).includes(x);
}
const rates: Record<CurrencyCode, number> = { USD: 1, EUR: 0.92, GBP: 0.79 };
if (isCurrencyCode(currency)) {
rates[currency].toFixed(2);
}go deeper
Know that reading a key out of a string-keyed Record gives you the value type even when the key is missing, so a lookup can hand you undefined at runtime despite type-checking.
Explain that an index signature describes permitted keys rather than present ones, and show the two declaration-level fixes: adding undefined to the value type, or using a finite key union so entries are required.
Diagnose it as a deliberate unsoundness, choose between narrowing the key set, Partial, and the project flag with reasons, and move the runtime check to a single boundary guard rather than guarding every read.
Own the rollout decision: whether to turn on unchecked-index reporting across a large codebase, how to stage the resulting error wave, and what the team's default lookup-table shape should be so this class of bug stops recurring.
## Why the compiler said nothing `Record<string, number>` is the mapped type `{ [P in string]: number }`, which collapses to a string index signature: ```ts type Rates = Record<string, number>; // { [x: string]: number } declare const rates: Rates; declare const currency: string; const rate = rates[currency]; // typed number rate.toFixed(2); // compiles; throws if the key was absent ``` An index signature is a statement about the keys the type *permits*, not the keys the value *has*. There is no finite key list to compare against, so the checker has two options: treat every read as possibly missing, or trust the declaration. By default it trusts it. That is one of the type system's known, deliberate unsoundnesses — the same family as a type assertion or a non-null `!`. The reasoning is ergonomic: if every dictionary read produced `T | undefined`, ordinary code that just built the map three lines earlier would be full of guards. The consequence is that a `Record<string, T>` is only as honest as the code that populated it. Data from `JSON.parse`, a query string, a database row, or user input has whatever keys it has; the annotation asserts a shape nobody verified. ## Fix 1: put `undefined` in the value type The most local and most portable fix — it needs no compiler setting and it documents intent at the declaration: ```ts type Rates = Record<string, number | undefined>; declare const rates: Rates; const rate = rates[currency]; // number | undefined rate.toFixed(2); // Error: 'rate' is possibly 'undefined' if (rate !== undefined) { rate.toFixed(2); // narrowed to number } ``` Every consumer now has to make a decision — guard, default with `??`, or throw. The cost is that writing the map also allows `undefined` as a stored value, which is usually acceptable for a lookup table. ## Fix 2: stop using `string` as the key Often the real defect is upstream: the key set is not actually open. If currencies are a known set, say so: ```ts type CurrencyCode = 'USD' | 'EUR' | 'GBP'; // The table must be complete — a missing member is a compile error const rates: Record<CurrencyCode, number> = { USD: 1, EUR: 0.92, GBP: 0.79 }; declare const code: CurrencyCode; rates[code].toFixed(2); // genuinely safe: every member has an entry ``` Here the type is telling the truth, because the finite key union makes every entry required and a new member breaks the build until the table is filled in. Note that this only holds while the key variable is typed `CurrencyCode` — the burden moves to validating the incoming string once, at the boundary: ```ts const CODES = ['USD', 'EUR', 'GBP'] as const; function isCurrencyCode(x: string): x is CurrencyCode { return (CODES as readonly string[]).includes(x); } ``` That guard is the runtime check the index signature never performed. Do it once at the edge instead of guarding at every read. ## Fix 3: `Partial<Record<K, V>>` when the map is legitimately sparse An overrides table, a cache, a per-tenant customisation — these really do have missing entries. Say that: ```ts type Overrides = Partial<Record<CurrencyCode, number>>; declare const overrides: Overrides; const o = overrides.USD; // number | undefined const v = o ?? defaultRate; ``` This keeps the key set closed (a typo is still an error) while making absence explicit. It is the right choice far more often than people reach for it, because the common instinct is to widen the key to `string` the moment the map is not complete — which throws away both guarantees at once. ## Fix 4: the project-wide flag TypeScript's `noUncheckedIndexedAccess` makes index-signature and array element reads yield `T | undefined` everywhere, so the original `Record<string, number>` starts reporting the bug without any declaration change. Two things to say about it in an interview: it is not part of `strict`, so it must be enabled explicitly, and it applies to *index* access, not to the named properties a literal-keyed `Record` generates — `Record<CurrencyCode, number>` keeps reading as `number` because those are ordinary required properties, which is exactly right. Turning it on in a mature codebase produces a large one-time wave of errors, most of them real. ## A related trap: iterating the keys Even with a narrow key type, the standard library types `Object.keys(rec)` as `string[]`, because an object can carry more keys at runtime than its type admits. So `Object.keys(rates).forEach(k => rates[k])` will not hand you `CurrencyCode` for free. Iterate the key list you already control (`CODES.forEach(...)`) or write a helper whose assertion is confined to one place, rather than sprinkling casts through the loop body. ## What to say out loud The short version an interviewer wants: an index signature promises permitted keys, not present ones; the compiler trusts that promise by default; and the cure is to make the type match reality — narrow the key set when it is finite, add `| undefined` when it is not, and validate the string once at the boundary rather than asserting at every read.
- Why did TypeScript choose the unsound default rather than typing every index read as possibly missing?Ergonomics. Most dictionary reads happen on maps the same function just populated, so typing every one as `T | undefined` would force guards on code that is obviously fine and push people toward non-null assertions — a worse outcome. The team put the strict behaviour behind the opt-in `noUncheckedIndexedAccess` flag instead, so a project can choose the tradeoff deliberately.
- Does enabling `noUncheckedIndexedAccess` also change reads from `Record<CurrencyCode, number>`?No, and that is the right behaviour. A literal-keyed Record generates ordinary required properties, not an index signature, so `rates.USD` stays `number`. The flag targets index access — index signatures and array elements — where presence genuinely is unverified. It is another reason to prefer a finite key union when the key set really is finite.
- If the currency code arrives as a plain string from a request, how do you get to the narrow key type safely?Validate once at the boundary with a type predicate — a function returning `x is CurrencyCode` that checks membership of the known list — and reject or default when it fails. After that the value is `CurrencyCode` for the rest of the call, so lookups into a complete Record need no further guarding. Asserting with `as CurrencyCode` skips the check and reintroduces the same crash.
- Why doesn't `Object.keys` give you the narrow key union for a `Record<CurrencyCode, number>`?Because an object can carry more keys at runtime than its type lists — structural typing allows extra properties to travel — so the standard library types `Object.keys` as `string[]` to stay honest. Iterate the literal key list you control, or confine the assertion to a single helper rather than repeating it at every call site.
saying these in an interview costs you the question
- Fixing it with a non-null assertion on the lookup
- Saying the annotation guarantees the keys exist
- Widening the key to string because the map is sparse
- Thinking Record does any runtime validation of parsed JSON
- Assuming strict already enables unchecked-index reporting