Lesson 4 of 5

Union Types & Narrowing

JavaScript developers write defensive checks like if (typeof id === "string") constantly, and mostly blind — you're guarding against a value that might be a string or a number, but nothing in the language told you which, or even that both were possible. TypeScript lets you write that possibility into the type itself, so the compiler knows exactly when a check is required and holds you to actually doing it.

💡 The Bottom Line

A union type, written string | number, means "this value is one of these types — could be either." TypeScript won't let you call a string-only or number-only method on a union value until you narrow it — typically with typeof or instanceof — down to one specific member of the union. The check itself is exactly what a JS developer already writes defensively; the difference is the compiler now enforces that you wrote it.

An id that can be a string or a number

JavaScript
function printId(id) {
  console.log(id.toUpperCase());
}

printId("abc123"); // fine
printId(42);
// crashes at runtime:
// id.toUpperCase is not a function
TypeScript
function printId(id: string | number) {
  console.log(id.toUpperCase());
  // compile error: toUpperCase does
  // not exist on type 'number'
}

📋 Key differences

  • string | number is a union type — the pipe | means "or."
  • On a union, TypeScript only allows operations that are valid for every member of the union — toUpperCase() isn't valid on number, so it's rejected even though it would work fine for the string branch.
  • The JavaScript version only fails when someone actually calls printId(42); the TypeScript version fails immediately at the definition, regardless of how it's called.

Narrowing with typeof

To use a member-specific method, first prove to the compiler which branch of the union you're in. A typeof check inside an if does exactly that — inside the branch, TypeScript treats id as just string, and in the else, just number.

Narrowed with typeof
function printId(id: string | number) {
  if (typeof id === "string") {
    console.log(id.toUpperCase());
    // id is `string` here
  } else {
    console.log(id.toFixed(2));
    // id is `number` here
  }
}
Equivalent defensive JS
function printId(id) {
  if (typeof id === "string") {
    console.log(id.toUpperCase());
  } else {
    console.log(id.toFixed(2));
  }
}
// same check, but nothing forces
// you to write it, and nothing
// tells you id could be either

Narrowing with instanceof

For unions of classes rather than primitives, instanceof narrows the same way typeof does for primitives.

Two possible error shapes
class NetworkError {
  constructor(public status: number) {}
}
class ValidationError {
  constructor(public field: string) {}
}

function handle(err: NetworkError | ValidationError) {
  if (err instanceof NetworkError) {
    console.log("HTTP", err.status);
    // err is NetworkError here
  } else {
    console.log("bad field:", err.field);
    // err is ValidationError here
  }
}
Without narrowing (rejected)
function handle(err: NetworkError | ValidationError) {
  console.log(err.status);
  // compile error: 'status' does not
  // exist on type 'ValidationError'
}

⚠️ Gotcha

Narrowing only survives as long as TypeScript can statically trace the control flow. Extract the typeof check into a separate helper function like function isString(x: string | number) { return typeof x === "string"; } and call that instead of checking inline, and the narrowing is lost the moment you use the helper's return value — TypeScript doesn't automatically know your boolean function's true branch means "it's a string" unless you write a type predicate (x is string as the return type). Until later lessons cover that, keep narrowing checks inline.

🔧 Try It Yourself

Write a function format(value: string | boolean) that returns the value as a display string — for a string, return it trimmed; for a boolean, return "yes"/"no". Use a typeof check to narrow, and deliberately try calling .trim() before the check to see TypeScript reject it.

🙋 There Are No Dumb Questions

Q: If I already write typeof checks defensively in JavaScript, what does the type system actually add?
A: Enforcement and completeness. In JavaScript, nothing stops you from forgetting the check on one code path, and nothing tells you a value could ever be two different types in the first place — you find out from a bug report. In TypeScript, the union type string | number is documented right in the signature, and the compiler refuses to let member-specific code compile until the narrowing check is actually present.

✅ Quick Check

Inside if (typeof value === "string") { ... }, where value: string | number, what type does TypeScript treat value as inside the block?


Next up: time for the recap — but first, a lesson on the traps that catch even developers who've internalized everything so far: any, strict mode, and untyped libraries.

← Back to
Next →