Free Handbook · Runs in your browser

Unions, Intersections & Narrowing

Model a value that could be one of several shapes, combine shapes into one, and let the compiler prove every case is actually handled before your code runs.

0 / 105 lessons🔥 0 day streak
ShareXLinkedIn

Module 07 · what you'll be able to do

  • Write and use union types for values that can be one of several types
  • Combine object shapes with intersection types
  • Design a discriminated union and narrow it safely in a switch
  • Use typeof, instanceof and in as type guards
  • Write a user-defined type guard and use never to catch a missed case
01

Union types

A union type, written A | B, means a value could be an A or a B — but only one at a time, and you do not know which until you check. Before checking, the compiler only lets you use operations that are valid on every member of the union; checking which one you actually have is called narrowing, and it is the subject of this whole module.

typescriptEdit it. ⌘/Ctrl + Enter runs.
You should see
#7
ABC
Your turn
Add a third accepted type, boolean, to the union, and a branch that returns "yes" or "no" for it.
Error you will hit

TS2339: calling a method that not every union member has

typescript
function shout(x: string | number): string {
  return x.toUpperCase()
}
app.ts:2:12 - error TS2339: Property 'toUpperCase' does not exist on type 'string | number'.
  Property 'toUpperCase' does not exist on type 'number'.

2   return x.toUpperCase()
           ~~~~~~~~~~~
Why the compiler said that

toUpperCase exists on string but not on number, and x could be either one here. TypeScript refuses to call a method unless it exists on every member of the union — otherwise this would crash at runtime whenever x happened to be a number.

The fix

Narrow first with a type guard before calling the string-only method.

typescript
function shout(x: string | number): string {
  return typeof x === "string" ? x.toUpperCase() : String(x)
}
Union type
A type meaning "one of these", written with a pipe: string | number.
Narrowing
Checking a value at runtime so the compiler can shrink a union down to one specific member inside that branch.
JuniorWhat does string | number mean as a parameter type, and what can you safely do with a value of that type before narrowing it?

It means the value could be a string or could be a number, and you do not know which until you check. Before narrowing, the compiler only allows operations that are valid on both — like comparing with === or passing it to something that accepts either — never a method that exists on only one of the two types.

What they are really testing: Understanding that a union is "could be any of these," not "has every capability of all of these."

02

Intersection types

Where a union means "one of these," an intersection type, written A & B, means "all of these at once." A value typed A & B must satisfy every field both A and B require — it is how you combine two smaller shapes into one larger one without redeclaring the fields.

typescriptEdit it. ⌘/Ctrl + Enter runs.
You should see
Kai is 29
Your turn
Add a third interface, HasEmail { email: string }, fold it into the intersection, and include it in the object and the printed line.
Union vs. intersection, side by side
A | B shrinks what you can safely do with a value (only what both share) until you narrow it. A & B grows what the value is required to have (everything both demand) from the moment it is created — the two operators pull in opposite directions.
JuniorWhen would you use an intersection type instead of just extending one interface with another?

They often produce the same result for object shapes, but an intersection also works for combining things that are not both plain object interfaces — a type alias with a union member, or two independently defined types you do not control and cannot make one extend the other. extends also only works between interfaces (or a class and its interfaces); & works between any two type expressions.

What they are really testing: Whether the candidate understands intersections generalize beyond interface inheritance.

03

Discriminated unions

A discriminated union is a union of object types that all share one common property — the discriminant, often named kind or type — where each variant gives that property a distinct literal value. Checking just that one field lets the compiler narrow the entire object to the matching variant, including every field that variant alone has.

typescriptEdit it. ⌘/Ctrl + Enter runs.
You should see
13
16
Your turn
Add a third variant, { kind: "rectangle"; width: number; height: number }, and a matching case in the switch.
VisualizeHow the switch narrows Shape down to one variantStep 1 / 4
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number }
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.round(Math.PI * shape.radius * shape.radius)
case "square":
return shape.side * shape.side
}
}
console.log(area({ kind: "circle", radius: 2 }))
Line 12

area(...) is called with an object whose kind field is the literal string "circle".

Variables now
shape.kind'circle'
All 4 steps as a table
StepLineWhat happenedVariables now
112area(...) is called with an object whose kind field is the literal string "circle".shape.kind = 'circle'
25switch (shape.kind) reads the discriminant. TypeScript narrows the type of shape separately inside each case branch, based only on this one field.
37Inside case "circle": the compiler now treats shape as specifically { kind: "circle"; radius: number } — reading shape.radius is legal here, and would be a type error inside the "square" branch.
47Math.round(Math.PI * 2 * 2) evaluates to 13.
Why the discriminant needs a literal type
If kind were typed as plain string instead of the literal "circle" / "square", checking shape.kind === "circle" would not narrow anything — the compiler cannot distinguish variants by a field whose type does not pin down which variant it belongs to. The literal type on each variant is what makes narrowing possible.
Mid-levelWhat makes a union "discriminated," and why does the discriminant property need a literal type?

It is discriminated when every member of the union is an object type that shares one common property whose value is a distinct literal per variant. Because each variant's literal differs, checking that single field in a switch or if narrows the whole object down to the matching variant. If that field were typed as a plain string instead, the compiler would have no way to tell the variants apart from one check, and narrowing would not work.

What they are really testing: Whether the candidate understands why literal types make narrowing possible, not just the pattern's syntax.

04

Type guards: typeof, instanceof, in

Beyond typeof for primitives, TypeScript recognizes two more checks as narrowing: instanceof for class instances, and the in operator for checking whether a named property exists on an object at runtime — the one that works for plain object shapes that share no common class or primitive type.

typescriptEdit it. ⌘/Ctrl + Enter runs.
You should see
meow
beep
Your turn
Add a third class, Alarm, with a ring() method, include it in the union and the instanceof chain.
typescriptEdit it. ⌘/Ctrl + Enter runs.
You should see
swims
flies
Your turn
Fish and Bird are plain object types, not classes, so instanceof cannot distinguish them — try replacing "swim" in animal with animal instanceof Fish and see why it does not even compile.
GuardNarrowsWorks on
typeof x === "string"Primitivesstring, number, boolean, symbol, bigint, undefined, function, object
x instanceof ClassNameClass instancesAnything created with new against a class, via its prototype chain
"prop" in xPlain object shapesAny object union where the variants differ by which properties exist
Mid-levelWhat's the difference between typeof, instanceof and the in operator as type guards?

typeof narrows primitives by comparing against a string like "string" or "number". instanceof narrows class instances by checking the prototype chain, so it only works when the union members are actual classes. The in operator checks whether a named property exists on the object at runtime, which is the one that works for plain object union members that share no common class and no typeof-distinguishable primitive type.

What they are really testing: Whether the candidate knows to reach for the right guard given the actual shape of the union.

Quick check

You have type Input = { swim(): void } | { fly(): void }. Which check safely narrows the type inside an if block?

05

User-defined type guards

When none of the built-in checks fit, you can write your own narrowing function. Give it a return type of x is SomeType instead of boolean, and the compiler treats a truthy call to it as proof that the argument really is that type in every branch afterward — even though the function body itself just returns an ordinary boolean.

typescriptEdit it. ⌘/Ctrl + Enter runs.
You should see
HI
not a string
Your turn
Write a second guard, isNumber(x: unknown): x is number, and add a branch in describe that formats it with toFixed(1).
x is Type is a promise you have to keep yourself
The compiler trusts the return type annotation completely — it does not check that the function body's logic actually matches what it claims to prove. Writing function isString(x: unknown): x is string { return true } compiles fine and will narrow incorrectly, silently. The safety only holds if the check inside genuinely matches the claim.
Type predicate
The x is Type return type annotation that marks a function as a user-defined type guard.
unknown
A type that could be anything, like any, but unlike any it forces you to narrow before doing anything type-specific with it — the safe alternative to any for values of unknown shape.
06

Exhaustiveness checking with never

A switch over a union can leave a variant unhandled without any warning by default. The fix is a well-known idiom: in the default branch, after every real case, assign the leftover value to a variable typed never. If every case really was handled, nothing is left and the assignment compiles fine. If a variant slips through, the assignment fails to compile immediately, at the exact place the missing case would need to go.

typescriptEdit it. ⌘/Ctrl + Enter runs.
You should see
12
Your turn
Add a fourth variant, { kind: "pentagon"; sideLength: number }, without adding a matching case — notice the task afterward is what the next error card shows happening at compile time.
Error you will hit

TS2322: a new variant slips past the exhaustiveness check

typescript
type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; side: number }
  | { kind: "triangle"; base: number; height: number }
function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.round(Math.PI * shape.radius * shape.radius)
    case "square":
      return shape.side * shape.side
    default:
      const exhaustive: never = shape
      return exhaustive
  }
}
app.ts:11:13 - error TS2322: Type '{ kind: "triangle"; base: number; height: number; }' is not assignable to type 'never'.

11       const exhaustive: never = shape
               ~~~~~~~~~~
Why the compiler said that

After the "circle" and "square" cases are removed, the only thing shape could still be in the default branch is the triangle variant. Assigning a real, non-empty type to a never-typed variable is not allowed, so the compiler flags exactly the case that was forgotten.

The fix

Add the missing case with real handling.

typescript
function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.round(Math.PI * shape.radius * shape.radius)
    case "square":
      return shape.side * shape.side
    case "triangle":
      return (shape.base * shape.height) / 2
    default:
      const exhaustive: never = shape
      return exhaustive
  }
}
SeniorHow does assigning a variable to never catch a missing switch case at compile time, and what breaks this pattern?

In the default branch of a switch over a discriminated union, once every named case above it is handled, TypeScript narrows whatever is left to the type of the unhandled variants. Assigning that leftover to a never-typed variable only compiles if it truly is empty — if a new union member is added later without a matching case, the leftover type is no longer never, and the assignment fails to compile, catching the gap immediately at the right line. It stops working if the switch does not actually discriminate on a literal property, or if a case falls through without narrowing correctly.

What they are really testing: Deep understanding of the never-based exhaustiveness idiom, a favorite senior-level TypeScript interview topic.

Error you will hit

TS2367: comparing a union against a value it cannot hold

typescript
type Status = "active" | "inactive"
function check(status: Status): boolean {
  return status === "pending"
}
app.ts:3:10 - error TS2367: This comparison appears to be unintentional because the types 'Status' and '"pending"' have no overlap.

3   return status === "pending"
           ~~~~~~~~~~~~~~~~~~~~~~
Why the compiler said that

Status can only ever be "active" or "inactive". "pending" is not a member of that union, so the comparison can never be true under any possible input, and is almost certainly a typo or a forgotten union member.

The fix

Compare against a value that is actually part of the union, or add the missing member to the union type.

typescript
function check(status: Status): boolean {
  return status === "inactive"
}
Quick check

Why does the never exhaustiveness pattern fail to compile when a new union variant is added without a matching case?

Finish the TypeScript handbook, then get hired

Sit the exam for your certificate, run your resume through the ATS checker, and see the jobs that ask for exactly this.

Check my resume
Found this course useful? Share it.
ShareXLinkedIn

Comments

0

Join the conversation. Sign in to leave a comment — we'd love to hear your thoughts.