Alright, let’s get our hands dirty with user-defined type guards. You’ve already seen typeof and instanceof, and you’ve probably noticed their glaring weakness: they’re useless against data shapes you’ve invented yourself. When you’re dealing with data from an API, a config file, or a user’s input, you’re not checking for string or number; you’re checking for a string that looks like a valid email or an object that has the properties of a User.

This is where we stop asking TypeScript to guess and start telling it exactly what we know. We write our own truth.

The is Predicate: Your Truth-Telling Function

An is predicate (also called a type guard function) is a function that returns a boolean, but we’ve made a special promise to the type system. We’re not just returning true or false; we’re returning a value that means “if this function returns true, you can narrow the type of the parameter to this more specific type.”

The syntax is the magic bit. Your function return type isn’t boolean; it’s a type predicate: argumentName is DesiredType.

interface Cat {
  meow(): void;
}

interface Dog {
  bark(): void;
}

// This is a regular boolean function. TS doesn't learn from it.
function isCatRegular(animal: Cat | Dog): boolean {
  return (animal as Cat).meow !== undefined;
}

// This is a type guard. TS trusts its result.
function isCat(animal: Cat | Dog): animal is Cat {
  return (animal as Cat).meow !== undefined;
}

let creature: Cat | Dog = getAnimalFromSomewhere();

if (isCatRegular(creature)) {
  creature.meow(); // Error: Property 'meow' does not exist on type 'Cat | Dog'.
}

if (isCat(creature)) {
  creature.meow(); // Works perfectly. TypeScript knows it's a Cat.
}

See the difference? The first function is a liar as far as TypeScript’s compiler is concerned. It returns a boolean, and that’s that. The second function makes a pact: “I pinky-swear that if I return true, that animal is a Cat.” TypeScript then updates its understanding of the world accordingly inside the conditional block.

How to Actually Check the Damn Thing

The is syntax doesn’t do the checking for you. It just tells TypeScript what your check means. The onus is entirely on you, the programmer, to write a runtime check that correctly implements the type predicate. If your function is poorly implemented and returns true for a Dog, TypeScript will happily, and incorrectly, narrow it to a Cat. You will have lied to the compiler, and it will reward you with a runtime error. This is your responsibility.

Your checks should be as rigorous as the situation demands. A simple property check is often enough for basic discriminants.

type Square = { kind: 'square'; size: number };
type Circle = { kind: 'circle'; radius: number };

function isCircle(shape: Square | Circle): shape is Circle {
  return shape.kind === 'circle';
}

But for more complex validation, especially with untrusted data, you need to do a full structural check. This is where most developers get lazy. Don’t.

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

// BAD: This is naive and dangerous.
function isUserBad(obj: any): obj is User {
  return obj !== null && typeof obj === 'object';
}

// STILL BAD: Slightly better, but still terrible.
function isUserStillBad(obj: any): obj is User {
  return 'id' in obj && 'email' in obj && 'name' in obj;
}

// GOOD: This actually verifies the types of the properties.
function isUser(obj: any): obj is User {
  return (
    obj !== null &&
    typeof obj === 'object' &&
    typeof obj.id === 'number' &&
    typeof obj.email === 'string' &&
    typeof obj.name === 'string'
  );
}

const dataFromNetwork = JSON.parse('{"id": "101", "email": 42, "name": "Alice"}'); // Oops, id is string, email is number

if (isUserStillBad(dataFromNetwork)) {
  console.log(dataFromNetwork.id.toFixed(2)); // Runtime Error: toFixed is not a function on a string!
}

if (isUser(dataFromNetwork)) {
  // This block won't run because the check correctly failed.
  console.log("This is a valid user");
} else {
  console.log("Data is corrupted"); // This will run.
}

The Power (and Danger) of any and unknown

You’ll notice I used obj: any in those examples. This is often the entry point for a type guard—you have some blob of data whose type you don’t know yet. Using any is convenient but turns off all type safety. The safer alternative is to use unknown, which forces you to perform checks before using a value.

function isUserSafe(obj: unknown): obj is User {
  // We have to check the type first because we can't use 'in' on unknown.
  if (typeof obj !== 'object' || obj === null) {
    return false;
  }

  // Now we've narrowed obj to `object`, we can check for properties.
  // We use a type assertion to a Record<string, unknown> to check properties.
  const candidate = obj as Record<string, unknown>;
  return (
    typeof candidate.id === 'number' &&
    typeof candidate.email === 'string' &&
    typeof candidate.name === 'string'
  );
}

const unsafeData: unknown = getWeirdData();
if (isUserSafe(unsafeData)) {
  // Now we can safely use it as a User.
  console.log(unsafeData.email.toLowerCase());
}

Best Practices and Pitfalls

  1. Be Meticulous: Your type guard is the gatekeeper. A sloppy guard makes your entire codebase unsafe. If you’re validating external data, use a library like zod or io-ts to define your schemas and guards—they automate this tedious and critical work.
  2. Keep Them Pure: A type guard should not have side effects. Its only job is to check a value and return a boolean. If it’s also making API calls or updating state, you’re doing it wrong.
  3. Don’t Overcomplicate: If you’re just discriminating between two members of a union with a common literal property (like our kind example above), a simple equality check is all you need. Save the full structural validation for when you’re parsing truly unknown data.
  4. They Work in Arrays: This is where they truly shine, moving you from the land of any[] to something meaningful.
const networkResponse: unknown[] = [/*... some data ...*/];

// Without a guard, map is a nightmare.
const badNames = networkResponse.map(item => item.name); // Error: Object is of type 'unknown'.

// With a guard, we can filter and map with confidence.
const goodUsers = networkResponse.filter(isUserSafe);
const goodNames = goodUsers.map(user => user.name); // All good here.

The is predicate is your tool for building a bridge between the messy, unpredictable world of runtime data and the clean, structured world of TypeScript’s type system. Use it wisely, and it will make your code incredibly robust. Use it poorly, and it will give you a false sense of security. The choice, as always, is yours.