Custom Type Guards

Difficulty: Intermediate

Custom type guards are functions that return a type predicate - a special return type annotation of the form `paramName is Type`. When such a function returns `true`, TypeScript narrows the parameter to the specified type in the calling scope. This extends TypeScript's built-in narrowing (typeof, instanceof, in) to handle any custom logic you need.

The syntax is straightforward: instead of returning `boolean`, you annotate the return type as `param is SomeType`. Inside the function body, you perform whatever runtime checks are necessary and return true or false. The key insight is that the type predicate tells TypeScript what the `true` return value means for the type system. This bridges runtime validation with compile-time type safety.

Custom type guards are essential when working with data from external sources - API responses, user input, localStorage, or any data that enters your application as `unknown` or `any`. By writing a type guard that validates the shape of the data, you get both runtime safety (the validation check) and compile-time safety (TypeScript narrows the type after the check). This pattern eliminates the need for type assertions (`as SomeType`) which are unsafe.

A common pattern is to create a library of type guard functions for your application's data types. These guards serve double duty as runtime validators and compile-time narrowing functions. When combined with discriminated unions and exhaustive checks, they form a comprehensive type safety strategy that catches errors at both compile time and runtime.

Code examples

Basic Type Guard Function

interface Cat {
  meow(): string;
  purr(): string;
}

interface Dog {
  bark(): string;
  fetch(): string;
}

function isCat(animal: Cat | Dog): animal is Cat {
  return "meow" in animal;
}

function interact(animal: Cat | Dog): string {
  if (isCat(animal)) {
    return animal.purr();
  }
  return animal.fetch();
}

const kitty: Cat = { meow: () => "Meow!", purr: () => "Purrrr" };
const doggo: Dog = { bark: () => "Woof!", fetch: () => "Fetched the ball!" };

console.log(interact(kitty));
console.log(interact(doggo));

The isCat function returns `animal is Cat`. When it returns true, TypeScript narrows animal to Cat in the if block, giving access to Cat-specific methods.

Validating Unknown Data

interface User {
  name: string;
  email: string;
  age: number;
}

function isUser(data: unknown): data is User {
  return (
    typeof data === "object" &&
    data !== null &&
    "name" in data &&
    typeof (data as any).name === "string" &&
    "email" in data &&
    typeof (data as any).email === "string" &&
    "age" in data &&
    typeof (data as any).age === "number"
  );
}

const raw: unknown = JSON.parse('{"name":"Alice","email":"a@b.com","age":30}');

if (isUser(raw)) {
  console.log(`${raw.name} (${raw.age}) - ${raw.email}`);
} else {
  console.log("Invalid data");
}

const bad: unknown = { name: "Bob" };
console.log(isUser(bad) ? "Valid" : "Invalid");

The isUser guard validates that unknown data matches the User shape. After the check, TypeScript treats the data as User with full type safety.

Type Guard with Arrays

function isStringArray(value: unknown): value is string[] {
  return Array.isArray(value) && value.every(item => typeof item === "string");
}

function processInput(input: unknown): string {
  if (isStringArray(input)) {
    return input.map(s => s.toUpperCase()).join(", ");
  }
  if (typeof input === "string") {
    return input.toUpperCase();
  }
  return "Unsupported input";
}

console.log(processInput(["hello", "world"]));
console.log(processInput("test"));
console.log(processInput(42));

The isStringArray guard validates both that the value is an array and that every element is a string. After the guard, TypeScript knows the value is string[] and allows array string methods.

Key points

Concepts covered

type predicates, is keyword, user-defined type guards, type narrowing functions