RizTech Academy logo
RizTech Academy
Unions and NarrowingLesson 1 of 525 min

Union types

A union type says a value is "one of these types". You have already used them — string | null, "pending" | "paid", string | number — but this module treats them as the powerful idea they are. Unions plus narrowing (the next lessons) are what make TypeScript genuinely expressive rather than merely a spell-checker, so it is worth slowing down here.

The idea

A union is written with | between types, and means "a value of any one of these":

let id: string | number;
id = "abc";      // fine
id = 42;         // fine
id = true;       // error: Type 'boolean' is not assignable to type 'string | number'

string | number is the type of a value that is either a string or a number — but not a boolean, and not both at once. Unions appear everywhere:

type Status = "pending" | "paid" | "shipped";        // a union of literal types
type Nullable = string | null;                        // a value or null
type Id = string | number;                            // two primitive options
type Result = { ok: true; data: string } | { ok: false; error: string };   // two object shapes

That last one — a union of two object shapes — is the most powerful form, and the discriminated-unions lesson builds an entire pattern on it. For now, note the range: unions combine literals, primitives, null, and whole object types.

The restriction: you can only use what every member shares

Here is the crucial rule, and the reason narrowing exists. When a value is a union, TypeScript only lets you do things that are valid for every member of the union:

function describe(id: string | number) {
  id.toString();          // fine — both string and number have toString
  id.toUpperCase();       // error: 'toUpperCase' does not exist on type 'number'
  id.toFixed(2);          // error: 'toFixed' does not exist on type 'string'
}

id might be a string or a number. toString() works because both have it. But toUpperCase() is a string method that a number lacks, and toFixed() is a number method a string lacks — so TypeScript forbids each, because it cannot guarantee the value is the right one. A union only exposes the common capabilities of its members, which is exactly the safe thing to do: you cannot call a string method on something that might be a number.

This restriction is not a limitation to fight — it is the safety. And the way you lift it, to access type-specific behaviour, is narrowing.

Narrowing: proving which member you have

To use string-specific or number-specific behaviour, you first prove which type the value is, with an ordinary check — and inside that check, TypeScript narrows the type:

function describe(id: string | number): string {
  if (typeof id === "string") {
    return id.toUpperCase();   // here TypeScript KNOWS id is a string — toUpperCase is allowed
  }
  return id.toFixed(2);        // here TypeScript knows id is a number — toFixed is allowed
}

Inside if (typeof id === "string"), TypeScript narrows id from string | number down to just string, so all string methods are available. After the if (in the return below), it knows id must be the other member, number, so number methods are available. You use a normal JavaScript check, and TypeScript follows your reasoning, narrowing the type as you rule out possibilities. The next lesson is all the ways to narrow; this one plants the pattern: a union restricts you to the common capabilities until you narrow to a specific member.

Unions versus any — precision, not surrender

It is worth contrasting a union with any, because beginners sometimes use any where a union is right:

function process(value: any) { ... }              // gives up all safety
function process(value: string | number) { ... }  // precise — exactly two possibilities, both checked

any says "I do not care what this is" and turns off checking. A union says "this is exactly one of these specific types" and keeps full checking — you just have to narrow before using type-specific features. When you find yourself reaching for any because "it could be a few different things", a union is almost always what you actually want: it captures the real possibilities and keeps the compiler helping you.

Unions of objects — a taste

The most powerful unions combine object shapes, modelling "this is one of a few kinds of thing":

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "rectangle"; width: number; height: number };

function area(shape: Shape): number {
  if (shape.kind === "circle") {
    return Math.PI * shape.radius ** 2;      // narrowed to the circle — radius available
  }
  return shape.width * shape.height;          // narrowed to the rectangle — width/height available
}

Shape is "a circle or a rectangle", each with its own properties. The kind property tells you which one you have, and checking it narrows the type so the right properties become available. This is the discriminated union — the single best pattern in TypeScript, and the subject of its own lesson. For now, register the shape of it: a union of object types, each tagged with a common literal property, narrowed by checking that property.

Check your work

What a union type is. A value that is one of several types — string | number is either a string or a number (not both, not a boolean).

What forms a union can combine. Literals, primitives, null, and whole object shapes.

The key restriction. A union only exposes what every member shares; type-specific methods are forbidden until you narrow.

Why that restriction is safety. You cannot call a string method on something that might be a number.

What narrowing does. A normal check (like typeof x === "string") proves which member you have, and TypeScript narrows the type inside that branch so type-specific behaviour becomes available.

Union versus any. A union captures the exact possibilities and keeps checking; any surrenders checking entirely — prefer a union when a value could be a few specific things.

The most powerful union. A union of object shapes tagged with a common literal property — the discriminated union.

Practice

  1. Declare let id: string | number. Assign a string, a number, and a boolean; confirm which is rejected.
  2. In a function taking string | number, call toString() (works), toUpperCase() and toFixed() (both error). Explain why.
  3. Narrow with if (typeof id === "string") and call toUpperCase() inside; confirm it now works, and that id is a number after the branch.
  4. Replace an any parameter that "could be a string or a number" with a string | number union and note the added safety.
  5. Write a Shape union of a circle and a rectangle and an area function that narrows on kind.
  6. Try to access shape.radius before narrowing and read the error. Then narrow and confirm it works.
  7. Write a union of three literal statuses and a function that behaves differently for each.

Official documentation

Next: narrowing, in all its forms.

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