In TypeScript, `if (typeof obj[key] === "string") { obj[key].toUpperCase(); }` compiles when `key` is declared `const key = "a"`, but fails when `key` is a `let`. Why does the declaration of the key decide this, and why is the narrowing you do get still unsound?
answer
- narrowing matches reference paths, not values
- a key must name one slot, not a family
- const keeps the literal type, let widens
- the checker only sees assignments it can see
- copy the value into a local instead
basics
~20 sNarrowing an element access requires the key to be a stable literal, so the checker can treat both obj[key] occurrences as the same slot. A const key has a literal type; a let key widens to string and is mutable, so the two accesses are not matched.
solid answer
~50 sTypeScript narrows a reference by matching syntactically identical references. Since TypeScript 4.7 an element access counts as such a reference, but only when the index is effectively constant with a literal type — a `const` variable, a `readonly` property, or a literal written inline. `const key = "a"` has the literal type `"a"`, so the checker can prove both `obj[key]` expressions denote the same slot and carries the narrowing across. `let key = "a"` widens to `string` and could be reassigned between the two accesses, so no match is made and you get the declared union type back. The narrowing that does happen is unsound in the usual way: the checker only invalidates it on assignments it can see, so a function call between check and use that mutates the object leaves the stale narrowed type in place.
code
typescript · 17 linesdeclare const obj: Record<string, string | number>;
const constKey = "a";
if (typeof obj[constKey] === "string") {
console.log(obj[constKey].toUpperCase()); // ok
}
let letKey = "a";
if (typeof obj[letKey] === "string") {
// @ts-expect-error 'toUpperCase' does not exist on 'string | number'
console.log(obj[letKey].toUpperCase());
}
const value = obj[letKey];
if (typeof value === "string") {
console.log(value.toUpperCase()); // always works
}go deeper
Recall that const key = "a" and let key = "a" do not have the same type — the const keeps the literal "a" while the let widens to string. Know the safe habit: copy the value into a local const before checking it.
Explain that narrowing records facts about syntactic reference paths, and that an element access qualifies only when the index is an effectively-constant literal. Contrast the const and let cases and name the resulting error on the union type.
Show the production angle: demonstrate the stale-narrowing case where a call between the check and the use mutates the object, explain why the checker cannot catch it without dropping all property narrowing at every call, and recommend copying to a local at the boundaries where it matters.
Frame it as a soundness budget. The checker trades a known unsoundness for usable ergonomics, and your job is to decide where the team may spend it — local reads in a tight block, versus data crossing module or async boundaries where a copied, narrowed value should be the declared shape.
## Narrowing works on references, not on values When the checker narrows, it records a fact about a *reference* — a syntactic path such as `x`, `x.y`, or `x.y.z`. At a later use it asks whether the reference there is the same path, and if so applies the recorded fact. This is why narrowing `user.profile` does not narrow `getUser().profile`: a call expression is not a stable path. ## Element accesses as references TypeScript 4.7 extended that matching to element accesses, so `obj[key]` can be narrowed and stay narrowed. The requirement is that the index expression be effectively constant *and* have a literal type — a string, number or unique symbol literal written inline, or a `const` variable / `readonly` property whose type is such a literal. ```ts declare const obj: Record<string, string | number>; const key = "a"; // type "a" if (typeof obj[key] === "string") { obj[key].toUpperCase(); // ok since TypeScript 4.7 } ``` The canonical example from the release used a symbol key, which behaves the same way: ```ts const key = Symbol(); const numberOrString = Math.random() < 0.5 ? 42 : "hello"; const o = { [key]: numberOrString }; if (typeof o[key] === "string") { o[key].toUpperCase(); } ``` ## Why `let` breaks it Two separate things go wrong at once. First, **widening**. A `const` initialised with a string literal keeps the literal type `"a"`; the same initialiser on a `let` widens to `string`, because the binding is expected to hold other strings later. The checker needs a literal to form a stable reference path — `string` names a whole family of slots, not one. Second, **mutability**. Even if the type were preserved, a `let` can be reassigned between the check and the use, at which point the two `obj[key]` expressions genuinely denote different properties. Rather than analyse whether an assignment occurs, the checker declines. ```ts let key = "a"; // type string if (typeof obj[key] === "string") { obj[key].toUpperCase(); // error: Property 'toUpperCase' does not exist // on type 'string | number' } ``` The same reasoning covers numeric indexes: `const i = 0` has the literal type `0` and narrows `arr[i]`, while `let i = 0` widens to `number` and does not. ## The unsoundness you keep even when it works Narrowing on any property or element access is a deliberate compromise. The checker invalidates a recorded fact when it sees a direct assignment to that same reference, but it does not attempt to model what arbitrary code might do to the object: ```ts function reset(o: Record<string, string | number>) { o.a = 0; } if (typeof obj[key] === "string") { reset(obj); obj[key].toUpperCase(); // still typed string; throws at run time } ``` The same applies to a property implemented as a getter that returns a different value on each read, and to code that runs concurrently between the two statements in an async function. None of this is a bug report — modelling it soundly would require the checker to assume every call invalidates every narrowing, which would make the feature useless. There is a second, related caveat about element access in general: by default the checker assumes an indexed read returns the declared value type rather than possibly `undefined`, which is why a missing key produces a confident type. The compiler flag `noUncheckedIndexedAccess` changes that assumption; it belongs to the strictness surface rather than to narrowing itself. ## Working with the limitation The reliable move is to stop re-reading the slot: pull the value into a `const` and narrow that local instead. ```ts const value = obj[key]; if (typeof value === "string") { value.toUpperCase(); // narrowing on a local const — no re-read, no staleness } ``` This works regardless of how `key` is declared, keeps the narrowed value stable across intervening calls, and reads more plainly. Reach for element-access narrowing when a local copy would be awkward, and treat it as an ergonomic convenience rather than a guarantee.
- Does the same rule apply to numeric indexes, such as `arr[i]`?Yes. `const i = 0` has the literal type `0`, so the checker can match `arr[i]` across the check and the use. `let i = 0` widens to `number` and is mutable, so the accesses are not matched and you get the declared element type back. Writing the literal inline, `arr[0]`, works for the same reason.
- Why does the key need a literal type rather than just being declared `const`?Because the reference path has to identify one slot. `const key: string = compute()` is immutable but its type is `string`, which stands for every possible property name, so the checker cannot prove the second `obj[key]` reads what the first one tested. Only a literal type — or a unique symbol — pins the access to a single property.
- What would it take for the checker to catch the mutation case?It would have to assume that any function call might modify any object reachable from it, and therefore drop every property and element narrowing at each call. That is sound but unusable in practice — ordinary code calls functions constantly — so TypeScript accepts the known unsoundness and expects you to copy the value into a local when it matters.
saying these in an interview costs you the question
- Says a const key works because const objects are frozen
- Thinks the compiler re-checks the property at each read
- Assumes any immutable key is enough, ignoring literal types
- Believes narrowing survives because the object was not reassigned
- Claims the failing case is a compiler bug rather than a design choice