cheat sheet

Type Narrowing

TypeScript narrowing refines broad types to specific ones within code branches. Covers typeof, instanceof, in, equality, assignment narrowing, discriminated unions, control flow analysis, and the never type.

Type Narrowing

What it is

Narrowing is TypeScript's ability to refine a broad type to a more specific one within a code branch, based on runtime checks. When TypeScript sees a conditional check, it uses control flow analysis to update the type of a variable on each branch. This allows you to safely call methods or access properties that only exist on specific types — without casting.

typeof narrowing

The typeof operator narrows to one of the eight primitive type strings:

typescript
function processValue(val: string | number | boolean | undefined) {
  if (typeof val === "string") {
    console.log(val.toUpperCase()); // val: string
  } else if (typeof val === "number") {
    console.log(val.toFixed(2));    // val: number
  } else if (typeof val === "boolean") {
    console.log(val ? "yes" : "no"); // val: boolean
  } else {
    console.log("undefined");        // val: undefined
  }
}

All typeof narrow targets:

typescript
typeof x === "string"
typeof x === "number"
typeof x === "boolean"
typeof x === "object"    // narrows to object | null (typeof null === "object")
typeof x === "function"
typeof x === "symbol"
typeof x === "bigint"
typeof x === "undefined"

typeof null === "object" is a longstanding JavaScript quirk. After typeof x === "object", x is still null | SomeObject. Follow up with a null check.

typescript
function handleObject(val: string | null | { id: number }) {
  if (typeof val === "object") {
    // val: null | { id: number }
    if (val !== null) {
      console.log(val.id); // val: { id: number }
    }
  }
}

Truthiness narrowing

Falsy values (false, 0, "", null, undefined, NaN, 0n) narrow away the falsy members:

typescript
function printName(name: string | null | undefined) {
  if (name) {
    console.log(name.toUpperCase()); // name: string (non-empty)
  } else {
    console.log("(no name)");        // name: string | null | undefined (includes "")
  }
}

Truthiness narrowing removes null and undefined but also removes the empty string "", 0, and other falsy values. If an empty string is valid, use an explicit != null check instead of a bare truthiness check.

typescript
function processInput(val: string | null) {
  if (val != null) {        // strict null check — "" is kept
    console.log(val.length); // val: string (includes "")
  }
}

instanceof narrowing

instanceof narrows to a specific class:

typescript
class ApiError extends Error {
  constructor(public statusCode: number, message: string) {
    super(message);
    this.name = "ApiError";
  }
}

class NetworkError extends Error {
  constructor(public retryable: boolean, message: string) {
    super(message);
  }
}

function handleError(err: ApiError | NetworkError | Error) {
  if (err instanceof ApiError) {
    console.log(`API error ${err.statusCode}: ${err.message}`);
  } else if (err instanceof NetworkError) {
    if (err.retryable) console.log("Retrying...");
  } else {
    console.log(`Unexpected error: ${err.message}`);
  }
}

in operator narrowing

The in operator checks if a property exists on an object, narrowing the union:

typescript
interface Cat { meow(): void; purr(): void }
interface Dog { bark(): void; fetch(): void }

function makeSound(animal: Cat | Dog) {
  if ("meow" in animal) {
    animal.meow(); // animal: Cat
  } else {
    animal.bark(); // animal: Dog
  }
}

Also useful for optional vs required property distinction:

typescript
interface WithId   { id: string; name: string }
interface WithSlug { slug: string; name: string }

function getRoute(resource: WithId | WithSlug): string {
  if ("id" in resource) {
    return `/items/${resource.id}`;
  }
  return `/items/${resource.slug}`;
}

Equality narrowing

Strict equality (=== / !==) and loose equality (== / !=) both narrow:

typescript
function processStatus(code: string | number) {
  if (code === 200) {
    // code: 200 (literal number type)
    console.log("OK");
  } else if (code === "error") {
    // code: "error" (literal string type)
    console.log("Error");
  } else {
    // code: string | number (everything else)
  }
}

Loose equality with null is especially useful — == null catches both null and undefined:

typescript
function coalesce(val: string | null | undefined, fallback: string): string {
  if (val == null) return fallback;     // removes null and undefined
  return val;                            // val: string
}

Assignment narrowing

TypeScript narrows based on the value assigned to a variable:

typescript
let result: string | number;

result = "hello";
console.log(result.toUpperCase()); // result: string — narrowed by assignment

result = 42;
console.log(result.toFixed(1));    // result: number — narrowed by new assignment

Control flow analysis

TypeScript tracks types through the entire control flow of a function, including early returns, throws, and assignments:

typescript
function processUser(user: User | null) {
  if (user === null) {
    throw new Error("No user");
  }
  // user: User — TypeScript knows null was thrown out

  if (!user.active) {
    return; // early return
  }
  // user: User & { active: true } — implicitly narrowed after the check

  console.log(user.name);
}

Multiple assignments and branching:

typescript
let text: string | undefined;

text = maybeGetText(); // string | undefined

if (text !== undefined) {
  const upper = text.toUpperCase(); // text: string
}

// After the block — text is still string | undefined
// TypeScript does not narrow based on what happened inside blocks

Discriminated unions (tagged unions)

A discriminated union is a union of object types that each have a common literal field (the discriminant). TypeScript uses the discriminant to narrow the union:

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

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;    // shape: { kind: "circle"; radius: number }
    case "square":
      return shape.side ** 2;                // shape: { kind: "square"; side: number }
    case "rectangle":
      return shape.width * shape.height;     // shape: { kind: "rectangle"; ... }
  }
}

Discriminated unions work best with:

  • A literal string (kind, type, tag, status) as the discriminant
  • A switch statement or if/else chain on the discriminant
  • A never default branch for exhaustiveness checking

never and exhaustiveness checking

After narrowing, if a branch is unreachable, TypeScript infers the type as never. You can use this to write an exhaustive check that fails at compile time if a new union member is added but not handled:

typescript
function assertNever(value: never): never {
  throw new Error(`Unhandled value: ${JSON.stringify(value)}`);
}

function describeShape(shape: Shape): string {
  switch (shape.kind) {
    case "circle":    return `Circle r=${shape.radius}`;
    case "square":    return `Square s=${shape.side}`;
    case "rectangle": return `Rect ${shape.width}x${shape.height}`;
    default:
      return assertNever(shape); // Error if Shape union grows and case is missing
  }
}

If you add | { kind: "triangle"; base: number; height: number } to Shape and forget to add a case "triangle", TypeScript will report an error on the assertNever(shape) line because shape is no longer never.

Narrowing with assignment after use

typescript
let id: string | number = getId();

if (typeof id === "string") {
  id = parseInt(id, 10); // id: number after assignment
}

console.log(id.toFixed(0)); // id: number — control flow knows it was assigned

Summary of narrowing techniques

TechniqueExampleNarrows via
typeoftypeof x === "string"Primitive type check
Truthinessif (x)Falsy/truthy branch
instanceofx instanceof DatePrototype chain
in operator"id" in objProperty existence
Equalityx === "active"Literal match
Assignmentx = "hello"Assigned value type
Discriminantswitch (x.kind)Union discriminant literal
Type guard functionisString(x)is predicate return
Assertion functionassertDefined(x)asserts return

Control flow analysis — the deep dive

Control flow analysis is the engine that makes narrowing possible. The TypeScript compiler walks every path through your code — straight-line, branches, loops, early returns, throws, and assignments — and tracks the current narrowed type of each variable at each point. The narrowed type is the intersection of every constraint the path has imposed so far, minus everything any earlier branch has thrown out.

The mental model: imagine a small bookkeeper sitting inside the compiler that updates a "current type" sticker on each variable whenever your code does anything observable to that variable — a check, a cast, an assignment, a throw, a function call that asserts. The sticker is what hover-tooltips display, and what method calls are checked against.

typescript
function example(input: string | number | null) {
  // input: string | number | null
  if (input === null) {
    return;
  }
  // input: string | number — null branch returned

  if (typeof input === "string") {
    // input: string
    console.log(input.toUpperCase());
    return;
  }

  // input: number — only branch left
  console.log(input.toFixed(2));
}

Every early return, throw, or process.exit() call subtracts the current narrowing from the rest of the function, which is why the pattern of "guard at the top, work below" composes so well with TypeScript.

Narrowing through loops

Narrowing inside a loop body persists for that iteration but resets at the top of the next iteration, because the variable could have been reassigned. If you want narrowing across iterations, store the narrowed value in a fresh const.

typescript
function process(items: Array<string | null>) {
  for (const item of items) {
    // item: string | null at the start of each iteration
    if (item === null) continue;
    // item: string for the rest of this iteration
    console.log(item.toUpperCase());
  }
}

Narrowing across function boundaries

TypeScript narrows within a function but loses narrowing the moment you call another function — the compiler has no way to know whether the callee mutated a captured variable. This is the single largest source of "I narrowed it, why is it still nullable?" complaints.

typescript
function example(state: { value: string | null }) {
  if (state.value === null) return;
  // state.value: string

  doWork(); // any side effect

  // state.value: string | null again — compiler reset the narrowing
  // because doWork() could have set state.value = null
  console.log(state.value.toUpperCase()); // Error TS2531
}

The fix:

typescript
function example(state: { value: string | null }) {
  if (state.value === null) return;
  const v = state.value; // capture into a const
  doWork();
  console.log(v.toUpperCase()); // OK — v is string, not state.value
}

A local const is invisible to outside mutation. This is the standard pattern for narrowing across await boundaries, callbacks, and any non-trivial control flow.

Narrowing and async/await

Awaiting a Promise is a function-call boundary — TypeScript will discard narrowing of mutable fields across it.

typescript
async function example(user: { name: string | null }) {
  if (user.name === null) return;
  // user.name: string

  await delay(100);

  // user.name: string | null — narrowing lost across await
  console.log(user.name.length); // Error
}

Local const capture is the fix here too:

typescript
async function example(user: { name: string | null }) {
  if (user.name === null) return;
  const name = user.name;
  await delay(100);
  console.log(name.length); // OK
}

typeof — full reference

typeof narrows to one of eight primitive type tag strings. The list and what each narrows to:

typeof x === ...Result type
"string"string
"number"number
"boolean"boolean
"undefined"undefined
"object"object | null (note the null!)
"function"Function
"symbol"symbol
"bigint"bigint

The null quirk is the famous JavaScript bug from 1995 that was preserved for backwards compatibility. typeof null === "object", so after typeof x === "object" your value is still possibly null.

typescript
function example(val: unknown) {
  if (typeof val === "object") {
    // val: object | null
    if (val !== null) {
      // val: object
      console.log(Object.keys(val));
    }
  }
}

typeof does not narrow to specific class instances — for Date, RegExp, Map, Set, etc., use instanceof instead.

instanceof — full reference

instanceof checks the prototype chain at runtime, so it narrows to the specific class and any subclasses.

typescript
abstract class Vehicle {
  abstract wheels(): number;
}

class Car extends Vehicle {
  wheels() { return 4 }
  honk() { console.log("beep!") }
}

class Motorcycle extends Vehicle {
  wheels() { return 2 }
  wheelie() { console.log("yeehaw!") }
}

function example(v: Vehicle) {
  if (v instanceof Car) {
    v.honk();     // v: Car
  } else if (v instanceof Motorcycle) {
    v.wheelie(); // v: Motorcycle
  } else {
    // v: Vehicle — could still be a future subclass we don't know about
  }
}

instanceof has three notable gotchas:

  1. Cross-realm failures — an object created in one window/iframe/worker fails instanceof against the same-named class in another realm because the prototypes are different references. This breaks instanceof Array for cross-iframe arrays; use Array.isArray() which uses a tag and works across realms.
  2. Plain objects after JSON round-tripJSON.parse(JSON.stringify(date)) returns a string, not a Date. The prototype is lost across serialization.
  3. Custom errors lose their class identity in older transpilersclass MyError extends Error worked incorrectly in TypeScript targeting ES5 because of how Error's constructor handles prototype chains. Fixed in ES2015+ targets.
typescript
class MyError extends Error {
  constructor(message: string, public code: number) {
    super(message);
    Object.setPrototypeOf(this, MyError.prototype); // ES5 target workaround
  }
}

in operator — full reference

The in operator narrows based on whether a property exists on an object. It is the workhorse for narrowing union types where the variants do not share a common discriminant property.

typescript
type ApiResponse =
  | { data: User[]; meta: { total: number } }
  | { error: string; code: number };

function handle(res: ApiResponse): void {
  if ("data" in res) {
    console.log(`got ${res.data.length} users`);
  } else {
    console.error(`[${res.code}] ${res.error}`);
  }
}

Limitations:

  • Inherited properties count"toString" in {} is true because every object inherits from Object.prototype. Use Object.hasOwn(obj, "key") if you need own-property semantics, though that does not narrow types.
  • Optional properties"data" in res is true when the property exists with any value, including undefined. If data?: User[] is optional and undefined, the in check still passes.
  • Symbols and computed keysin works with symbol keys too, but you cannot pass a generic string variable as the left side without narrowing it first.
typescript
function hasProp<K extends string>(obj: object, key: K): obj is Record<K, unknown> {
  return key in obj;
}

Equality narrowing — strict vs loose

Strict equality (===, !==) narrows to the literal value on either side. Loose equality (==, !=) does the same plus the JavaScript coercion rules.

typescript
type Status = "pending" | "active" | "done";

function isFinal(s: Status): boolean {
  return s === "done"; // narrows s to "done" inside the comparison
}

function example(val: string | null | undefined) {
  if (val == null) {
    // val: null | undefined — loose equality catches both
    return;
  }
  // val: string
  console.log(val.toUpperCase());
}

Loose == null is the canonical "is this nullish" check. The TypeScript team and the official ESLint config explicitly carve it out as an exception to the "never use ==" rule — see @typescript-eslint/eqeqeq with allowNull: true.

Equality narrowing on a union

When you compare two unions of literals, TypeScript narrows both sides to the intersection of their literal values.

typescript
function example(a: "x" | "y" | "z", b: "x" | "y") {
  if (a === b) {
    // a: "x" | "y" (intersection of "x"|"y"|"z" and "x"|"y")
    // b: "x" | "y" (same)
  }
}

This is occasionally useful in switch-like code where two variables are correlated. Most of the time it is just a fun property of the algorithm to know.

Truthiness — the empty-string trap

Truthiness narrowing removes every falsy value: 0, "", false, null, undefined, NaN, 0n, 0.0. That is more aggressive than most developers expect — a bare if (str) rejects the empty string, which is valid data in many domains (search queries, form fields, etc.).

typescript
function search(query: string | undefined) {
  if (!query) {
    // query: "" | undefined — empty string was lumped in with undefined!
    return [];
  }
  return runSearch(query);
}

// The empty-string case is now indistinguishable from "no query passed"
search("");        // returns [] — but maybe user wants all results?
search(undefined); // returns []

The fix is to be explicit about which falsy values you mean to filter:

typescript
function search(query: string | undefined) {
  if (query === undefined) {
    return getAllResults();
  }
  return runSearch(query); // "" runs an empty search, as the caller intended
}

A common house rule: == null for "is this nullish", and explicit === "" or === 0 for those values. Never use bare truthiness for narrowing string/number unions unless you mean the falsy set.

A discriminated (tagged) union is a union of object types that all share a literal-typed property. Switching on that property narrows the union to one variant. This is the most powerful and most-used narrowing technique in real-world TypeScript — it underpins Redux actions, Result<T, E> types, finite state machines, WebSocket protocols, and React reducer hooks.

See the dedicated discriminated unions article for the full deep dive. The narrowing-specific summary:

typescript
type AsyncState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; message: string };

function render<T>(state: AsyncState<T>, fmt: (data: T) => string): string {
  switch (state.status) {
    case "idle":    return "(idle)";
    case "loading": return "loading…";
    case "success": return fmt(state.data);
    case "error":   return `error: ${state.message}`;
  }
}

The literal status property is what makes narrowing exhaustive — every variant has a unique tag, so the compiler can prove which variant you are inside each case.

Assertion functions — asserts

An assertion function is a function whose return type is asserts <param> [is T]. When the function returns normally (does not throw), TypeScript narrows the parameter for the rest of the calling scope. This is more powerful than a boolean-returning type guard because the narrowing applies to the line after the call rather than inside an if.

typescript
function assert(condition: unknown, message: string): asserts condition {
  if (!condition) throw new Error(message);
}

function example(input: string | null) {
  assert(input !== null, "input must not be null");
  // input: string — narrowing persists past the call site
  console.log(input.toUpperCase());
}

function assertIsString(val: unknown): asserts val is string {
  if (typeof val !== "string") {
    throw new TypeError(`expected string, got ${typeof val}`);
  }
}

const raw: unknown = JSON.parse('"hello"');
assertIsString(raw);
console.log(raw.toUpperCase()); // raw: string

Two forms of asserts:

  1. asserts condition — narrows nothing specific; signals "if this returns, the boolean expression was truthy". Useful for general invariants.
  2. asserts val is T — narrows val to T. Equivalent power to a type guard but applies as a statement rather than a condition.

The runtime contract: an assertion function must throw when its condition is false. There is no way for the compiler to verify this; you are signing a contract with TypeScript.

Exhaustiveness checking with never

never is TypeScript's "uninhabited" type — no value has type never. When you narrow a union and every member is excluded, the remaining type is never. You can use this to write a check that fails at compile time if a union grows and a switch does not.

typescript
function assertNever(value: never): never {
  throw new Error(`Unhandled value: ${JSON.stringify(value)}`);
}

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; side: number };

function describe(shape: Shape): string {
  switch (shape.kind) {
    case "circle": return `r=${shape.radius}`;
    case "square": return `s=${shape.side}`;
    default:       return assertNever(shape);
  }
}

// Add | { kind: "triangle"; base: number; height: number } to Shape and
// the default branch fails to compile because `shape` is no longer `never`.

never also appears in:

  • Intersection of disjoint typesstring & number collapses to never.
  • Function return type when the function always throwsfunction fail(): never { throw new Error() }.
  • Promise<never> — a promise that never resolves (used in some rejection helpers).

Narrowing limitations

These are the patterns where narrowing silently fails — knowing them saves hours of "why doesn't this work" debugging.

1. Mutable property access narrowing is fragile

typescript
type Box = { value: string | null };

function example(box: Box) {
  if (box.value !== null) {
    // box.value: string
    box = { value: null };
    console.log(box.value.toUpperCase()); // Error — box was reassigned
  }
}

A property of an object is narrowed only as long as the object reference does not change. Reassigning box resets every narrowed property on it.

2. Property narrowing on aliased values

typescript
type Box = { value: string | null };

function example(box: Box) {
  if (box.value !== null) {
    const v = box.value;
    // v: string
    randomFn(); // unknown side effects
    console.log(box.value.toUpperCase()); // Error TS2531
    console.log(v.toUpperCase());          // OK — v captured before the call
  }
}

Captures of narrowed values into a local const are immune to invalidation.

3. Generic parameter narrowing is partial

Narrowing a generic parameter narrows the call-site but not the type variable itself. This sometimes confuses developers writing higher-order functions.

typescript
function example<T>(val: T | null) {
  if (val === null) return;
  // val: T — narrowed to "not null", but the type variable T still includes null possibilities
  return val; // returns T, not NonNullable<T>
}

If you need a non-null version of the type, use NonNullable<T> explicitly:

typescript
function example<T>(val: T | null): NonNullable<T> {
  if (val === null) throw new Error();
  return val as NonNullable<T>;
}

4. Method calls on union types

If two variants of a union share a method name but with incompatible signatures, calling that method on the un-narrowed union produces never or a confusing error.

typescript
type Container =
  | { kind: "list"; push(item: string): void }
  | { kind: "set"; push(item: number): void };

function example(c: Container) {
  c.push("hello"); // Error — push expects `string & number` = `never`
}

// Fix: narrow first
function fixed(c: Container) {
  if (c.kind === "list") c.push("hello");
}

5. delete does not narrow

typescript
type Box = { value?: string };

function example(box: Box) {
  if (box.value !== undefined) {
    delete box.value;
    // box.value: string | undefined — delete does not refine the type
  }
}

6. Default values in destructuring do not narrow the source

typescript
function example({ name = "anonymous" }: { name?: string }) {
  // name: string (because of default) — useful!
  // but the parameter itself is still { name?: string }
}

This one is actually favourable — the destructured local is narrower than the parameter shape.

Narrowing with branded types

See the branded types article for the full pattern. The narrowing-specific point: a brand is a phantom property the compiler tracks, so an unknown narrowed via a Zod schema gets a branded type that distinguishes it from raw input downstream.

typescript
import { z } from "zod";

const Email = z.string().email().brand<"Email">();
type Email = z.infer<typeof Email>;

function send(to: Email) { console.log(`sending to ${to}`) }

const raw: unknown = "alice@example.com";
const parsed = Email.parse(raw); // parsed: Email — narrowed AND branded
send(parsed);

Common pitfalls

  1. Narrowing lost across function calls — any function call invalidates narrowed object properties. Capture into a local const before the call.
  2. Truthiness eating empty stringsif (s) removes "". Use == null or explicit s !== undefined checks.
  3. typeof x === "object" includes null — always pair with a separate null check.
  4. instanceof failing across realms — use Array.isArray(), ArrayBuffer.isView(), or a Symbol.toStringTag-based check for cross-frame code.
  5. Discriminant not a literal — if kind: string instead of kind: "circle" | "square", narrowing silently fails. Use as const, an enum, or explicit literal annotations.
  6. Forgetting assertNever — a switch without an exhaustiveness check silently skips new variants. Always add a default that calls assertNever.
  7. Awaiting between narrowing and useawait is a function-call boundary. Capture narrowed values to a local const first.
  8. Optional discriminant{ kind?: "a" } does not narrow because the property may be absent. Make discriminants required.
  9. delete not narrowingdelete obj.foo does not change the type of obj.foo. Reassign or restructure if you need the narrowing.
  10. Mutating a narrowed value — reassigning the narrowed variable resets its type to the original. Use a const capture or accept the reset.

Real-world recipes

Recipe 1 — typed fetch with full error narrowing

A fetch wrapper that returns { ok: true; data: T } | { ok: false; error: Error }. The caller narrows once and gets full type safety down each branch.

typescript
async function safeFetch<T>(url: string): Promise<{ ok: true; data: T } | { ok: false; error: Error }> {
  try {
    const res = await fetch(url);
    if (!res.ok) {
      return { ok: false, error: new Error(`HTTP ${res.status}`) };
    }
    const data = (await res.json()) as T;
    return { ok: true, data };
  } catch (err) {
    return { ok: false, error: err instanceof Error ? err : new Error(String(err)) };
  }
}

interface User { id: string; name: string }
const result = await safeFetch<User>("https://api.example.com/users/1");

if (result.ok) {
  console.log(`user: ${result.data.name}`);
} else {
  console.error(`failed: ${result.error.message}`);
}

Recipe 2 — narrowing DOM queries

document.querySelector returns Element | null. A common pattern is to narrow with instanceof to the specific subtype before using it.

typescript
function focusInput(selector: string): void {
  const el = document.querySelector(selector);
  if (el instanceof HTMLInputElement) {
    el.focus();
    el.select();
  } else if (el === null) {
    console.warn(`no element matches ${selector}`);
  } else {
    console.warn(`${selector} is not an input`);
  }
}

Recipe 3 — Node.js error narrowing

Node's filesystem and child-process errors put the error code on a non-standard code property. Narrow with a type guard before reading it.

typescript
import { readFile } from "node:fs/promises";

interface NodeError extends Error {
  code: string;
  syscall?: string;
  path?: string;
}

function isNodeError(err: unknown): err is NodeError {
  return err instanceof Error && typeof (err as { code?: unknown }).code === "string";
}

async function readConfig(path: string): Promise<string | null> {
  try {
    return await readFile(path, "utf8");
  } catch (err) {
    if (isNodeError(err) && err.code === "ENOENT") {
      return null; // file doesn't exist — treat as missing config
    }
    throw err; // unexpected error — re-throw
  }
}

Recipe 4 — exhaustive event router

A WebSocket router that handles a typed event union. Adding a new event type causes a compile error in the router until it is handled.

typescript
type Event =
  | { type: "join"; userId: string }
  | { type: "leave"; userId: string }
  | { type: "message"; userId: string; text: string }
  | { type: "typing"; userId: string };

function assertNever(x: never): never {
  throw new Error(`Unhandled event: ${JSON.stringify(x)}`);
}

function route(event: Event): void {
  switch (event.type) {
    case "join":    console.log(`${event.userId} joined`); break;
    case "leave":   console.log(`${event.userId} left`);    break;
    case "message": console.log(`${event.userId}: ${event.text}`); break;
    case "typing":  console.log(`${event.userId} is typing`); break;
    default:        assertNever(event);
  }
}

Recipe 5 — config validation with assertion functions

Parsing environment variables with assertion functions keeps the validation site centralized and gives downstream code narrow types.

typescript
function assertString(val: unknown, name: string): asserts val is string {
  if (typeof val !== "string" || val.length === 0) {
    throw new Error(`Expected ${name} to be a non-empty string`);
  }
}

function assertNumber(val: unknown, name: string): asserts val is number {
  if (typeof val !== "number" || Number.isNaN(val)) {
    throw new Error(`Expected ${name} to be a finite number`);
  }
}

function loadConfig(): { host: string; port: number; apiKey: string } {
  const host = process.env.HOST;
  const port = Number(process.env.PORT);
  const apiKey = process.env.API_KEY;

  assertString(host, "HOST");
  assertNumber(port, "PORT");
  assertString(apiKey, "API_KEY");

  // host: string, port: number, apiKey: string — all narrowed
  return { host, port, apiKey };
}

Recipe 6 — narrowing async iterators

Async iterators emit values whose type might be a union. Narrow inside the loop to dispatch to per-variant handlers.

typescript
type LogEntry =
  | { level: "info"; message: string }
  | { level: "error"; message: string; stack?: string };

async function* readLogs(): AsyncIterableIterator<LogEntry> {
  yield { level: "info", message: "starting up" };
  yield { level: "error", message: "boom", stack: "..." };
}

async function process(): Promise<void> {
  for await (const entry of readLogs()) {
    if (entry.level === "error") {
      console.error(`[ERROR] ${entry.message}`);
      if (entry.stack) console.error(entry.stack);
    } else {
      console.log(`[INFO] ${entry.message}`);
    }
  }
}