In TypeScript, how does `if ('radius' in shape)` narrow a union of object types, and why can an optional property leave the member you expected to be removed sitting in the `else` branch?
answer
- filters constituents by declared property
- optional means the check proves nothing
- the member shows up on both sides
- the key has to be a literal
basics
~20 sThe in operator keeps union members that declare the named property and drops those that do not. When the property is optional, the member can legitimately lack it at runtime, so the checker keeps that member in both branches.
solid answer
~50 sFor a union of object types, `'radius' in shape` filters constituents by whether they declare that property: the true branch keeps the ones that do, the false branch keeps the ones that do not. That makes it the standard way to discriminate shapes that share no discriminant field. The catch is optionality — if a member declares `radius?: number`, the property may genuinely be absent at runtime, so the checker cannot conclude anything from either outcome and keeps that member on **both** sides. The key must also be a literal or a literal-typed constant; a plain `string` variable proves nothing about which property was tested. Since TypeScript 4.9 the operator also narrows a value whose type does not list the key at all, by intersecting in a `Record` of that key with an `unknown` value.
code
typescript · 20 linestype Circle = { radius: number };
type Square = { side: number };
function area(shape: Circle | Square): number {
if ("radius" in shape) {
return Math.PI * shape.radius ** 2; // shape: Circle
}
return shape.side ** 2; // shape: Square
}
type WithMeta = { id: string; meta?: object };
type Bare = { id: string };
function describe(v: WithMeta | Bare): string {
if ("meta" in v) {
return "has meta"; // v: WithMeta
}
// v: WithMeta | Bare — the optional property survives here
return v.id;
}go deeper
Know that the property-presence test picks the union member that declares that property, and that it is the go-to guard when the members have no shared tag field to compare.
Explain why an optional property leaves its member in both branches — the check proves nothing about a property that may legitimately be absent — and why the tested key must be a literal type.
Read an ambiguous else branch as a modelling signal: replace presence-based discrimination with a required literal tag so both branches are conclusive and exhaustiveness can be enforced.
Own the convention across a codebase: mandate required discriminants on domain unions so narrowing is total, and reserve property-presence checks for genuinely untyped boundary data.
## The basic behaviour ```ts type Circle = { radius: number }; type Square = { side: number }; function area(shape: Circle | Square): number { if ("radius" in shape) { return Math.PI * shape.radius ** 2; // shape: Circle } return shape.side ** 2; // shape: Square } ``` The checker treats the property-presence test as evidence and filters the union: constituents that declare `radius` go to the true branch, constituents that do not go to the false branch. This is the guard of choice when the union members have no shared discriminant field to switch on — you are discriminating by *shape* rather than by a tag. ## Why optional properties break the symmetry Narrowing only ever credits a guard with what the runtime check can prove. A required property is guaranteed to be present on any value of that type, so its absence proves the value is not that type. An **optional** property carries no such guarantee: a value of the type may or may not have the key. So neither outcome of the test is conclusive for that member, and the checker keeps it on both sides. ```ts type WithMeta = { id: string; meta?: object }; type Bare = { id: string }; function f(v: WithMeta | Bare) { if ("meta" in v) { // v: WithMeta } else { // v: WithMeta | Bare ← WithMeta survives, because meta is optional } } ``` Candidates usually expect `Bare` in the else branch and are surprised. The rule to state out loud: **an optional property appears on both sides of an `in` check.** The consequence is that the else branch of such a test is often useless for discrimination, and the union should be redesigned — most commonly by giving each member a required literal tag, which turns the check into a plain equality test on a field that is always there. ## The key must be a literal The left operand has to be a string literal, or a constant whose type is that literal: ```ts const KEY = "radius"; // type "radius" — const, so the literal type is kept if (KEY in shape) { // narrows } let key = "radius"; // type string if (key in shape) { // does not narrow — the checker does not know which key was tested } ``` This is not an arbitrary restriction: a value of type `string` could be any key, so the test proves nothing specific about the value's type. ## Narrowing a type that does not list the key Since TypeScript 4.9 the operator also does something useful when the *key* is not part of the declared type. Testing a key on a value typed as an object with unknown extra properties intersects in a record of that key, so the property becomes readable as `unknown` rather than being a compile error: ```ts function hasVersion(v: object): string | undefined { if ("version" in v) { // v: object & Record<"version", unknown> return typeof v.version === "string" ? v.version : undefined; } return undefined; } ``` The added property is `unknown`, not `any` — the check proved the key exists, not what is under it, so a second guard is still needed before use. This makes `in` the natural companion to the object-tag check when validating data whose shape you do not yet trust. ## What it does not do The presence test is a *declaration-level* filter, not a runtime survey of the object's keys. It cannot tell you that a union member has an extra property beyond its declaration, and it does not distinguish an own property from an inherited one at the type level — the checker simply asks which declared constituents have that key. And, like every guard here, it is erased: the emitted code keeps the `in` expression, the refinement does not exist at runtime. ## Interview framing Give the filtering rule, then the optional-property trap and the reason behind it, then the literal-key requirement. Finishing with "and if the else branch is ambiguous, that is a signal the union wants a required discriminant instead" shows you can read the diagnostic as a modelling problem rather than a compiler quirk.
- Your else branch keeps a member because the tested property is optional. How do you redesign the union?Give every member a required property that distinguishes it — usually a literal tag such as `kind: 'circle'` — and check that instead. A required field is guaranteed present, so both branches become conclusive, the check becomes a plain equality test, and a `switch` over the tag can be made exhaustive. Optional-property presence is a weaker signal than a required tag, and modelling should not depend on it.
- Why does `if (key in shape)` fail to narrow when `key` is declared with `let key = 'radius'`?Because that declaration gives `key` the widened type `string`, and a value of type `string` could be any property name — so the test proves nothing about which member `shape` is. Declaring it with `const`, or annotating the literal type, keeps the type as `'radius'` and the narrowing works. The guard needs to know *which* key was checked, not merely that some key was.
- When you test a key on a value typed as `object`, what type does the property have afterwards?`unknown`. Since TypeScript 4.9 the operator intersects in a record of the tested key, so the property becomes readable rather than a compile error — but the check established only that the key exists, not what it holds. You still need a second guard, typically a `typeof` test, before using the value.
saying these in an interview costs you the question
- Expects an optional property to be excluded by the else branch
- Thinks the check inspects the object's actual runtime keys
- Uses a string-typed variable as the key and expects narrowing
- Believes the check distinguishes own from inherited properties
- Assumes the property is typed any after narrowing an object