Overview

Type guards let you narrow a broad type into a specific one inside a conditional block. They bridge the gap between TypeScript's compile-time types and JavaScript's runtime values, which is essential when handling unknown, union types, and external data.

Why Type Guards Matter

Consider a value that could be a string or a number:

function format(value: string | number) {
  return value.toUpperCase(); // Error: toUpperCase does not exist on number
}

TypeScript refuses to compile because toUpperCase is not available on number. A type guard narrows the type so the call is safe.

Built-in Narrowing Techniques

typeof

function format(value: string | number): string {
  if (typeof value === "string") {
    return value.toUpperCase();
  }
  return value.toFixed(2);
}
typeof resultMatches
"string"String primitives
"number"Numbers (including NaN)
"boolean"true / false
"object"Objects, arrays, null (careful)
"function"Functions
"undefined"undefined

instanceof

class ApiError extends Error {
  constructor(public status: number, message: string) {
    super(message);
  }
}

function handle(err: unknown) {
  if (err instanceof ApiError) {
    console.log(err.status);   // narrowed to ApiError
  } else if (err instanceof Error) {
    console.log(err.message);  // narrowed to Error
  }
}

in

type Admin = { role: "admin"; permissions: string[] };
type User  = { role: "user"; email: string };

function describe(person: Admin | User) {
  if ("permissions" in person) {
    return `Admin with ${person.permissions.length} permissions`;
  }
  return `User with email ${person.email}`;
}

Discriminated Unions

The most reliable pattern: give each variant a literal field that TypeScript can discriminate on.

type Result =
  | { status: "success"; data: string }
  | { status: "error"; message: string }
  | { status: "loading" };

function render(result: Result) {
  switch (result.status) {
    case "success":
      return result.data;      // narrowed to success variant
    case "error":
      return result.message;   // narrowed to error variant
    case "loading":
      return "Loading...";
  }
}

Discriminated unions also power exhaustiveness checks:

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

function render(result: Result) {
  switch (result.status) {
    case "success": return result.data;
    case "error":   return result.message;
    case "loading": return "Loading...";
    default: return assertNever(result);
  }
}

If you add a new variant to Result, the default branch fails to compile until it is handled.

Custom Type Guards

A user-defined type guard returns a type predicate: value is Type.

interface Product {
  id: number;
  name: string;
  price: number;
}

function isProduct(value: unknown): value is Product {
  return (
    typeof value === "object" &&
    value !== null &&
    "id" in value &&
    "name" in value &&
    "price" in value &&
    typeof (value as Product).id === "number" &&
    typeof (value as Product).name === "string" &&
    typeof (value as Product).price === "number"
  );
}

const data: unknown = JSON.parse(response);
if (isProduct(data)) {
  console.log(data.price);   // typed as number
}

Assertion Functions

An assertion function throws on failure and narrows the type for all following code.

function assertIsString(value: unknown): asserts value is string {
  if (typeof value !== "string") {
    throw new TypeError("Expected a string");
  }
}

function process(input: unknown) {
  assertIsString(input);
  console.log(input.toUpperCase());   // input is string from here on
}

Array Type Guards

Filtering an array of mixed types requires a type predicate; a plain boolean filter does not narrow.

const values: (string | number)[] = ["a", 1, "b", 2];

// Wrong: result is still (string | number)[]
const strings = values.filter(v => typeof v === "string");

// Correct: result is string[]
function isString(v: unknown): v is string {
  return typeof v === "string";
}
const strings2 = values.filter(isString);

Type Guard Comparison

TechniqueBest forLimitations
typeofPrimitivesnull is "object"
instanceofClass instancesFails across realms and for interfaces
inObject shape checksDoes not verify value types
Discriminated unionVariants with a literal tagRequires upfront design
Custom predicateExternal or unknown dataRuntime cost, must be correct
Assertion functionEarly validationThrows rather than branching

Validation Libraries

For complex shapes, a schema library is less error-prone than hand-written guards:

LibraryApproach
ZodSchema-first with z.infer for types
ValibotModular, small bundle size
io-tsFunctional, codec-based
ArkTypeType syntax at runtime
import { z } from "zod";

const ProductSchema = z.object({
  id: z.number(),
  name: z.string(),
  price: z.number().positive(),
});

type Product = z.infer<typeof ProductSchema>;

const parsed = ProductSchema.safeParse(json);
if (parsed.success) {
  console.log(parsed.data.price);   // typed as number
}

Common Pitfalls

  • Trusting typeof null === "object". Always check value !== null first.
  • Using as instead of a guard. Type assertions do not validate at runtime.
  • Filtering without a predicate. The result stays a union type.
  • Assuming instanceof works across iframes or worker boundaries. It does not.
  • Writing guards that are too permissive. A guard that returns true incorrectly is worse than no guard.