skip to content

In TypeScript, what does the noUncheckedIndexedAccess compiler option change about the type of rows[0] for const rows: string[], and which accesses does it deliberately leave alone?

level: middleimportance: should knowfreq 52%

answer

  1. the default index type is optimistic
  2. reads gain undefined, writes do not
  3. for...of escapes, index loops do not
  4. length checks never narrow an index

basics

~10 s

With noUncheckedIndexedAccess on, every read through an array index or an index signature gains undefined: rows[0] is string | undefined rather than string. Declared properties, fixed tuple positions and for...of iteration are unaffected.

solid answer

~50 s

By default TypeScript types `rows[0]` as `string`, which is a lie — the array may be empty, and JavaScript hands back `undefined` for a missing index. `noUncheckedIndexedAccess` corrects the model: every read through a numeric array index or an index signature becomes `T | undefined`, so `rows[0]` is `string | undefined` and a `Record<string, number>` lookup is `number | undefined`. It is scoped narrowly. Declared properties are untouched, a known tuple position such as `pair[0]` on a `[string, number]` stays `string`, and iteration forms hand you the element type directly, so `for (const row of rows)` and `rows.forEach` still give you `string`. The friction lands on C-style index loops and dictionary lookups, and note that `rows.length > 0` does not narrow `rows[0]` — the checker does not relate a length check to an index. The idiomatic fix is to read once into a local and guard it, or supply a fallback with `??`.

code

typescript · 16 lines
typescript
const rows: string[] = ["a", "b"];
const counts: Record<string, number> = { a: 1 };

const first = rows[0];             // string | undefined
if (first !== undefined) {
  console.log(first.toUpperCase());
}

for (const row of rows) {
  console.log(row.toUpperCase());  // string - iteration is unaffected
}

const n = counts["a"] ?? 0;        // number
const pair: [string, number] = ["a", 1];
const label = pair[0];             // string - known tuple index is unaffected
console.log(n, label);

go deeper

for a junior

Recall the headline: with this option on, reading an array index or a dictionary key gives you the value type plus undefined, so you have to check before using it.

for a middle

Explain precisely which accesses change and which do not — index signatures and array indexes yes, declared properties and known tuple slots no, for...of no — and show the read-into-a-local-then-guard fix.

for a senior

Show why the compiler cannot use a length check to narrow an index, and judge where the flag earns its cost: dictionary-heavy and external-data code, versus fixed-shape code where it mostly adds ceremony.

for a principal

Own the adoption policy: whether the whole estate gets this flag or only the packages that handle untrusted data, and how you stop the rollout from degrading into blanket assertions that keep the build green with none of the safety.

## The unsoundness it fixes Write `const rows: string[] = []` and then `rows[0]`, and TypeScript's default answer is `string`. At runtime that expression is `undefined`. This is a deliberate default: an index signature — including the implicit numeric one on an array — is modelled as though every key is present, because forcing a check on every array read would have made early TypeScript unusable. `noUncheckedIndexedAccess` is the opt-in that removes the lie. With the flag on, an indexed read produces `T | undefined`: ```ts const rows: string[] = ["a"]; const counts: Record<string, number> = {}; const first = rows[0]; // string | undefined const n = counts["a"]; // number | undefined ``` Destructuring is the same operation under the hood, so it follows: `const [first] = rows` gives `string | undefined` too. ## What it does not touch The flag is about *indexed* access, not about optionality in general. - **Declared properties.** Given `interface Named { name: string }`, `named.name` is still `string`. The property is declared, so there is no missing-key question. - **Known tuple positions.** For `const pair: [string, number]`, `pair[0]` remains `string`, because the tuple type guarantees that slot exists. Index a tuple with a *variable* number, though, and you are back to an index signature read, which does admit `undefined`. - **Iteration.** `for (const row of rows)` and `rows.forEach(row => ...)` bind `row` as `string`. Neither is an indexed read — the iterator and the callback are typed to yield the element type — so the flag has nothing to say about them. This is worth knowing because it makes iteration the cheapest way to keep a codebase quiet under the flag. A related point that is not the flag's doing: the standard library already types `Array.prototype.at` as returning `T | undefined`, so `rows.at(0)` was always honest regardless of configuration. ## Where the friction actually lands Two shapes generate almost all the errors. First, the classic index loop: ```ts for (let i = 0; i < rows.length; i++) { const s: string = rows[i]; // error: string | undefined is not assignable to string } ``` The compiler does not connect `i < rows.length` to the safety of `rows[i]`. It cannot: control-flow analysis tracks types of expressions, not arithmetic relationships between a counter and a length. The same limitation makes `if (rows.length > 0) { rows[0] }` fail — a length guard narrows nothing. Second, dictionary lookups. Any `Record<string, T>` or `{ [k: string]: T }` read now needs handling, which is exactly the point: a lookup by a runtime key genuinely can miss. The honest fixes are ordinary narrowing. Read once into a local and check it, which also avoids re-reading: ```ts const row = rows[i]; if (row !== undefined) { console.log(row.toUpperCase()); } ``` Or supply a default: `const n = counts[key] ?? 0`. Narrowing on a stable expression works too — after `if (counts[key] !== undefined)`, a subsequent `counts[key]` is `number`, provided the key expression itself has not changed. One asymmetry worth knowing: the flag loosens *reads*, not writes. Assigning `counts["k"] = undefined` is still an error against a `Record<string, number>`, because the declared value type has not changed. ## The judgment call The flag pays for itself in code that walks dictionaries, parses external data, or does lookups keyed by runtime values — precisely where the missing key is a real bug that would otherwise surface as a downstream `undefined`. It pays much less in code dominated by fixed-shape objects and iteration, where it mostly adds ceremony to accesses that were already safe. The failure mode to watch for is a team that adopts the flag and then silences every new error with an assertion. That converts a compile-time question into a claim the compiler is required to believe, which is the same state as having the flag off — except now the codebase is littered with punctuation implying someone checked. If the fixes are not real checks, fallbacks, or a switch to iteration, the flag is not buying anything.

  • Why can't the compiler use `i < rows.length` in a for loop to prove `rows[i]` is defined?
    Because control-flow analysis narrows the types of expressions, it does not do arithmetic reasoning about the relationship between a counter and an array's length — and the array can be mutated inside the loop anyway. The same reason makes `if (rows.length > 0)` fail to narrow `rows[0]`. If you want the element narrowed, read it into a local and compare it against `undefined`, or iterate with `for...of`.
  • Does the flag also change what you are allowed to write into an indexed slot?
    No. It affects reads only. Against a `Record<string, number>`, `counts["k"] = undefined` is still an error, because the declared value type is still `number`. The flag changes the *result* type of a lookup to reflect that the key may be missing; it does not widen the value type the container accepts.
  • A team enables the flag and clears the errors in a week by adding non-null assertions everywhere. What went wrong?
    They kept the cost and threw away the benefit. Each assertion tells the compiler to believe exactly the claim the flag was questioning, so the code is back to the pre-flag guarantees — but now it reads as though someone verified the access. The rollout is only worth doing if the fixes are real: a guard, a `??` fallback, or a switch to an iteration form that never indexes.

saying these in an interview costs you the question

  • Thinks the flag adds runtime checks to indexed reads
  • Believes checking arr.length narrows arr[0]
  • Assumes for...of elements also become possibly undefined
  • Says a known tuple index gains undefined too
  • Treats non-null assertions as the standard fix

context