Proving which member you have
Narrowing is TypeScript following your runtime checks and shrinking a union accordingly. The workhorse is JavaScript's own typeof, which you met in the first JavaScript course:
function double(value: string | number): string | number { if (typeof value === "number") { return value * 2; // here value is number } return value + value; // here value must be string }
Inside the if, TypeScript knows value is a number, so arithmetic is allowed. After the if, only string remains, so string concatenation is allowed. No casts, no new syntax. The compiler reads ordinary control flow and updates the type line by line. This is why the if (p.bio !== undefined) check in lesson 3-2 unlocked p.bio.
Following both branches
function double(value: string | number): string | number { if (typeof value === "number") { return value * 2; } return value + value; } console.log(double(21)); console.log(double("ha"));
Output
42
hahaThe number branch goes through * 2, giving 42. The string branch adds the value to itself, giving "haha". Both are the natural meaning of "double" for their own type, which is why one function can serve both.
Moving return value * 2 below the if makes the compiler reject it, and the reason is worth stating plainly: outside the guard, value is once again string | number, and multiplication is not valid for a string. The narrowing was never a property of the variable, it was a property of the place in the code.
Narrowing objects with in
typeof only distinguishes primitives, it answers "object" for every object. To split a union of object shapes, check which property exists with the in operator:
interface Circle { radius: number } interface Square { side: number } function area(shape: Circle | Square): number { if ("radius" in shape) { return 3.14 * shape.radius * shape.radius; } return shape.side * shape.side; }
"radius" in shape is plain JavaScript, and TypeScript understands it: inside the if the shape is a Circle, after it a Square. It works, but hunting for distinguishing properties gets fragile as shapes grow. The next lesson shows the pattern real codebases use instead.
toLabel: a different method in each branch
toLabel(input: string | number) formats its argument differently depending on which member it received, calling a number method in one branch and a string method in the other.
function toLabel(input: string | number): string { if (typeof input === "number") { return "num:" + input.toFixed(1); } return "str:" + input.toUpperCase(); } console.log(toLabel(3.14)); console.log(toLabel("hi"));
Output
num:3.1
str:HIWhat narrowing unlocks here
- The function opens with
if (typeof input === "number")and returns inside it, which is the shape most narrowing takes: check, handle, return. toFixedandtoUpperCaseonly compile inside the branch where TypeScript knows the precise type. Swap them between branches and both lines fail, since neither method exists on the other type.toFixed(1)rounds3.14to one decimal place and returns a string, which is why the output readsnum:3.1rather thannum:3.14.