Skip to content
Codeloom
TypeScript

Type Predicates and Custom Type Guards in TypeScript

Write type predicate functions to narrow unions, filter arrays, and validate unknown data with full type safety.

·7 min read · By Codeloom
Intermediate 8 min read

What you'll learn

  • Write type predicate functions with the is keyword
  • Use assertion functions for throw-on-fail validation
  • Filter arrays with type-safe predicate callbacks

Prerequisites

  • TypeScript union types and narrowing basics
  • Familiarity with control flow analysis

TypeScript’s control flow analysis can narrow types through typeof, instanceof, equality checks, and property access. But sometimes the narrowing logic is complex enough that you need to extract it into a function. A regular function that returns a boolean does not narrow the type in the calling scope. Type predicates fix this by telling the compiler that a true return means the argument has a specific type.

Basic Type Predicates

A type predicate is a return type of the form paramName is Type.

interface Fish {
  swim(): void;
}

interface Bird {
  fly(): void;
}

function isFish(animal: Fish | Bird): animal is Fish {
  return 'swim' in animal;
}

function move(animal: Fish | Bird): void {
  if (isFish(animal)) {
    animal.swim(); // TypeScript knows animal is Fish here
  } else {
    animal.fly();  // TypeScript knows animal is Bird here
  }
}

The animal is Fish annotation tells TypeScript that when isFish returns true, the argument should be narrowed to Fish in the calling scope.

Guarding Against unknown

Type predicates are essential for validating data from external sources where the type is unknown.

interface UserDTO {
  id: string;
  name: string;
  email: string;
  age: number;
}

function isUserDTO(data: unknown): data is UserDTO {
  if (typeof data !== 'object' || data === null) return false;

  const obj = data as Record<string, unknown>;
  return (
    typeof obj.id === 'string' &&
    typeof obj.name === 'string' &&
    typeof obj.email === 'string' &&
    typeof obj.age === 'number'
  );
}

async function fetchUser(id: string): Promise<UserDTO> {
  const response = await fetch(`/api/users/${id}`);
  const data: unknown = await response.json();

  if (!isUserDTO(data)) {
    throw new Error('Invalid user data from API');
  }

  // data is UserDTO here
  return data;
}

Filtering Arrays

One of the most practical uses of type predicates is filtering arrays. The Array.filter method accepts a type predicate, and the returned array has the narrowed type.

type Result = { status: 'ok'; value: number } | { status: 'error'; message: string };

function isOk(result: Result): result is { status: 'ok'; value: number } {
  return result.status === 'ok';
}

const results: Result[] = [
  { status: 'ok', value: 42 },
  { status: 'error', message: 'Not found' },
  { status: 'ok', value: 7 },
];

const successes = results.filter(isOk);
// typeof successes is { status: 'ok'; value: number }[]

const total = successes.reduce((sum, r) => sum + r.value, 0);
// No error accessing .value because the type is narrowed

Without the type predicate, results.filter(r => r.status === 'ok') returns Result[], not the narrowed type.

Filtering Out null and undefined

A common pattern is removing nullish values from arrays.

function isNonNull<T>(value: T | null | undefined): value is T {
  return value != null;
}

const mixed: (string | null | undefined)[] = ['hello', null, 'world', undefined, 'foo'];

const strings = mixed.filter(isNonNull);
// typeof strings is string[]

This is cleaner than inline arrow functions and reusable across the codebase.

Assertion Functions

TypeScript 3.7 introduced assertion functions with the asserts keyword. Instead of returning a boolean, they throw on failure and narrow the type for all subsequent code.

function assertIsString(value: unknown): asserts value is string {
  if (typeof value !== 'string') {
    throw new TypeError(`Expected string, got ${typeof value}`);
  }
}

function processInput(input: unknown): string {
  assertIsString(input);
  // input is string from this point on
  return input.toUpperCase();
}

Assertion functions are useful at validation boundaries like the top of a function or route handler.

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

function getConfig(): { apiUrl: string } {
  const apiUrl = process.env.API_URL;
  assertDefined(apiUrl, 'API_URL');
  // apiUrl is string here, not string | undefined
  return { apiUrl };
}

Discriminated Union Guards

Type predicates work well for narrowing discriminated unions when the check is reused.

type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'rect'; width: number; height: number }
  | { kind: 'triangle'; base: number; height: number };

function isCircle(shape: Shape): shape is Extract<Shape, { kind: 'circle' }> {
  return shape.kind === 'circle';
}

function isRect(shape: Shape): shape is Extract<Shape, { kind: 'rect' }> {
  return shape.kind === 'rect';
}

const shapes: Shape[] = [
  { kind: 'circle', radius: 5 },
  { kind: 'rect', width: 10, height: 20 },
  { kind: 'triangle', base: 8, height: 6 },
];

const circles = shapes.filter(isCircle);
// { kind: 'circle'; radius: number }[]

const areas = circles.map((c) => Math.PI * c.radius ** 2);

Composing Type Guards

You can build complex guards from simpler ones.

function hasProperty<K extends string>(
  obj: unknown,
  key: K
): obj is Record<K, unknown> {
  return typeof obj === 'object' && obj !== null && key in obj;
}

function isStringProperty<K extends string>(
  obj: unknown,
  key: K
): obj is Record<K, string> {
  return hasProperty(obj, key) && typeof obj[key] === 'string';
}

function isNumberProperty<K extends string>(
  obj: unknown,
  key: K
): obj is Record<K, number> {
  return hasProperty(obj, key) && typeof obj[key] === 'number';
}

// Compose guards for complex validation
function isProduct(data: unknown): data is { name: string; price: number; sku: string } {
  return (
    isStringProperty(data, 'name') &&
    isNumberProperty(data, 'price') &&
    isStringProperty(data, 'sku')
  );
}

Class-Based Type Guards With instanceof

For class hierarchies, instanceof works as a built-in type guard. But when you need custom logic, type predicates complement it.

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

function isApiError(error: unknown): error is ApiError {
  return error instanceof ApiError;
}

function isNotFoundError(error: unknown): error is ApiError & { statusCode: 404 } {
  return isApiError(error) && error.statusCode === 404;
}

async function fetchData(url: string): Promise<unknown> {
  try {
    const res = await fetch(url);
    if (!res.ok) throw new ApiError('Request failed', res.status, await res.json());
    return res.json();
  } catch (error) {
    if (isNotFoundError(error)) {
      console.log('Resource not found');
      return null;
    }
    throw error;
  }
}

Pitfalls

Type predicates put the burden of correctness on you. TypeScript trusts your predicate function. If the runtime check does not match the declared type, you introduce unsoundness.

// DANGEROUS: This predicate lies to the compiler
function isString(value: unknown): value is string {
  return true; // Always returns true, even for non-strings!
}

const num: unknown = 42;
if (isString(num)) {
  num.toUpperCase(); // Compiles, crashes at runtime
}

Always ensure the runtime check genuinely validates the type you declare. For complex data shapes, consider using a validation library like Zod that generates type predicates from schemas.

Wrap Up

Type predicates and assertion functions extend TypeScript’s narrowing to custom validation logic. Use value is Type predicates to narrow in conditionals and filter arrays. Use asserts value is Type to throw on invalid data and narrow for all subsequent code. Compose simple guards into complex validators, and always ensure the runtime check matches the declared type.