RizTech Academy logo
RizTech Academy
Unions and NarrowingLesson 2 of 530 min

Narrowing with typeof, in and truthiness

Narrowing is the mechanism that makes unions usable: you write an ordinary JavaScript check, and TypeScript follows your reasoning, narrowing a value's type inside the branch where the check holds. It is one of the cleverest and most pleasant things about the language — the compiler tracks what you have proven. This lesson is the full set of ways to narrow, which you will use in almost every function that touches a union.

typeof — narrowing by primitive type

The most basic narrowing, for primitives:

function format(value: string | number): string {
  if (typeof value === "string") {
    return value.toUpperCase();      // value narrowed to string
  } else {
    return value.toFixed(2);         // value narrowed to number
  }
}

typeof value === "string" narrows to string in the if; TypeScript knows the else must be number. The typeof operator returns one of a fixed set of strings — "string", "number", "boolean", "object", "function", "undefined", "symbol", "bigint" — and TypeScript understands each as a narrowing. This is your tool for unions of primitives.

One famous JavaScript gotcha typeof carries: typeof null === "object". So typeof x === "object" narrows to the object types and null — a real trap the truthiness check below handles better.

Truthiness — narrowing out null and undefined

A plain if (value) narrows away the "falsy" values — null, undefined, "", 0, false, NaN:

function greet(name: string | null | undefined): string {
  if (name) {
    return `Namaste, ${name}`;       // name narrowed to string (null/undefined ruled out)
  }
  return "Namaste, guest";
}

if (name) is truthy only when name is a non-empty string, so inside the branch TypeScript narrows name to string (removing null and undefined). This is the most common way to handle a possibly-absent value. But mind the JavaScript trap it shares: for a string | undefined, if (name) also excludes the empty string "" — which is sometimes a valid value you did not mean to treat as absent. When the difference between "absent" and "empty" matters, check explicitly:

if (name != null) {          // narrows out null AND undefined, but KEEPS "" and 0
  // name is string here, empty string included
}

x != null (with the loose !=) is a handy idiom: it removes both null and undefined while keeping other falsy values like "" and 0. Reach for it when empty and zero are legitimate.

Equality — narrowing by comparing values

Comparing a value against a literal narrows it:

type Direction = "north" | "south" | "east" | "west";

function move(dir: Direction) {
  if (dir === "north") {
    // dir narrowed to "north" here
  }
}

function check(a: string | number, b: string | boolean) {
  if (a === b) {
    // a and b must both be the shared type: string. Both narrowed to string here.
  }
}

=== against a literal narrows to that literal. And comparing two unions with === narrows both to their common type — if a === b can only be true when both are strings, TypeScript narrows both to string in the branch. This is how a switch over a literal union works (the exhaustiveness lesson), and it is the discriminant check that powers discriminated unions.

in — narrowing by property presence

For object unions, checking whether a property exists narrows to the member that has it:

type Admin = { role: "admin"; permissions: string[] };
type User = { role: "user"; lastLogin: Date };

function describe(account: Admin | User): string {
  if ("permissions" in account) {
    return `Admin with ${account.permissions.length} permissions`;   // narrowed to Admin
  }
  return `User, last seen ${account.lastLogin}`;                       // narrowed to User
}

"permissions" in account is true only for Admin, so inside the branch TypeScript narrows to Admin and account.permissions is available. in is useful when object members differ by which properties they have. (When they share a common tag property, the discriminated-union pattern — checking account.role — is cleaner; in is for when there is no such tag.)

instanceof — narrowing by class

For values that might be instances of classes, instanceof narrows:

function handle(value: Date | string): string {
  if (value instanceof Date) {
    return value.toISOString();      // narrowed to Date — Date methods available
  }
  return value.toUpperCase();         // narrowed to string
}

value instanceof Date narrows to Date. This works for any class (built-in like Date, Error, Array, or your own), and is the right tool when your union includes class instances.

Narrowing flows through your code

The clever part: TypeScript tracks narrowing across statements, not just inside one if. The early-return (or "guard clause") pattern uses this to flatten code:

function process(value: string | null): string {
  if (value === null) {
    return "no value";
  }
  // from here on, TypeScript knows value is string — for the rest of the function
  return value.toUpperCase();
}

After if (value === null) return, TypeScript knows value cannot be null for the rest of the function — so the whole body below works with a narrowed string, no nesting. This "handle the special cases at the top, then work with the narrowed value" shape is idiomatic and readable, and it is exactly the same pattern good code uses in every language — TypeScript just makes it type-safe.

One caveat: narrowing can be lost. If you narrow a value and then call a function (which might, in principle, change it), or reassign it, TypeScript may widen it back. For a let variable modified between the check and the use, or a property accessed through something the compiler cannot prove is stable, the narrowing may not hold — copying into a const local usually fixes it. This is rare but worth recognising when a narrowing "does not stick".

Check your work

What narrowing is. Writing an ordinary check that TypeScript follows, narrowing a value's type inside the branch where the check holds.

typeof narrowing. For primitives — typeof x === "string" narrows to string; note the trap typeof null === "object".

Truthiness narrowing. if (value) removes falsy values (null, undefined, "", 0, ...); use x != null to remove only null/undefined and keep ""/0.

Equality narrowing. === against a literal narrows to it; comparing two unions narrows both to their common type.

in narrowing. Checking a property's presence narrows to the object member that has it — for untagged object unions.

instanceof narrowing. Narrows to a class instance — for unions including class instances.

The early-return pattern. Handle special cases at the top with return; the narrowed type flows through the rest of the function.

When narrowing is lost. After a reassignment or an intervening call the compiler cannot prove is safe — copy into a const to restore it.

Practice

  1. Narrow a string | number with typeof and use a type-specific method in each branch.
  2. Narrow a string | null | undefined with if (value). Then test with an empty string and note it is treated as absent. Switch to value != null and confirm "" survives.
  3. Narrow a literal union with === and act differently per literal.
  4. Narrow an untagged object union (Admin | User with different properties) using in.
  5. Narrow a Date | string union with instanceof.
  6. Use the early-return pattern to handle a null at the top, then work with the narrowed value below.
  7. Narrow a let variable, reassign it, and observe the narrowing is lost. Fix it with a const.

Official documentation

Next: discriminated unions — the best pattern in the language.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship