In TypeScript, given `type Shape = { kind: 'circle'; radius: number } | { kind: 'square'; side: number }`, why can you read `shape.radius` inside `case 'circle':` of `switch (shape.kind)`, but not on the same value before the switch?
answer
- one value, several possible shapes
- only shared properties on the whole union
- the tag is a literal, not a string
- comparing the tag filters the union
- the case body holds one member
basics
~20 sComparing the literal tag narrows the value, so inside case 'circle' shape is only the circle member and radius exists. Before the switch it is still the whole union, where one member has no radius.
solid answer
~40 sEach member carries a `kind` property typed as a distinct string literal, which makes `kind` the discriminant. On the un-narrowed union you may only touch properties that *every* member guarantees — here just `kind` — because the compiler cannot know which arm you are holding, so `shape.radius` is an error at that point. When you write `switch (shape.kind)`, control-flow analysis compares the discriminant against each `case` literal and keeps only the members whose tag could match. Inside `case 'circle':` the type of `shape` is exactly `{ kind: 'circle'; radius: number }`, so `radius` is available with no cast, no optional chaining and no `in` check. All of it is compile-time only: the emitted JavaScript is an ordinary `switch` on a string.
go deeper
Be able to say that a union only exposes properties shared by all members, and that comparing the literal tag in a case narrows the value so member-specific properties become readable.
Explain the mechanism: the tag is a literal type, control-flow analysis filters the union per case, fallthrough leaves the union of the matched arms, and the narrowing is scoped to that branch.
Show that you know the narrowing is a compile-time claim over erased types, so it is only trustworthy when the value's origin was validated rather than cast, and be ready to say what breaks when it was not.
Own the argument for modelling data as a tagged union rather than an object of optional fields, and be able to say what that choice buys a codebase in review cost and refactor safety.
## What a union type lets you touch A union type `A | B` says the value is one of the members, but not which one. The compiler must keep every property access safe for *all* possibilities, so on the un-narrowed value you can only reach members that are present on every arm with compatible types. For `Shape`, that is `kind` alone. Writing `shape.radius` before the switch produces an error along the lines of *Property 'radius' does not exist on type 'Shape'* — because it does not exist on the square arm. This is not the compiler being pedantic. At runtime the value really might be the square, and `undefined.radius`-style bugs are exactly what the union is protecting you from. ## What makes the union *discriminated* The union is discriminated because each member declares the same property name with a **literal** type — `'circle'` and `'square'` — rather than `string`. A literal type has exactly one inhabitant, so a successful comparison against that literal is enough information to identify the member. ```ts type Shape = | { kind: 'circle'; radius: number } | { kind: 'square'; side: number }; ``` ## How the switch narrows TypeScript runs control-flow analysis over the function body. When it sees `switch (shape.kind)`, it recognises that the switched expression is a discriminant property read directly off a union-typed value. For each `case` clause it then filters the union: any member whose tag type cannot equal that case's literal is dropped from the type of `shape` inside that clause. ```ts function describe(shape: Shape): void { switch (shape.kind) { case 'circle': // shape: { kind: 'circle'; radius: number } console.log('circle r =', shape.radius); break; case 'square': // shape: { kind: 'square'; side: number } console.log('square s =', shape.side); break; } } ``` The narrowing applies to the variable itself, not only to the property you compared — that is the whole point of a discriminant. Nothing is copied or converted; the compiler simply reasons about a smaller type in that region of code. ## Fallthrough gives you the union of the arms If two case labels share a body, the body sees the union of the matched members, so once again only the common properties are accessible: ```ts function label(shape: Shape): string { switch (shape.kind) { case 'circle': case 'square': return shape.kind; // shape is still Shape here; shape.radius would error } } ``` The same is true after the `break` — narrowing is scoped to the branch, not to the rest of the function. ## An if/else chain does the same thing `if (shape.kind === 'circle') { ... }` narrows identically; the comparison operator and the switch clause feed the same analysis. A switch is just the compact form when you are comparing one discriminant against many literals, and it makes the tail branch a natural place to reason about what is left over. ## None of this exists at runtime Types are erased. The compiler emits a plain `switch` on a string value; there is no injected check, no reflection, no per-case validation. The narrowing is a *statement about your code*, and it is only as true as the claim that the value really matches the declared type. If the value came from an unvalidated cast, the type says circle while the data says something else, and the emitted code will happily read `undefined`. ## The mistakes that show up in interviews A weak answer reaches for a cast — `(shape as Circle).radius` — which silences the checker without adding any safety, or sprinkles optional chaining `shape.radius?` to make the error go away. Neither is needed: the switch already proved which member you hold, and taking the narrowing is both safer and shorter than defeating it.
- What happens to the narrowing if two members of the union declare the same literal tag value?The case narrows to the union of both members, not to a single one, so the body can again only touch the properties they share. A duplicated tag defeats the point of the discriminant: the compiler has no way left to tell those two shapes apart.
- Does an if/else chain narrow the same way, or is switch special?It narrows the same way. `if (shape.kind === 'circle')` feeds the same control-flow analysis as a case clause; switch is just the readable form when one discriminant is compared against many literals, and it gives you a single tail branch for whatever is left.
- Is the narrowing still in effect after the switch statement ends?No. Narrowing is scoped to the branch where the comparison held. After the switch the variable is back to the full union type, unless the branches all returned or threw, in which case the code after is reached only by paths the compiler has already accounted for.
saying these in an interview costs you the question
- Says the union type exposes every property of every member
- Claims you must cast inside each case to read the payload
- Thinks the switch performs a runtime type check
- Says optional chaining is required because radius may be missing
- Believes narrowing persists after the switch statement