skip to content

In TypeScript, reading a key that is absent from a dictionary type such as `{ [id: string]: User }` still type-checks and yields `User` rather than `User | undefined`. Why does the checker behave that way, and how do you get honest types for those lookups?

level: seniorimportance: must knowfreq 50%

answer

  1. signature says what, never whether
  2. optimism traded for ergonomics
  3. the flag adds undefined to reads
  4. not part of strict
  5. read into a local, then narrow

basics

~20 s

By default an index-signature read is typed as the value type, because the checker cannot know which keys exist and assuming presence keeps everyday code ergonomic. Enable noUncheckedIndexedAccess to make such reads yield the value type plus undefined.

solid answer

~50 s

An index signature says what a key holds **if** it is there, and the compiler has no way to know which keys a runtime object actually has — so by default it optimistically types the read as the value type. That is a deliberate, documented unsoundness traded for ergonomics: without it, every dictionary read in every codebase would need a null check. The fix is the `noUncheckedIndexedAccess` flag, which makes reads through an index signature (and array element access) produce `T | undefined`, while writes still demand a plain `T`. It is **not** part of `strict`, so you must turn it on deliberately, and on an existing codebase it surfaces a large number of genuine bugs at once. The working pattern is to assign the read to a local, narrow it, and use the local — plus `??` for defaults.

code

typescript · 12 lines
typescript
// tsconfig: { "compilerOptions": { "noUncheckedIndexedAccess": true } }
interface User { name: string }

const users: { [id: string]: User } = { u1: { name: "Ada" } };

const u = users["u2"]; // User | undefined
if (u !== undefined) {
  console.log(u.name);
}

const labels: { [code: string]: string } = {};
console.log(labels["fr"] ?? "fr"); // fallback instead of a check

go deeper

for a junior

Remember that reading a key from a dictionary type is not proof the key exists, and that the value can be undefined at run time even when the type says otherwise.

for a middle

Explain the tradeoff behind the optimistic default and describe exactly what noUncheckedIndexedAccess changes: reads and array element access gain undefined, writes do not.

for a senior

Show how you make lookups safe in real code — narrow through a local, use ?? for defaults, keep assertions rare and justified — and how you would roll the flag out without flooding the team.

for a principal

Own the correctness-flag policy: which unsoundnesses the codebase accepts, where run-time validation must sit at data boundaries, and how the migration is sequenced across packages.

## The lie, stated plainly ```ts const users: { [id: string]: User } = {}; const u = users["nobody"]; // typed User u.name; // compiles; throws at run time ``` Nothing here is a mistake by the programmer, and yet it crashes. The type says `User`; the value is `undefined`. This is one of the small set of **deliberate unsoundnesses** in TypeScript's type system, and interviewers use it to find out whether a candidate knows where the model diverges from reality. ## Why the default is optimistic An index signature is a statement about keys *of a given shape*, not about which keys exist. The compiler has no membership information: the object may be built from `JSON.parse`, filled in a loop, or handed over by another module. Faced with that, it has two options. It could type every read as `T | undefined`. That is honest — and it means every single dictionary access in every codebase becomes a narrowing exercise, including the overwhelming majority where the key was just checked or just written. TypeScript's original default chose ergonomics: assume the key is there. The cost is real bugs, and the language's answer is not to change the default (that would break the world) but to offer the honest behaviour behind a flag. ## `noUncheckedIndexedAccess` Enabling it changes reads: ```ts // with noUncheckedIndexedAccess: true const u = users["nobody"]; // User | undefined u.name; // error: possibly undefined ``` Points worth knowing precisely: - It applies to reads through an **index signature** and to **array element access**, which is where most latent `undefined` bugs live. - It does **not** loosen writes: `users[id] = value` still requires a real `User`. - It does **not** affect declared properties. `config.retries` on a declared `retries: number` is unchanged; only keys covered by the signature become optional. - It is **not included in `strict`**. This surprises people who assume `strict: true` buys every correctness check; it does not, and this flag is the headline example. ## Living with it The pattern that works is to read once into a local and narrow the local: ```ts const u = users[id]; if (u === undefined) return notFound(id); use(u.name); ``` Repeating `users[id]` after the check re-runs the index read, so keep the value in the local. For values with a sensible fallback, `??` is enough: `const label = labels[code] ?? code;`. When the missing case is genuinely impossible and you can justify it — a key you wrote two lines above — a non-null assertion is honest as a *local, commented* exception rather than a reflex. What does **not** work is checking `if (id in users)` and then indexing again: an `in` test does not remove `undefined` from a later index-signature read, so the error stays and you reach for the assertion anyway. Read into a local instead. ## Adoption on an existing codebase Turning the flag on mid-project is a real migration, not a config tweak. Expect hundreds of errors in a dictionary-heavy codebase, and expect a meaningful fraction to be actual latent bugs — cache lookups, lookup tables keyed by user input, config maps. The pragmatic sequence is: enable it on new packages or a leaf module first, fix by narrowing rather than by asserting, and only then move it up to the root config. Measuring the assertion count afterwards is a decent proxy for whether the team is fixing or silencing. ## The related access flag `noPropertyAccessFromIndexSignature` is a complementary control: it forbids dotted access for keys that exist only because of an index signature, forcing `obj["key"]`. Combined with `noUncheckedIndexedAccess`, the two make untrusted lookups *look* untrusted at the call site — bracket syntax plus a mandatory `undefined` story — while declared properties keep clean dotted access. ## One more honest gap Even with the flag on, iterating keys does not carry the same guarantee: `Object.keys(obj)` is typed `string[]`, not a key-specific type, because a runtime object may carry keys beyond what the type lists. The lesson is the same one the flag teaches — the type layer describes a claim about data, and the compiler cannot verify membership for you. Where the data crosses a boundary you do not control, a run-time validation step is what actually makes the type true.

  • Does `noUncheckedIndexedAccess` also change what you may assign to a dictionary key?
    No. It adds `undefined` on the read side only; `users[id] = value` still requires a value of the declared type, and assigning `undefined` to a `{ [k: string]: User }` remains an error. That asymmetry is intentional — the uncertainty is about whether a key is present, not about what you are allowed to store.
  • Why is checking `if (id in users)` and then reading `users[id]` still an error under the flag?
    The `in` test narrows the object where a union or optional property is involved, but it does not remove `undefined` from a subsequent index-signature read — the read is re-evaluated and re-typed. Read the value into a local variable once and narrow that local; it is also cheaper, since you index the object a single time.
  • How would you roll this flag out on a large existing codebase?
    Not repository-wide in one commit. Enable it on a leaf package or new code first, fix by narrowing rather than by adding `!`, and treat the error count as a bug backlog rather than noise — a good share of the hits in cache and config lookups are genuine. Move it to the root config once the assertion count stops growing.

saying these in an interview costs you the question

  • Believes strict already enables noUncheckedIndexedAccess
  • Thinks a missing key throws instead of returning undefined
  • Silences the new errors with ! or an as assertion everywhere
  • Assumes an in check fixes the type of a later index read
  • Says the flag adds undefined to declared properties as well

context