DNA🔷 TypeScriptType Guards & Narrowing
ðŸĶ–DinosaurTypeScriptType GuardsNarrowing

Type Guards & Narrowing

Control flow analysis, type predicates, assertion functions, discriminated unions, branded types — every technique for making TypeScript prove your code is correct before it runs.

Type Guards & Narrowing

Narrowing is where TypeScript's type system meets runtime reality. The compiler tracks how your code refines types through control flow, and understanding this mechanism deeply is what lets you write code that is both safe and ergonomic — no casts needed.

Control Flow Narrowing

TypeScript's control flow analysis narrows types automatically through several mechanisms:

typeof Guards

function format(value: string | number | boolean): string {
  if (typeof value === "string") {
    return value.toUpperCase(); // value: string
  }
  if (typeof value === "number") {
    return value.toFixed(2); // value: number
  }
  return value ? "yes" : "no"; // value: boolean
}

typeof works for primitives: "string", "number", "boolean", "bigint", "symbol", "undefined", "function", and "object".

instanceof Guards

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

Truthiness Narrowing

function greet(name: string | null | undefined): string {
  if (name) {
    return `Hello, ${name}`; // name: string (null and undefined eliminated)
  }
  return "Hello, stranger";
}

Pitfall: Truthiness narrowing eliminates "", 0, NaN, null, undefined, and false. If 0 or "" are valid values, use explicit null checks instead.

Equality Narrowing

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

in Operator Narrowing

type Fish = { swim: () => void };
type Bird = { fly: () => void };
type Dog = { swim: () => void; bark: () => void };
 
function move(animal: Fish | Bird | Dog) {
  if ("bark" in animal) {
    animal.bark(); // animal: Dog
  } else if ("swim" in animal) {
    animal.swim(); // animal: Fish (Dog already eliminated)
  } else {
    animal.fly();  // animal: Bird
  }
}

Type Predicates (is keyword)

Custom type guards use the is keyword to tell the compiler what a function proves:

interface Cat {
  type: "cat";
  purr(): void;
}
 
interface Dog {
  type: "dog";
  bark(): void;
}
 
type Animal = Cat | Dog;
 
function isCat(animal: Animal): animal is Cat {
  return animal.type === "cat";
}
 
function interact(animal: Animal) {
  if (isCat(animal)) {
    animal.purr(); // ✅ narrowed to Cat
  } else {
    animal.bark(); // ✅ narrowed to Dog
  }
}

Type Predicates with Arrays

Type predicates shine with array filtering:

function isNotNull<T>(value: T | null | undefined): value is T {
  return value != null;
}
 
const mixed: (string | null)[] = ["hello", null, "world", null];
const strings: string[] = mixed.filter(isNotNull); // ✅ correctly typed

Without the type predicate, filter(x => x !== null) returns (string | null)[] — TypeScript can't narrow through callbacks without predicates.

The Danger of Lying Type Guards

Type predicates are assertions to the compiler — the compiler trusts you completely:

function isString(value: unknown): value is string {
  return typeof value === "number"; // BUG: lying to the compiler
}
 
const value: unknown = 42;
if (isString(value)) {
  value.toUpperCase(); // TypeScript believes this is safe — RUNTIME CRASH
}

A lying type guard is worse than any because it creates a false sense of safety. Always ensure the runtime check matches the type assertion.

Assertion Functions (asserts keyword)

Assertion functions narrow in the rest of the scope, not just inside an if block:

function assertIsString(value: unknown): asserts value is string {
  if (typeof value !== "string") {
    throw new Error(`Expected string, got ${typeof value}`);
  }
}
 
function process(input: unknown) {
  assertIsString(input);
  // From here on, input is `string` — no if/else needed
  console.log(input.toUpperCase());
}

Assertion Functions for Invariants

function assertDefined<T>(
  value: T | null | undefined,
  message?: string
): asserts value is T {
  if (value == null) {
    throw new Error(message ?? "Value is null or undefined");
  }
}
 
function processUser(userId: string) {
  const user = users.get(userId);
  assertDefined(user, `User ${userId} not found`);
  // user is narrowed to User (non-nullable) for the rest of the function
  console.log(user.name);
}

Discriminated Unions — The Canonical Pattern

Discriminated unions are the most important narrowing pattern in production TypeScript. The discriminant property (usually called kind, type, or status) must be a literal type.

State Management

type AsyncState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: Error };
 
function renderUser(state: AsyncState<User>): string {
  switch (state.status) {
    case "idle":
      return "Ready";
    case "loading":
      return "Loading...";
    case "success":
      return `Hello, ${state.data.name}`; // ✅ data available
    case "error":
      return `Error: ${state.error.message}`; // ✅ error available
  }
}

API Responses

type APIResponse<T> =
  | { ok: true; data: T; statusCode: number }
  | { ok: false; error: string; statusCode: number };
 
async function fetchUser(id: string): Promise<APIResponse<User>> {
  const res = await fetch(`/api/users/${id}`);
  if (res.ok) {
    return { ok: true, data: await res.json(), statusCode: res.status };
  }
  return { ok: false, error: await res.text(), statusCode: res.status };
}
 
const result = await fetchUser("123");
if (result.ok) {
  console.log(result.data.name); // ✅ TypeScript knows data exists
} else {
  console.error(result.error);   // ✅ TypeScript knows error exists
}

Form Validation

type ValidationResult<T> =
  | { valid: true; data: T }
  | { valid: false; errors: Record<string, string[]> };
 
function validateEmail(input: string): ValidationResult<string> {
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  if (emailRegex.test(input)) {
    return { valid: true, data: input };
  }
  return { valid: false, errors: { email: ["Invalid email format"] } };
}

Exhaustive Checking with never

The never type ensures you've handled every variant:

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "rectangle"; width: number; height: number }
  | { kind: "triangle"; base: number; height: number };
 
function assertNever(value: never): never {
  throw new Error(`Unhandled variant: ${JSON.stringify(value)}`);
}
 
function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    case "triangle":
      return 0.5 * shape.base * shape.height;
    default:
      return assertNever(shape);
  }
}

If you add { kind: "polygon"; sides: number; sideLength: number } to Shape but forget to handle it in the switch, the assertNever(shape) call will fail at compile time — shape is narrowed to the polygon variant, which isn't assignable to never.

Branded / Opaque Types

TypeScript's structural typing means string is string — there's no compile-time distinction between a user ID, an email, and a random string. Branded types fix this:

type Brand<T, B extends string> = T & { readonly __brand: B };
 
type UserId = Brand<string, "UserId">;
type Email = Brand<string, "Email">;
type NonEmptyString = Brand<string, "NonEmptyString">;
 
function createUserId(id: string): UserId {
  if (!id.match(/^usr_[a-z0-9]{8}$/)) {
    throw new Error("Invalid user ID format");
  }
  return id as UserId;
}
 
function createEmail(email: string): Email {
  if (!email.includes("@")) throw new Error("Invalid email");
  return email as Email;
}
 
function sendEmail(to: Email, subject: string): void {}
function getUser(id: UserId): User | null { return null; }
 
const userId = createUserId("usr_abc12345");
const email = createEmail("ada@example.com");
 
sendEmail(email, "Welcome!");    // ✅
sendEmail(userId, "Welcome!");   // ❌ UserId is not assignable to Email
getUser(email);                  // ❌ Email is not assignable to UserId
getUser("random-string");        // ❌ string is not assignable to UserId

The brand property (__brand) never exists at runtime — it's a phantom type that exists only in the type system. The validation functions serve as the "constructors" that enforce the brand.

Branded Numeric Types

type Celsius = Brand<number, "Celsius">;
type Fahrenheit = Brand<number, "Fahrenheit">;
 
function celsiusToFahrenheit(c: Celsius): Fahrenheit {
  return ((c * 9) / 5 + 32) as Fahrenheit;
}
 
const temp = 100 as Celsius;
celsiusToFahrenheit(temp);                 // ✅
celsiusToFahrenheit(100 as Fahrenheit);    // ❌ prevents unit confusion

This pattern prevents the kind of unit confusion that famously caused the Mars Climate Orbiter crash.

Narrowing with satisfies

type Route = { path: string; auth: boolean };
type Routes = Record<string, Route>;
 
const routes = {
  home: { path: "/", auth: false },
  dashboard: { path: "/dashboard", auth: true },
  profile: { path: "/profile", auth: true },
} satisfies Routes;
 
// Each value is narrowed to its literal shape, not widened to Route
routes.home.path;      // type: "/" (not string)
routes.dashboard.auth; // type: true (not boolean)

Pattern Matching (TC39 Proposal)

While not yet in TypeScript, the TC39 pattern matching proposal will bring first-class pattern matching. Today, libraries like ts-pattern provide it:

import { match, P } from "ts-pattern";
 
type Input = { type: "text"; value: string } | { type: "number"; value: number };
 
const result = match(input)
  .with({ type: "text", value: P.select() }, (text) => text.toUpperCase())
  .with({ type: "number", value: P.when(n => n > 0) }, ({ value }) => `+${value}`)
  .with({ type: "number" }, ({ value }) => String(value))
  .exhaustive();

Interview Power Moves

  1. "Discriminated unions with exhaustive checking make illegal states unrepresentable at compile time" — the most important sentence in TypeScript architecture.

  2. "I use branded types for domain primitives — UserId, Email, NonEmptyString — because structural typing alone can't prevent passing the wrong string to the wrong function."

  3. "Assertion functions are underused. They narrow for the rest of the scope, not just inside an if-block, which makes validation code much cleaner than chains of if-returns."

  4. "A lying type guard is worse than any because it creates a false sense of safety. I always audit custom type predicates in code review."

  5. "The combination of satisfies and as const gives you the best of all worlds: type validation, precise inference, and immutability."