Chapter 6.3☕ 17 min read

Custom Type Guards (Type Predicates)

When built-in checks are not enough, bring in the specialist.

01The Specialist Officer

So far, we have relied on built-in checks like typeof, instanceof, and truthiness to narrow types. These cover a lot of ground, but they have a major limitation: they cannot handle CUSTOM object shapes. How do you check if an object is a Success response versus an Error response? How do you check if something is a Dog versus a Cat when they are both plain objects (not class instances)? typeof will just say "object" for both. instanceof will not work for plain object literals. You need something more powerful.

Enter CUSTOM TYPE GUARDS — functions that return a SPECIAL type predicate telling TypeScript exactly what type was checked. Think of the Hyderabad Traffic Police at the RTA checkpost. The regular constable does basic checks — "License hai? RC hai?" That is typeof and instanceof. Basic identity verification. But sometimes you need the SPECIALIST officer — the one who can verify "Is this a valid commercial taxi permit versus a personal car permit?" The specialist does a CUSTOM check and gives an OFFICIAL STAMP: "✅ This is a commercial taxi."

That stamp is the type predicate — value is CommercialTaxi. Once the specialist stamps it, the whole system accepts it. The traffic police constable (TypeScript) trusts the specialist's stamp and narrows the type accordingly. Custom type guards are YOUR specialist officers — you define the check logic, you give the official stamp, and TypeScript trusts it! This gives you immense power to create type-safe code for complex business logic, API boundaries, and custom data structures.

02Type Predicate Syntax

A type predicate is a special return type annotation on a function. The syntax is: function isType(value: WideType): value is NarrowType { ... }. The value is NarrowType part is the PREDICATE — it tells TypeScript: "If this function returns true, then value is of type NarrowType." Let us start with a basic example:

function isString(
  val: unknown
): val is string {
  return typeof val === "string";
}

Now when you use this function in a condition, TypeScript knows how to narrow the type:

function process(input: unknown) {
  if (isString(input)) {
    // TypeScript knows: input is string
    console.log(input.toUpperCase());
  }
}

Inside the if block, input is narrowed from unknown to string. The type predicate acts as a bridge between runtime checks and compile-time types. Let us see it with discriminated unions, which is where type guards truly shine:

interface Success {
  status: "success";
  data: string;
}

interface Error {
  status: "error";
  message: string;
}

function isSuccess(
  res: Success | Error
): res is Success {
  return res.status === "success";
}

function handle(res: Success | Error) {
  if (isSuccess(res)) {
    // res is Success — .data is safe
    console.log(res.data);
  } else {
    // res is Error — .message is safe
    console.log(res.message);
  }
}

Notice that the function MUST return a boolean. The predicate is a RETURN TYPE annotation, not the return value itself. The actual runtime logic is entirely up to you. TypeScript trusts your implementation! If the function returns true, TypeScript narrows the type. If it returns false, TypeScript narrows to the excluded type. This is what makes type predicates so incredibly powerful for complex type narrowing.

03Real-World Type Guards

Let us explore practical type guard patterns you will use daily in production code. These patterns handle real-world scenarios where built-in narrowing falls short.

Pattern 1: Filtering with Type Guards. You can use type guards with Array.filter to narrow the elements of the resulting array. Since TypeScript 4.4, arr.filter(isType) properly narrows the array type:

const results = items.filter(isSuccess);
// results is Success[]

Pattern 2: Filtering Nulls. A very common use case is removing null or undefined from an array. Without a type guard, filter does not narrow the type. With a generic type guard, it works beautifully:

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

const arr: (string | null)[] = [
  "a", null, "b", null, "c"
];

const filtered = arr.filter(isNonNull);
// filtered is string[]

Pattern 3: Checking for Specific Properties. When validating unknown data, you need to check if an object has a specific property. This requires multiple runtime checks combined into one type guard:

function hasId(
  obj: unknown
): obj is { id: string } {
  return typeof obj === "object" &&
    obj !== null &&
    "id" in obj &&
    typeof obj.id === "string";
}

Pattern 4: Validating API Responses. This is the SAFEST way to validate API data at runtime while also narrowing the type. You check every field you care about, and the predicate guarantees their existence to TypeScript:

interface UserResponse {
  name: string;
  email: string;
}

function isUserResponse(
  data: unknown
): data is UserResponse {
  return typeof data === "object" &&
    data !== null &&
    "name" in data &&
    "email" in data;
}

Pattern 5: Discriminated Object Types. For objects that are not class instances, you can use the in operator to check for distinguishing properties:

function isDog(
  animal: Dog | Cat
): animal is Dog {
  return "bark" in animal;
}

These five patterns cover the vast majority of custom type guard usage. They let you safely validate unknown data at runtime while keeping TypeScript fully informed of the narrowed types.

04Type Guard Traps

Custom type guards are powerful, but with great power comes great responsibility. TypeScript TRUSTS your predicate, so if you make a mistake, TypeScript cannot save you. Let us look at the most common traps.

Trap 1: The Lying Predicate. Your runtime check does not match your predicate! This is the most dangerous trap. TypeScript will narrow the type based on your predicate, not your actual check:

// DANGEROUS! Lying to TypeScript
function isNumber(
  val: unknown
): val is string { // Says string!
  return typeof val === "number"; // Checks number!
}

function process(val: unknown) {
  if (isNumber(val)) {
    // TypeScript thinks val is string
    // But it is actually a number!
    val.toUpperCase(); // RUNTIME CRASH!
  }
}

The predicate is a PROMISE. If you lie, TypeScript will believe you, and your code will crash at runtime. Always make sure your runtime check matches your predicate exactly!

Trap 2: Array.filter Gotcha. TypeScript supports narrowing with arr.filter(isType), but wrapping the type guard in an arrow function BREAKS the narrowing:

// Works! Narrowed properly
const filtered =
  items.filter(isSuccess);
// filtered is Success[]

// Does NOT narrow the type!
const notNarrowed =
  items.filter(i => isSuccess(i));
// notNarrowed is (Success | Error)[]

Always pass the function reference directly to .filter(), not wrapped in an arrow function!

Trap 3: Predicate on a Different Variable. The variable in the predicate MUST be the function parameter. You cannot narrow a variable that was not passed in:

// WRONG: otherVar is not the parameter
function check(
  val: unknown
): otherVar is string {
  // Error! 'otherVar' not in scope
  return typeof val === "string";
}

// CORRECT: val is the parameter
function check(
  val: unknown
): val is string {
  return typeof val === "string";
}

Trap 4: Over-Narrowing. Claiming a predicate narrows to a type that the input type does not even contain is nonsensical. TypeScript allows it, but it makes no logical sense:

// Nonsensical! string is not in the union
function isString(
  val: number | boolean
): val is string {
  return typeof val === "string";
}

Since string is not part of number | boolean, this predicate can never be true. Avoid over-narrowing — keep your predicates honest and aligned with the actual types involved.

05Custom Type Guards Cheatsheet

Let us recap everything we have learned about custom type guards and type predicates. This cheatsheet is your quick reference for writing safe, effective type guards.

Basic Syntax:

function isType(
  value: WideType
): value is NarrowType {
  // return a boolean
  return someRuntimeCheck;
}

Usage in Conditions:

if (isType(myValue)) {
  // myValue is NarrowType here
}

// With ternary
const result = isType(val)
  ? val.narrowMethod()
  : "fallback";

Usage with Arrays:

// Properly typed narrowing
const filtered =
  items.filter(isSuccess);
// filtered is Success[]

// Removing nulls
const nonNull =
  arr.filter(isNonNull);
// nonNull is T[]

Common Patterns:

  • Validate API responses: Check every field, predicate guarantees the shape.
  • Filter nulls: Generic isNonNull<T> removes null from arrays.
  • Discriminated unions: Check the discriminant property to narrow.
  • Property existence: Use "prop" in obj for structural checking.

Key Rules:

  • The predicate is a RETURN TYPE annotation, not the return value.
  • The function MUST return a boolean value.
  • TypeScript TRUSTS your predicate — do not lie!
  • Use the function reference directly with .filter(), not an arrow wrapper.
  • Always make the runtime check match the predicate promise.

The Golden Rule: Custom type guards are the specialist officer's stamp — you define the check, you give the official seal, and the whole system trusts it. But with great power comes great responsibility, bhai — do not stamp wrongly! A wrong stamp is worse than no stamp at all, because it gives false confidence. Keep your predicates honest, your runtime checks accurate, and TypeScript will be your best friend.

Key Points — Custom Type Guards

  • Use type predicates (value is Type) to create custom type guards
  • TypeScript trusts your predicate — always match the runtime check
  • Pass the function reference directly to .filter() for proper narrowing
  • Type guards are perfect for validating API responses and unknown data
  • The predicate variable must be the function parameter
Course Search
Search across all chapters & stages
📖

Search the course

Type any topic — branching, stash, rebase, hooks — and jump straight to that chapter.

merge branchesgit stashundo commitrebase