intermediate24 min

Union Types and Narrowing

Say a value may be one of several types, then prove which one it is before using it.

What you'll learn

  • Declare a union type and explain what operations it permits
  • Narrow a union with a typeof or equality check
  • Recognise why the compiler rejects a member-specific operation on an un-narrowed union

Prerequisites

Explanation

Some values genuinely have more than one possible type. An id might arrive as a number from a database and a string from a URL. A union says so, with |:

type Id = string | number;

A union permits only what all members permit

This is the rule that explains every union error you will hit:

function printId(id: string | number) {
  console.log(id.toUpperCase()); // rejected
}

toUpperCase exists on string but not on number, so TypeScript refuses. It cannot know which one arrived, and it will not let you gamble. The error is not the compiler being awkward — it is pointing at a real crash that would happen whenever a number is passed.

Narrowing

You fix it by proving which member you have. TypeScript follows ordinary JavaScript checks and updates the type inside each branch — this is control flow analysis:

function printId(id: string | number) {
  if (typeof id === "string") {
    console.log(id.toUpperCase()); // here, id is string
  } else {
    console.log(id.toFixed(0));    // here, id is number
  }
}

Nothing special was added. A plain typeof check is enough, because the compiler models what your code has already established. Inside the if, id is a string; in the else, the only remaining possibility is number, so that is what it becomes.

Several everyday checks narrow:

  • typeof x === "string" for primitives
  • x === "published" for literal unions
  • Array.isArray(x) for arrays
  • a truthiness check like if (x) for removing null/undefined

Unions of literals

Unions are not limited to primitive types. A union of literal types is one of TypeScript's most useful patterns:

type Status = "draft" | "review" | "published";

Now "pubished" (typo) is a compile error rather than a value that silently fails a comparison forever. Lesson 10 goes further with this idea.

Exhaustiveness

When you narrow a literal union across branches and handle every member, the final else receives a value of type never — the type with no possible values. That is TypeScript telling you "nothing can reach here", and it is how you get a compile error later if someone adds a fourth status and forgets a branch.

Example

The same value, two types, two safe paths. Notice each branch permits different methods.

type Id = string | number;

function formatId(id: Id): string {
  if (typeof id === "string") {
    return id.toUpperCase();
  }
  return "#" + id.toFixed(0);
}

console.log(formatId("ab-42"));
console.log(formatId(7));

type Status = "draft" | "review" | "published";

function label(status: Status): string {
  if (status === "draft") return "Not ready";
  if (status === "review") return "Being checked";
  return "Live";
}

console.log(label("review"));

Try it yourself

Remove the typeof check and press Run — read how the compiler explains the problem.

Code editor. Press Escape then Tab to leave the editor if keyboard focus becomes trapped. Press Control+Shift+M inside the editor to toggle Tab-key focus trapping.

Loading editor…

Guided exercise

Guided exercise

Write `describeValue(value: string | number): string`. For a string return its length as `"text of length N"`. For a number return `"number N"`. Use a typeof check to narrow.

Checks: describeValue is defined · describeValue("hello") returns "text of length 5" · describeValue(42) returns "number 42"

Code editor. Press Escape then Tab to leave the editor if keyboard focus becomes trapped. Press Control+Shift+M inside the editor to toggle Tab-key focus trapping.

Loading editor…

Stuck? Get a hint.

Independent exercise

Independent exercise

Define `type Shape = "circle" | "square"`. Write `area(shape: Shape, size: number): number` returning the circle area (Math.PI * size * size, where size is the radius) or the square area (size * size). Round the result to 2 decimals with Math.round(x * 100) / 100.

Checks: area is defined · area("square", 3) returns 9 · area("circle", 2) returns about 12.57

Code editor. Press Escape then Tab to leave the editor if keyboard focus becomes trapped. Press Control+Shift+M inside the editor to toggle Tab-key focus trapping.

Loading editor…

Stuck? Get a hint.

Common mistakes

  • Calling a member-specific method before narrowing. A union only permits what every member supports.
  • Assuming narrowing persists across a callback boundary. The compiler re-analyses inside a new function scope.
  • Writing `string | number` when a literal union like `"draft" | "published"` would have caught typos too.

Knowledge check

Knowledge check

1. Why is `id.toUpperCase()` rejected when `id: string | number`?
2. In `if (typeof v === "string") { … } else { … }` where `v: string | number`, what is `v` in the else branch?
3. What is the advantage of `"draft" | "published"` over `string`?

Takeaway

A union restricts you to what all members share; narrowing with ordinary JavaScript checks unlocks the rest.

Summary

Unions (`A | B`) describe values with several possible types and permit only shared operations. `typeof`, equality, `Array.isArray`, and truthiness checks narrow the type per branch through control flow analysis.

References

Your notes

Notes save automatically.

Finished this lesson?

Mark it complete to track your progress and schedule a future review.