Skip to Content
Course content

140: User-Defined Type Guard Functions

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I see this all the time when I'm reviewing PRs from developers moving to TypeScript: they write a helper function to check a type, it returns a boolean, and then they're baffled when TypeScript still screams at them for accessing a property that they know exists.

Thinking a boolean return is enough for narrowing

The misconception is that if a function returns true based on a type check, TypeScript will automatically "remember" that check in the calling code. It doesn't. Let's look at a real-world scenario: handling API responses that could either be a success or an error.

type SuccessResponse = { status: 'success'; data: string[] };
type ErrorResponse = { status: 'error'; message: string };
type ApiResponse = SuccessResponse | ErrorResponse;

function isSuccess(response: ApiResponse): boolean {
  return response.status === 'success';
}

function handleResponse(response: ApiResponse) {
  if (isSuccess(response)) {
    // You'd think this works, right? 
    // WRONG. TypeScript still sees 'response' as ApiResponse.
    console.log(response.data); // Error: Property 'data' does not exist on type 'ErrorResponse'.
  }
}

Here's what's happening: isSuccess returns a boolean. To the TypeScript compiler, that function is a black box that returns true or false. It has no idea that the true value is logically tied to the SuccessResponse type. The "narrowing" happened inside the function, but it didn't leak back out to the caller.

Using type predicates to link logic to types

To fix this, we use a Type Predicate. Instead of returning boolean, we tell TypeScript exactly what the variable is if the function returns true using the parameterName is Type syntax.

type SuccessResponse = { status: 'success'; data: string[] };
type ErrorResponse = { status: 'error'; message: string };
type ApiResponse = SuccessResponse | ErrorResponse;

// Note the return type change here:
function isSuccess(response: ApiResponse): response is SuccessResponse {
  return response.status === 'success';
}

function handleResponse(response: ApiResponse) {
  if (isSuccess(response)) {
    // Now this works perfectly.
    // TypeScript knows that inside this block, response MUST be SuccessResponse.
    console.log(response.data); 
  } else {
    // And here, it knows it MUST be ErrorResponse.
    console.log(response.message);
  }
}

By changing the return type to response is SuccessResponse, you're creating a contract. You're telling the compiler: "If this function returns true, you can trust that the object I passed in is specifically a SuccessResponse."

I usually recommend these when your type-checking logic is complex or reused in multiple places. If you're just doing a quick check inside a single function, a simple if (response.status === 'success') is faster and achieves the same narrowing. But the moment you move that logic into its own function, the type predicate is non-negotiable.




📋 Practical Task

Implementing a User Permission Guard

You are building a dashboard that handles different types of User objects. Some are AdminUser (who have a permissions array) and some are GuestUser (who only have a guestId).

Currently, the code is failing because the developer used a standard boolean return for the type guard. Your task is to fix the isAdmin function so that the renderAdminPanel function can safely access the permissions property.

type AdminUser = {
  role: 'admin';
  permissions: string[];
  name: string;
};

type GuestUser = {
  role: 'guest';
  guestId: string;
  name: string;
};

type User = AdminUser | GuestUser;

// FIX THIS FUNCTION: Change the return type from 'boolean' to a type predicate
function isAdmin(user: User): boolean {
  return user.role === 'admin';
}

function renderAdminPanel(user: User) {
  if (isAdmin(user)) {
    // This line is currently throwing a TS error
    console.log(`Admin permissions: ${user.permissions.join(', ')}`);
  } else {
    console.log(`Guest ID: ${user.guestId}`);
  }
}

// Test cases
renderAdminPanel({ role: 'admin', permissions: ['edit', 'delete'], name: 'Alice' });
renderAdminPanel({ role: 'guest', guestId: '12345', name: 'Bob' });
Rating
0 0

There are no comments for now.

to be the first to leave a comment.