TypeScript’s is keyword lets you narrow down types within conditional blocks, but its real power is in how it allows you to define assertions that the compiler can trust across function boundaries, effectively letting you teach TypeScript about your data’s shape without resorting to any.

Let’s see it in action with a common scenario: parsing JSON data where the shape isn’t guaranteed. Imagine you’re fetching user data from an API, and you want to ensure you’re dealing with a valid User object before accessing its properties.

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

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

async function fetchUserData(userId: number): Promise<User | null> {
  try {
    const response = await fetch(`/api/users/${userId}`);
    const data = await response.json();

    if (isUser(data)) {
      // Inside this block, TypeScript *knows* data is a User
      console.log(`Fetched user: ${data.name} (${data.email})`);
      return data;
    } else {
      console.error('Invalid user data received.');
      return null;
    }
  } catch (error) {
    console.error('Error fetching user data:', error);
    return null;
  }
}

// Example usage:
fetchUserData(123).then(user => {
  if (user) {
    // Safe to access user properties here
    console.log(`User ID: ${user.id}`);
  }
});

In this example, isUser is a type predicate function. The crucial part is data is User. This tells TypeScript: "If this function returns true, then the data argument is of type User." The fetchUserData function then leverages this. When isUser(data) evaluates to true, the compiler automatically narrows the type of data from any to User within that if block. This means you can access data.id, data.name, and data.email without type errors.

The mental model here is about trusting your runtime checks. TypeScript, by default, can’t know the shape of arbitrary any data. Type predicates are your way of saying, "Hey TypeScript, I’ve just performed a runtime check, and based on its outcome, you can now treat this variable as this specific type." This is far more robust than casting (as User) because the type assertion is tied to the result of the predicate function, not just an arbitrary declaration. You’re not just telling TypeScript "assume this is a User"; you’re telling it "check if this is a User and then assume it is."

The isUser function itself is a series of runtime checks. It verifies that data is an object, not null, and crucially, that it possesses the expected keys (id, name, email) and that those keys have the correct primitive types (number, string, string). This is the core of how you build robust type guards for your application.

The in operator is particularly useful here. It checks for the existence of a property on an object, regardless of its value (though we often follow up with typeof checks). This is distinct from simply accessing data.id, which would throw a runtime error if data didn’t have an id property and was of type any.

Here’s the tricky bit that trips people up: the is keyword doesn’t magically make your runtime checks more accurate. It only tells TypeScript to trust the outcome of those checks. If your isUser function has a bug (e.g., it misses a check or incorrectly identifies a type), TypeScript will happily treat malformed data as a User inside the if block, leading to runtime errors later. The power of is is entirely dependent on the correctness of the predicate logic itself.

The next step is exploring how to create more complex type predicates for union types or discriminated unions, where the is keyword becomes even more powerful for selective narrowing.

Want structured learning?

Take the full Typescript course →