bastianplsfix

Unions and narrowing

The what a type is page defined a type as a set of values. A union type is the union of sets: string | number is every string together with every number, and a value of that type is unusable until you establish which side of the union you are holding. Working that out is called narrowing, and it needs no special syntax. TypeScript reads the ordinary JavaScript checks you would have written anyway, and follows your control flow.

Create programs/unions-and-narrowing.test.ts for this reference and keep it open. Start it with the assertion helpers used on this page:

import { assertEquals, assertThrows } from "@std/assert";

Follow the page as you add and revise the runnable examples below that import.

a union is a union of sets

Subtype means subset ended with one-element types like 1000 looking useless alone. Joined into a union, they become the most useful type in everyday TypeScript. Three sets of one, joined into a set of three, and then a value from outside all three:

Deno.test("a union is a union of sets", () => {
type Status = "draft" | "sent" | "paid";
const good: Status = "sent";
assertEquals(good, "sent");
const bad: Status = "posted";
});
Check programs/unions-and-narrowing.test.ts
TS2322 [ERROR]: Type '"posted"' is not assignable to type 'Status'.
const bad: Status = "posted";
~~~
at file:///programs/unions-and-narrowing.test.ts:8:11

error: Type checking failed.

info: The program failed type-checking, but it still might work correctly.
hint: Re-run with --no-check to skip type-checking.

Read the rejection as membership, the way a type is a set, and assignment is membership taught: "sent" is a member of one of the three sets, so it flows in; "posted" is a member of none of them, so it is refused by name. That one line replaces a runtime validation, a comment, and a whole class of typo. Pin the rejection so the file proves it permanently:

Deno.test("a union is a union of sets", () => {
type Status = "draft" | "sent" | "paid";
const good: Status = "sent";
assertEquals(good, "sent");
// @ts-expect-error: "posted" is not a member of any of the three sets
const bad: Status = "posted";
assertEquals(String(bad), "posted");
});
Check programs/unions-and-narrowing.test.ts
running 1 test from ./programs/unions-and-narrowing.test.ts
a union is a union of sets ... ok (0ms)

ok | 1 passed | 0 failed (1ms)

a union member is unusable until you know which

Holding a string | number, what may you do with it? Try to read length and save:

Deno.test("a union member is unusable until you know which", () => {
function length(value: string | number): number {
const wrong = value.length;
return typeof value === "string" ? value.length : String(value).length;
}
assertEquals(length("oak"), 3);
});
Check programs/unions-and-narrowing.test.ts
TS2339 [ERROR]: Property 'length' does not exist on type 'string | number'.
Property 'length' does not exist on type 'number'.
const wrong = value.length;
~~~~~~~~~~~~
at file:///programs/unions-and-narrowing.test.ts:15:27

error: Type checking failed.

info: The program failed type-checking, but it still might work correctly.
hint: Re-run with --no-check to skip type-checking.

This is the union working correctly, and the diagnostic's second line names the offender. On a union you may only reach what every member of the set has in common, and a number has no length. The return line below the rejected one shows the repair: typeof value === "string" splits the union, and inside each branch the checker knows which member it is holding. Delete the wrong line:

Deno.test("a union member is unusable until you know which", () => {
function length(value: string | number): number {
return typeof value === "string" ? value.length : String(value).length;
}
assertEquals(length("oak"), 3);
assertEquals(length(1000), 4);
});
a union member is unusable until you know which ... ok (0ms)

the checks that narrow are ordinary JavaScript

Every check that narrows is a check you would write regardless. Line five of them up in one function:

Deno.test("the checks that narrow are ordinary JavaScript", () => {
function describe(
value: string | number | string[] | Date | null,
): string {
if (value === null) return "nothing";
if (typeof value === "string") return `string of ${value.length}`;
if (typeof value === "number") return `number ${value}`;
if (Array.isArray(value)) return `array of ${value.length}`;
return `date ${value.getTime()}`;
}
assertEquals(describe(null), "nothing");
assertEquals(describe("oak"), "string of 3");
assertEquals(describe(8), "number 8");
assertEquals(describe(["a", "b"]), "array of 2");
assertEquals(describe(new Date(0)), "date 0");
});
the checks that narrow are ordinary JavaScript ... ok (0ms)

Walk the eliminations.

  1. value === null removes the one-element set null from the union, so every line below holds string | number | string[] | Date.
  2. typeof value === "string" removes string, and inside its branch value.length is a string's length.
  3. typeof value === "number" removes number.
  4. Array.isArray(value) removes string[], and inside its branch length is an array's length.
  5. The last line has no check at all, and value.getTime() is still allowed, because with four members eliminated the checker knows only Date remains. Elimination narrows as surely as a positive test.

Comparison against a literal, typeof, Array.isArray, instanceof, in, and plain truthiness from the truthiness page all narrow this way. The checker is not adding a language; it is reading the one you already write.

in narrows a union with no tag

Sometimes the members share nothing to compare, only different properties. The in operator narrows on the presence of a key:

Deno.test("in narrows a union with no tag", () => {
type Cat = { meow: () => string };
type Dog = { bark: () => string };
function speak(pet: Cat | Dog): string {
return "meow" in pet ? pet.meow() : pet.bark();
}
assertEquals(speak({ meow: () => "meow" }), "meow");
assertEquals(speak({ bark: () => "woof" }), "woof");
});
in narrows a union with no tag ... ok (0ms)

"meow" in pet asks the question the nothing-twice page's in sees the property that reads cannot established, and here the checker listens: a true narrows pet to Cat, a false to Dog. This works, and it carries hidden costs worth naming: the reader has to know which properties distinguish the members, and adding a third member means finding every chain of in checks in the codebase. The tag in a shared literal field is a tag to switch on makes both costs go away.

in on object proves the key, not the type

in has a sharp limit, and meeting it precisely saves confusion later. On a value typed only object, check for a key and then try to use it as a number:

Deno.test("in on object proves the key, not the type", () => {
function sizeOf(value: object): number {
if ("size" in value) {
const intersected: object & Record<"size", unknown> = value;
assertEquals(intersected === value, true);
const eager: number = value.size;
return typeof value.size === "number" ? value.size : -1;
}
return 0;
}
assertEquals(sizeOf(new Set([1, 2])), 2);
assertEquals(sizeOf({}), 0);
assertEquals(sizeOf({ size: "large" }), -1);
});
Check programs/unions-and-narrowing.test.ts
TS2322 [ERROR]: Type 'unknown' is not assignable to type 'number'.
const eager: number = value.size;
~~~~~
at file:///programs/unions-and-narrowing.test.ts:53:15

error: Type checking failed.

info: The program failed type-checking, but it still might work correctly.
hint: Re-run with --no-check to skip type-checking.

value.size is unknown, and the surrounding lines explain why that is the correct answer.

  1. The intersected assignment proves what the narrowing produced: the type inside the branch is object & Record<"size", unknown>. The key exists; its value could be anything.
  2. That is faithful to what actually happened at run time: in tested the key and never looked at the value. The third call proves the caution justified, because { size: "large" } has the key holding a string.
  3. So reading the property usefully takes a second check, typeof value.size === "number", and with it the function answers 2, 0, and -1 for the three callers.

Delete the eager line and the step passes as shown above.

in on object proves the key, not the type ... ok (0ms)

a shared literal field is a tag to switch on

Give every member of a union the same field, typed as a different literal, and that field becomes a tag the checker can switch on. This is the workhorse pattern of the entry:

Deno.test("a shared literal field is a tag to switch on", () => {
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rect"; width: number; height: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rect":
return shape.width * shape.height;
}
}
assertEquals(area({ kind: "rect", width: 3, height: 4 }), 12);
assertEquals(area({ kind: "circle", radius: 1 }), Math.PI);
});
a shared literal field is a tag to switch on ... ok (0ms)

Three things to see.

  1. shape.kind is a union of two one-element sets, "circle" | "rect", and each case narrows the whole object: inside the first, shape.radius is available and shape.width does not exist.
  2. Notice what is missing: no default, and no return after the switch. The checker can see that the two cases cover the union, so the function cannot fall out of the bottom, and it demands nothing further.
  3. The pattern is called a discriminated union, and it is the shape to reach for when a value comes in several kinds: a result that succeeded or failed, a message of several types, a form field of several sorts. Reach for it before reaching for a class hierarchy.

never turns a forgotten case into a compile error

When you do write a default, one assignment inside it buys insurance. Write the switch with a case missing, and the insurance pays out immediately:

Deno.test("never turns a forgotten case into a compile error", () => {
type Status = "draft" | "sent" | "paid";
function label(status: Status): string {
switch (status) {
case "draft":
return "Draft";
case "sent":
return "Sent";
default: {
const exhaustive: never = status;
return exhaustive;
}
}
}
assertEquals(label("sent"), "Sent");
});
Check programs/unions-and-narrowing.test.ts
TS2322 [ERROR]: Type '"paid"' is not assignable to type 'never'.
const exhaustive: never = status;
~~~~~~~~~~
at file:///programs/unions-and-narrowing.test.ts:87:17

error: Type checking failed.

info: The program failed type-checking, but it still might work correctly.
hint: Re-run with --no-check to skip type-checking.

The diagnostic names exactly the member without a case. Here is the mechanism.

  1. never is the empty set, so nothing may be assigned to it, ever, unless the checker has proved that no values remain.
  2. Inside the default, elimination has removed "draft" and "sent", so status is narrowed to what is left: "paid". That is not nothing, so the assignment fails, pointing straight at the switch that needs a case.
  3. Add the "paid" case and the default becomes unreachable, status inside it narrows to the empty set, and the assignment is legal.

Add the missing case and save:

Deno.test("never turns a forgotten case into a compile error", () => {
type Status = "draft" | "sent" | "paid";
function label(status: Status): string {
switch (status) {
case "draft":
return "Draft";
case "sent":
return "Sent";
case "paid":
return "Paid";
default: {
const exhaustive: never = status;
return exhaustive;
}
}
}
assertEquals(label("sent"), "Sent");
});
never turns a forgotten case into a compile error ... ok (0ms)

This is the single highest-value trick in the entry. Add a member to Status next year, and instead of hoping you found every switch, the compiler hands you the list.

the tags are a type you can derive

Once a union has a tag, the set of tag values is itself a type, and you should never write it out by hand:

Deno.test("the tags are a type you can derive", () => {
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rect"; width: number; height: number };
type ShapeKind = Shape["kind"];

const SHAPE_NAMES: Record<ShapeKind, string> = {
circle: "a circle",
rect: "a rectangle",
};
const kind: ShapeKind = "rect";
assertEquals(SHAPE_NAMES[kind], "a rectangle");

// @ts-expect-error: Record demands one entry per tag, and rect is missing
const INCOMPLETE: Record<ShapeKind, string> = { circle: "a circle" };
assertEquals(INCOMPLETE.rect, undefined);

const SHAPE_NOTES: Partial<Record<ShapeKind, string>> = {
circle: "radius, not diameter",
};
assertEquals(SHAPE_NOTES.rect, undefined);
});
the tags are a type you can derive ... ok (0ms)

Follow the derivation and its payoff.

  1. Shape["kind"] indexes the type by a property name, which gives that property's type. Done to a union, it does it to each member and joins the results, so ShapeKind is "circle" | "rect", derived rather than repeated, and it stays correct when a member is added.
  2. Record<ShapeKind, string> demands one entry per tag, so SHAPE_NAMES is a lookup table the checker keeps complete. The pinned INCOMPLETE line records what a forgotten entry draws: a TS2741 naming the missing key. This is the never trick's exhaustiveness guarantee, in the shape people need most: labels, icons, colors, handlers, anything keyed by kind.
  3. When a map is genuinely partial, say so with Partial, which makes every entry optional and still rejects a key that is not a tag. Choose deliberately, because Partial is also the easy way to silence a completeness check you wanted.

Extract pulls one member out by its tag

Sometimes a function genuinely handles one member of the union, and its parameter should say so:

Deno.test("Extract pulls one member out by its tag", () => {
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rect"; width: number; height: number };
function radiusOf(shape: Extract<Shape, { kind: "circle" }>): number {
return shape.radius;
}
assertEquals(radiusOf({ kind: "circle", radius: 2 }), 2);
// @ts-expect-error: a rect is not in the extracted set
assertEquals(radiusOf({ kind: "rect", width: 3, height: 4 }), undefined);
});
Extract pulls one member out by its tag ... ok (0ms)

Extract<Shape, { kind: "circle" }> keeps the union members assignable to the second type, which leaves exactly the circle. So shape.radius needs no narrowing, the rectangle call is pinned as rejected, and the runtime line under the pin shows what the checker prevented: a rectangle flowing through returns undefined where the signature promised a number.

widening follows mutability

Between a literal and its whole type, the checker has to guess which you meant, and it guesses from mutability:

Deno.test("widening follows mutability", () => {
type Status = "draft" | "sent" | "paid";
const kept = "draft";
const fromConst: Status = kept;
assertEquals(fromConst, "draft");

let widened = "draft";
widened = "anything at all";
// @ts-expect-error: a let widens to string, and string is not a subset
const fromLet: Status = widened;
assertEquals(String(fromLet), "anything at all");

const plain = { status: "draft" };
// @ts-expect-error: a mutable property widens to string as well
const fromPlain: Status = plain.status;
const frozen = { status: "draft" } as const;
const fromFrozen: Status = frozen.status;
assertEquals(fromPlain, fromFrozen);
});
widening follows mutability ... ok (0ms)

The rule, three times over.

  1. kept is a const and can never hold anything else, so it keeps the literal type "draft", a subset of Status, and flows in.
  2. widened is a let and might hold any string, which the reassignment on the next line proves, so its type widens to string. string is not a subset of Status, and the pin records the refusal. The runtime line shows the value that would have flowed: "anything at all".
  3. plain.status widens for the same reason, because an object property is mutable. as const pins the literal: frozen.status has type "draft" and satisfies Status. And as const is compile-time only; it freezes nothing at run time, the same erasure the values-and-references page measured in readonly exists only at compile time.

a predicate teaches the checker a check of your own

Two signatures extend narrowing to logic the checker cannot follow on its own, and both are the natural shape for the edge where unknown data arrives:

Deno.test("a predicate teaches the checker a check of your own", () => {
type Status = "draft" | "sent" | "paid";
function isStatus(value: unknown): value is Status {
return value === "draft" || value === "sent" || value === "paid";
}
assertEquals(isStatus("paid"), true);
assertEquals(isStatus("posted"), false);

const incoming: unknown = JSON.parse('"sent"');
if (isStatus(incoming)) {
assertEquals(incoming.toUpperCase(), "SENT");
}

function assertNumber(value: unknown): asserts value is number {
if (typeof value !== "number") throw new TypeError("not a number");
}
const parsed: unknown = JSON.parse("21");
assertNumber(parsed);
assertEquals(parsed * 2, 42);
assertThrows(() => assertNumber("21"), TypeError, "not a number");
});
a predicate teaches the checker a check of your own ... ok (0ms)

Two tools, one warning.

  1. value is Status is a type predicate: the function returns a boolean, and the annotation tells the checker what a true means. Inside the if, incoming has narrowed from unknown to Status, which is why .toUpperCase() is allowed on what JSON.parse returned.
  2. asserts value is number is an assertion function: it returns nothing, and everything after a call that did not throw is narrowed. parsed * 2 type-checks because assertNumber either proved the number or threw, and the assertThrows line shows the throwing half.
  3. Both are promises the checker takes on trust. Write value is Status above a body that returns the wrong answer, and you have lied to the checker in a way nothing will catch. Keep predicate bodies boring.

narrowing ends where the value can change

Narrowing survives into a closure, which surprises people who learned otherwise from older TypeScript. What it does not survive is a reassignment:

Deno.test("narrowing ends where the value can change", () => {
function reassigned(value: string | undefined): number {
if (value === undefined) return 0;
// @ts-expect-error: the reassignment below un-narrows value in the closure
const later = () => value.length;
value = undefined;
return later();
}
assertThrows(() => reassigned("oak"), TypeError);
});
narrowing ends where the value can change ... ok (0ms)

Trace what the checker saw.

  1. After the guard, value is narrowed to string.
  2. The closure later captures value, and the checker analyzes the whole function before deciding what value is inside it. It finds the reassignment on the next line, concludes value can be undefined by the time later runs, and rejects the unguarded value.length. The pin records that rejection.
  3. The runtime agrees with the checker: the reassignment happens, later() reads length off undefined, and the TypeError from the nothing-twice page's reading a property throws only for these two arrives on schedule. The analysis was not pedantry; it predicted this exact crash.

narrowing on a property trusts too far

One place the checker's trust outruns the truth, and nothing will warn you. Narrow a property, call a function that mutates the object, and read the property again. Predict read({ note: "hi" }), then save:

Deno.test("narrowing on a property trusts too far", () => {
type Box = { note: string | undefined };
function clear(box: Box) {
box.note = undefined;
}
function read(box: Box): number {
if (box.note === undefined) return 0;
clear(box);
return box.note.length;
}
assertEquals(read({ note: "hi" }), 2);
});
Check programs/unions-and-narrowing.test.ts
running 13 tests from ./programs/unions-and-narrowing.test.ts
...
narrowing ends where the value can change ... ok (0ms)
narrowing on a property trusts too far ... FAILED (1ms)

ERRORS

narrowing on a property trusts too far => ./programs/unions-and-narrowing.test.ts:185:11
error: TypeError: Cannot read properties of undefined (reading 'length')
return box.note.length;
^
at read (file:///programs/unions-and-narrowing.test.ts:193:23)

FAILURES

narrowing on a property trusts too far => ./programs/unions-and-narrowing.test.ts:185:11

FAILED | 12 passed | 1 failed (3ms)

error: Test failed

No pin, no directive, and no assertion diff: the file type-checked cleanly and then crashed. That combination has appeared nowhere else in these references, and it is the whole point of the step.

  1. The guard narrowed box.note to string.
  2. clear(box) follows the shared reference from the values-and-references page's parameters receive a copy of the reference and writes undefined into the property.
  3. The checker still believes box.note is a string on the next line. It keeps property narrowing across function calls on purpose: tracking every possible mutation through every call would reject enormous amounts of correct code, so TypeScript takes the usable trade instead of the sound one. The previous step's reassignment was visible inside one function; this mutation hid behind a call.

Blame the trade-off rather than yourself, and record the crash as the claim:

Deno.test("narrowing on a property trusts too far", () => {
type Box = { note: string | undefined };
function clear(box: Box) {
box.note = undefined;
}
function read(box: Box): number {
if (box.note === undefined) return 0;
clear(box);
return box.note.length;
}
assertThrows(
() => read({ note: "hi" }),
TypeError,
"Cannot read properties of undefined (reading 'length')",
);
});
narrowing on a property trusts too far ... ok (0ms)

The practical rule: narrowing on a property is trustworthy only up to the next call that could touch the object. When it matters, copy the value into a local const first, which narrows something nothing else can reach.

In practice