Skip to content

Unions and Narrowing

Unions let a value be one of several types. Narrowing is how TypeScript works out which one at each point in your code. Together they are the part of the type system that feels genuinely smart, and the part that models real JavaScript best.

A union is written with | and means “any one of these”.

type Id = string | number;
function format(id: Id): string {
return `#${id}`;
}

You can only access members that exist on every member of the union:

function shout(x: string | number) {
return x.toUpperCase();
// error: Property 'toUpperCase' does not exist on type 'string | number'.
// Property 'toUpperCase' does not exist on type 'number'.
}

That’s the union’s whole contract: until you prove which branch you’re in, you get the intersection of the available operations.

Unions can contain literals, objects, functions, null, anything:

type Result = 'ok' | 'error';
type Nullable<T> = T | null;
type Callback = ((err: Error) => void) | null;

& combines types — the value must satisfy all of them at once.

interface HasId { id: string }
interface HasTimestamps { createdAt: Date; updatedAt: Date }
type Entity = HasId & HasTimestamps;
const e: Entity = {
id: 'a1',
createdAt: new Date(),
updatedAt: new Date(),
};

Useful for mixing in shared fields:

type WithLoading<T> = T & { isLoading: boolean };

Two things to know:

Conflicting primitive members collapse to never.

type Bad = { x: number } & { x: string };
type X = Bad['x']; // never — no value is both

The declaration itself doesn’t error; you find out at the assignment site, with a confusing message. interface extends catches the same mistake immediately — see functions and objects.

Intersecting unrelated primitives gives never.

type Impossible = string & number; // never

Narrowing is the compiler’s control-flow analysis: it tracks what a variable’s type must be along each path through your code. Every technique below is something TypeScript recognises specially.

function pad(value: string | number, width: number): string {
if (typeof value === 'number') {
return value.toFixed(2).padStart(width); // value: number
}
return value.padStart(width); // value: string
}

TypeScript understands all of typeof’s results: 'string', 'number', 'bigint', 'boolean', 'symbol', 'undefined', 'object', 'function'.

For anything constructed from a class:

function describe(err: Error | string) {
if (err instanceof TypeError) {
return `type error: ${err.message}`; // err: TypeError
}
if (err instanceof Error) {
return err.message; // err: Error
}
return err; // err: string
}

instanceof narrows using the constructor’s prototype property, so it works with your own classes too. It fails across realms (iframes, worker boundaries, some bundler setups) — that’s a JavaScript limitation, not a TypeScript one.

Checks for a property, which is the natural way to distinguish object shapes:

interface Circle { radius: number }
interface Square { side: number }
function area(shape: Circle | Square): number {
if ('radius' in shape) {
return Math.PI * shape.radius ** 2; // shape: Circle
}
return shape.side ** 2; // shape: Square
}

Since TypeScript 4.9, in also narrows an unknown-typed value to object with that key present:

function f(x: unknown) {
if (typeof x === 'object' && x !== null && 'name' in x) {
x.name; // unknown, but accessible
}
}
function print(name?: string) {
if (name) {
console.log(name.toUpperCase()); // name: string
}
}
// Early return / guard clause — the common idiom.
function load(el: HTMLElement | null) {
if (!el) return;
el.focus(); // el: HTMLElement
}

x != null (loose inequality) is special-cased: it excludes both null and undefined in one check, and TypeScript understands it.

Comparing two values narrows both of them:

function compare(a: string | number, b: string | boolean) {
if (a === b) {
a.toUpperCase(); // a: string
b.toUpperCase(); // b: string — the only overlapping type
}
}

Comparing against a literal narrows to that literal:

function move(dir: 'up' | 'down' | 'left' | 'right') {
if (dir === 'up' || dir === 'down') {
dir; // 'up' | 'down'
} else {
dir; // 'left' | 'right'
}
}

If the types have no overlap at all, TypeScript reports it rather than narrowing to never:

const s: string = 'a';
if (s === 42) {} // error: This comparison appears to be unintentional
// because the types 'string' and 'number' have no overlap.

Array.isArray is declared with a type predicate in the standard library, so it narrows:

function normalise(input: string | string[]): string[] {
return Array.isArray(input) ? input : [input];
}

Assigning to a variable narrows it to the assigned value’s type, within its declared type:

let x: string | number = 'hello';
x.toUpperCase(); // x: string
x = 42;
x.toFixed(); // x: number
x.toUpperCase(); // error

The declared type never changes — only what the compiler knows at each point.

Since TypeScript 4.4, narrowing survives being stored in a const:

function f(x: string | number | null) {
const isString = typeof x === 'string';
if (isString) {
x.toUpperCase(); // works — the condition was aliased
}
}

This requires const (not let) and a directly-analysable condition. Destructured discriminants work too:

type Action = { kind: 'add'; value: number } | { kind: 'reset' };
function reduce(action: Action) {
const { kind } = action;
if (kind === 'add') {
action.value; // narrowed via the destructured discriminant
}
}

The single most useful pattern in TypeScript. Give every member of a union a shared property with a distinct literal type — the discriminant — and narrowing becomes trivial and exhaustive.

type NetworkState =
| { status: 'idle' }
| { status: 'loading'; startedAt: number }
| { status: 'success'; data: string[] }
| { status: 'error'; error: Error; retryable: boolean };
function render(state: NetworkState): string {
switch (state.status) {
case 'idle':
return 'Ready';
case 'loading':
return `Loading since ${state.startedAt}`;
case 'success':
return `${state.data.length} items`; // `data` only exists here
case 'error':
return state.retryable ? 'Retrying' : state.error.message;
}
}

Each case narrows state to exactly one member, so you get precise property access with no casts and no optional chaining. The alternative — one interface with everything optional — forces defensive checks everywhere and lets impossible states exist ({ status: 'idle', data: [...] }).

Requirements for a discriminant:

  • The property must exist on every member of the union.
  • Its type must be a literal (string, number, or boolean literal) — not string.
// This does NOT discriminate: `kind` is `string` on both.
type Bad = { kind: string; a: number } | { kind: string; b: number };

Watch for literal widening. If you build the value in a mutable variable, status widens to string and discrimination breaks — annotate or use as const.

When narrowing needs logic TypeScript can’t infer, write a function returning x is T.

interface Cat { meow(): void }
interface Dog { bark(): void }
function isCat(animal: Cat | Dog): animal is Cat {
return 'meow' in animal;
}
function speak(animal: Cat | Dog) {
if (isCat(animal)) {
animal.meow(); // Cat
} else {
animal.bark(); // Dog
}
}

The animal is Cat return type is a type predicate. To the compiler, “this function returned true” means “the argument is a Cat”. The parameter named in the predicate must be a parameter of that function.

Guards over unknown are how you safely handle external data:

interface User { id: number; email: string }
function isUser(value: unknown): value is User {
return (
typeof value === 'object' &&
value !== null &&
'id' in value && typeof (value as Record<string, unknown>).id === 'number' &&
'email' in value && typeof (value as Record<string, unknown>).email === 'string'
);
}
const parsed: unknown = JSON.parse(raw);
if (isUser(parsed)) {
parsed.email; // safe
}

Inferred type predicates (TypeScript 5.5+)

Section titled “Inferred type predicates (TypeScript 5.5+)”

From 5.5, TypeScript infers predicates for simple functions, which fixes the classic filter annoyance:

const values = ['a', null, 'b'];
// TS 5.5+: inferred as (x: string | null) => x is string
const isNonNull = (x: string | null) => x !== null;
const filtered = values.filter(isNonNull); // string[]

Before 5.5 (or for anything more complex) you write the predicate explicitly:

const filtered = values.filter((x): x is string => x !== null);

Array.prototype.filter has an overload taking a predicate, which is why the above works. find does too. some and every do not narrow the array itself.

An assertion function throws if a condition doesn’t hold, and tells the compiler the condition held afterwards. Two forms:

// Form 1: asserts a condition.
function assert(condition: unknown, message = 'Assertion failed'): asserts condition {
if (!condition) throw new Error(message);
}
// Form 2: asserts a type for a specific parameter.
function assertIsString(value: unknown): asserts value is string {
if (typeof value !== 'string') {
throw new TypeError(`Expected string, got ${typeof value}`);
}
}

Use:

function process(input: unknown) {
assertIsString(input);
input.toUpperCase(); // input: string from here on
}
function get(map: Map<string, number>, key: string): number {
const value = map.get(key);
assert(value !== undefined, `missing key: ${key}`);
return value; // number
}

Assertion functions differ from type guards in that they narrow for the rest of the enclosing scope rather than inside an if. They also run — the throw is real, unlike a type assertion (as) which does nothing at runtime.

When you handle every member of a union, the residual type is never. Assign it to a never variable and the compiler will error the day someone adds a new member.

type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'square'; side: number }
| { kind: 'rect'; width: number; height: number };
function area(shape: Shape): number {
switch (shape.kind) {
case 'circle': return Math.PI * shape.radius ** 2;
case 'square': return shape.side ** 2;
case 'rect': return shape.width * shape.height;
default: {
const exhaustive: never = shape;
throw new Error(`Unhandled shape: ${JSON.stringify(exhaustive)}`);
}
}
}

Add { kind: 'triangle'; base: number; height: number } to Shape and you get:

error TS2322: Type '{ kind: "triangle"; ... }' is not assignable to type 'never'.

pointing at the exact function you forgot to update. This turns “add a case” from a code-review concern into a compiler error.

A reusable helper reads better:

function assertNever(value: never, message = 'Unexpected value'): never {
throw new Error(`${message}: ${JSON.stringify(value)}`);
}
// default: return assertNever(shape, 'Unhandled shape');

The compiler simulates every path. A few behaviours worth internalising:

Narrowing resets after a function call it can’t see through.

let value: string | null = getValue();
if (value !== null) {
doSomething();
value.length; // still narrowed — TS assumes calls don't mutate locals
}

Narrowing survives into a closure only if the variable is never reassigned.

function f(x: string | null) {
if (x !== null) {
setTimeout(() => x.length, 0); // fine — `x` is never reassigned
}
}
function g(x: string | null) {
if (x !== null) {
setTimeout(() => x.length, 0);
// error: 'x' is possibly 'null'
}
x = null; // this assignment is what breaks it
}

Because x is assigned somewhere in g, the compiler can’t know its value when the callback eventually runs, so it falls back to the declared type. Copying into a const fixes it.

Object properties narrow, but the narrowing is fragile.

interface Box { value?: string }
function f(box: Box) {
if (box.value) {
box.value.length; // fine
mutate(box);
box.value.length; // still considered narrowed — TS can't see the mutation
}
}

The safe habit is to copy to a local first:

const value = box.value;
if (value) { /* `value` is string, and nothing can change it */ }

Optional chaining narrows too.

function f(user?: { profile?: { name: string } }) {
if (user?.profile) {
user.profile.name; // both narrowed
}
}
  • A union gives you only the members common to all branches until you narrow.
  • Intersections merge object types; conflicting primitive members become never.
  • Narrowing works via typeof, instanceof, in, truthiness, equality, assignment, and Array.isArray — plus const-aliased conditions since TS 4.4.
  • Discriminated unions (shared literal property) are the default way to model variants.
  • x is T guards and asserts x is T functions extend narrowing to your own logic — and their bodies are unchecked, so get them right.
  • Assign the residual union to never to make missing cases a compile error.
  • Narrowing on object properties is fragile; copy to a local when it matters.