Skip to content

Type Guards and Assertion Functions

intermediate11 min read

When Built-in Narrowing Isn't Enough

TypeScript's typeof, instanceof, and in checks handle most narrowing. But what about complex types that can't be distinguished by a single runtime check? What about validating API responses, checking discriminated unions from external sources, or asserting invariants? That's where type guards and assertion functions come in.

Mental Model

Think of type guards as custom passport control. TypeScript's built-in narrowing is like automated gates — they check simple things (typeof, instanceof). Type guards are human inspectors — they can check anything, and when they stamp "approved," TypeScript trusts their verdict. The is keyword is the stamp that says "I've verified this is type X." The asserts keyword is a stamp that says "If I return at all, this is definitely type X — otherwise I throw."

Type Predicates with is

A type predicate is a function that returns a boolean and tells TypeScript what type the argument is when the return is true:

interface Fish { swim: () => void }
interface Bird { fly: () => void }

// Type predicate — return type is "value is Type"
function isFish(animal: Fish | Bird): animal is Fish {
  return "swim" in animal;
}

function move(animal: Fish | Bird) {
  if (isFish(animal)) {
    animal.swim(); // animal: Fish — narrowed by type predicate
  } else {
    animal.fly();  // animal: Bird — narrowed by elimination
  }
}

Without the is Fish return type, TypeScript wouldn't narrow — it would just see a boolean return:

// Without type predicate — no narrowing
function isFish(animal: Fish | Bird): boolean {
  return "swim" in animal;
}

function move(animal: Fish | Bird) {
  if (isFish(animal)) {
    animal.swim(); // ERROR: Property 'swim' does not exist on type 'Fish | Bird'
  }
}

Practical Type Guards

// Null check guard
function isNonNull<T>(value: T | null | undefined): value is T {
  return value !== null && value !== undefined;
}

const items = [1, null, 2, undefined, 3];
const clean = items.filter(isNonNull); // clean: number[] — nulls removed

// Object shape guard
interface ApiError {
  code: string;
  message: string;
  details?: unknown;
}

function isApiError(value: unknown): value is ApiError {
  return (
    typeof value === "object" &&
    value !== null &&
    "code" in value &&
    "message" in value &&
    typeof (value as ApiError).code === "string" &&
    typeof (value as ApiError).message === "string"
  );
}

// Array element guard
function isString(value: unknown): value is string {
  return typeof value === "string";
}

const mixed: unknown[] = [1, "hello", true, "world"];
const strings = mixed.filter(isString); // strings: string[]
Common Trap

Type guards are trusted by the compiler unconditionally. If your implementation is wrong, TypeScript still narrows based on your is declaration:

// DANGEROUS — lying type guard
function isString(value: unknown): value is string {
  return true; // Always returns true — lies to TypeScript
}

const x: unknown = 42;
if (isString(x)) {
  x.toUpperCase(); // TypeScript thinks this is safe — runtime crash!
}

The is keyword is a contract between you and the compiler. TypeScript trusts you completely. If the runtime check doesn't actually verify the type, you've introduced a bug that the type system can't catch.

Assertion Functions with asserts

Assertion functions narrow types by throwing on failure rather than returning a boolean:

// Assertion function — throws or narrows
function assertIsString(value: unknown): asserts value is string {
  if (typeof value !== "string") {
    throw new Error(`Expected string, got ${typeof value}`);
  }
}

function processInput(input: unknown) {
  assertIsString(input);
  // After assertion — input is string for the rest of this scope
  console.log(input.toUpperCase()); // OK — narrowed to string
}

// Assert non-null
function assertDefined<T>(value: T | null | undefined, name: string): asserts value is T {
  if (value === null || value === undefined) {
    throw new Error(`${name} must be defined`);
  }
}

function handleUser(user: User | null) {
  assertDefined(user, "user");
  // user is User for the rest of this function
  console.log(user.name);
}
asserts vs is — when to use each

Use is (type predicate) when:

  • You're checking in a conditional (if, ternary, filter callback)
  • Both the true and false branches need to do different things
  • You want the non-narrowed type available in the else branch

Use asserts (assertion function) when:

  • Invalid state should throw immediately
  • You want to narrow for the rest of the scope, not just an if branch
  • You're validating preconditions at function entry
// is — good for branching
function handle(value: string | number) {
  if (isString(value)) {
    // string branch
  } else {
    // number branch — both branches are useful
  }
}

// asserts — good for preconditions
function handle(value: unknown) {
  assertIsString(value); // Throws if not string
  // Everything below is string — no branching needed
  return value.toUpperCase();
}

Exhaustive Checking with never

The never type represents an impossible state. In the default case of a switch over a discriminated union, if all cases are handled, the value's type is never:

type Action =
  | { type: "INCREMENT"; amount: number }
  | { type: "DECREMENT"; amount: number }
  | { type: "RESET" };

function assertNever(value: never, message?: string): never {
  throw new Error(message ?? `Unexpected value: ${JSON.stringify(value)}`);
}

function reducer(state: number, action: Action): number {
  switch (action.type) {
    case "INCREMENT":
      return state + action.amount;
    case "DECREMENT":
      return state - action.amount;
    case "RESET":
      return 0;
    default:
      return assertNever(action); // Compile error if a case is missing
  }
}

If someone adds { type: "SET"; value: number } to the Action union without updating the reducer, the assertNever(action) line will error because the SET variant hasn't been eliminated and action isn't never in the default case.

Production Scenario: Runtime Schema Validation

// Type-safe configuration loader with assertion guards
interface DatabaseConfig {
  host: string;
  port: number;
  name: string;
  ssl: boolean;
}

function assertDatabaseConfig(value: unknown): asserts value is DatabaseConfig {
  if (typeof value !== "object" || value === null) {
    throw new Error("Config must be an object");
  }

  const obj = value as Record<string, unknown>;

  if (typeof obj.host !== "string") throw new Error("host must be a string");
  if (typeof obj.port !== "number") throw new Error("port must be a number");
  if (typeof obj.name !== "string") throw new Error("name must be a string");
  if (typeof obj.ssl !== "boolean") throw new Error("ssl must be a boolean");

  if (obj.port < 1 || obj.port > 65535) {
    throw new Error("port must be between 1 and 65535");
  }
}

// Usage — parse environment config safely
function loadConfig(): DatabaseConfig {
  const raw = JSON.parse(process.env.DB_CONFIG ?? "{}");
  assertDatabaseConfig(raw); // Throws with descriptive error if invalid
  return raw; // TypeScript knows this is DatabaseConfig
}

// Combining type guards with filter for arrays
interface PublishedPost {
  id: string;
  title: string;
  publishedAt: Date;
  status: "published";
}

interface DraftPost {
  id: string;
  title: string;
  status: "draft";
}

type Post = PublishedPost | DraftPost;

function isPublished(post: Post): post is PublishedPost {
  return post.status === "published";
}

const allPosts: Post[] = getPosts();
const published: PublishedPost[] = allPosts.filter(isPublished);
// published is PublishedPost[] — filter + type guard narrows the array
Execution Trace
Input:
raw: unknown (from JSON.parse)
No type information — could be anything
Assert:
assertDatabaseConfig(raw)
Checks every property exists with correct type
Throw?:
If any check fails → Error thrown
Execution stops here with descriptive error message
Narrow:
raw: DatabaseConfig (after assertion)
If we reach this line, TypeScript knows raw is DatabaseConfig
Return:
Return type: DatabaseConfig
Fully typed — no any, no unknown, no casts
Common Mistakes
  • Wrong: Writing type guards that don't actually verify the type at runtime Right: Every type guard must genuinely check the shape — never return true unconditionally

  • Wrong: Using as (type assertion) when a type guard would be safer Right: Write a type guard function that validates at runtime, not a cast that only satisfies the compiler

  • Wrong: Forgetting to use type guards with Array.filter() Right: Pass a type guard to filter to narrow the array element type: arr.filter(isString) → string[]

  • Wrong: Not exhaustively checking discriminated unions in reducers/handlers Right: Always add a default case with assertNever to catch unhandled variants

Quiz
What's the return type difference between a type guard (is) and an assertion function (asserts)?
Quiz
Why does items.filter(isNonNull) produce a narrowed array type but items.filter(x => x !== null) doesn't?
Quiz
What happens if you add a new variant to a union but forget to handle it in a switch with assertNever?

Type-Safe Event Validator

Key Rules
  1. 1Type predicates (value is Type) narrow in conditional branches — use for if/else branching and filter callbacks
  2. 2Assertion functions (asserts value is Type) narrow for the rest of the scope — use for preconditions and validation
  3. 3Type guards are trusted unconditionally — ensure your runtime check actually verifies the type
  4. 4Use assertNever in switch default cases for exhaustive checking of discriminated unions
  5. 5Array.filter with a type guard narrows the array element type — the only way to narrow arrays in TypeScript