Skip to Content
Course content

27: Type Narrowing and Type Guards

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

You've probably run into this situation: you have a variable that could be one of a few different things—a union type—and you know exactly which one it is based on some logic in your code, but TypeScript is still screaming at you. It's frustrating because the logic is sound, but the compiler isn't seeing the connection.

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

interface ApiError {
  message: string;
  code: number;
}

function handleResponse(response: User | ApiError) {
  // ❌ TypeScript Error: Property 'email' does not exist on type 'User | ApiError'.
  // Property 'email' does not exist on type 'ApiError'.
  console.log(`Welcome back, ${response.email}!`);
}

The "Property does not exist" Wall

In the code above, I'm trying to treat the response as a User immediately. But TypeScript is playing it safe. Since response could be an ApiError, and errors don't have emails, TS blocks the build. It doesn't matter if you've checked the network tab and know the API returned a user; the compiler only cares about the type definition.

To fix this, we need to "narrow" the type. Narrowing is the process of moving from a less-specific type (the union) to a more-specific type (the individual member) using a type guard.

Narrowing with the 'in' operator

One of the quickest ways to fix this is using the in operator. This allows you to check if a specific property exists on an object at runtime, and TypeScript is smart enough to use that check to narrow the type for the rest of that block.

function handleResponse(response: User | ApiError) {
  if ('email' in response) {
    // Inside this block, TS knows 'response' must be a User
    console.log(`Welcome back, ${response.email}!`);
  } else {
    // Here, TS knows it MUST be an ApiError
    console.error(`Error ${response.code}: ${response.message}`);
  }
}

This is clean, but it can get tedious if you're checking for the same type in ten different functions across your app. I hate repeating the same 'property' in object checks everywhere.

Creating Custom Type Guards

When your narrowing logic gets complex—or when you just want a reusable way to identify a type—you can write a User-Defined Type Guard. This is a function that returns a type predicate.

The magic happens in the return type: pet is Fish (or in our case, response is User). This tells TypeScript: "If this function returns true, you can officially treat this variable as this specific type."

function isUser(response: User | ApiError): response is User {
  return (response as User).email !== undefined;
}

function handleResponse(response: User | ApiError) {
  if (isUser(response)) {
    // Now this is clean and reusable
    console.log(`Welcome back, ${response.email}!`);
  } else {
    console.error(response.message);
  }
}

I generally prefer this approach for any project that's larger than a few files. It moves the "how do I identify a User?" logic into one single place. If the User interface changes later, you only have to update the isUser function, not every if statement in your codebase.




📋 Practical Task

Exercise: Implementing a Notification Dispatcher

You are building a notification system that handles three different types of alerts: EmailNotification, SmsNotification, and PushNotification. Each has a unique property, but they all share a message property.

Your Goal: Create a custom type guard for each notification type and use them in a sendNotification function to log a specific delivery method for each.

interface EmailNotification {
  message: string;
  emailAddress: string;
}

interface SmsNotification {
  message: string;
  phoneNumber: string;
}

interface PushNotification {
  message: string;
  deviceId: string;
}

type Notification = EmailNotification | SmsNotification | PushNotification;

// 1. Implement these type guards
function isEmail(n: Notification): n is EmailNotification {
  // Your code here
}

function isSms(n: Notification): n is SmsNotification {
  // Your code here
}

function isPush(n: Notification): n is PushNotification {
  // Your code here
}

function sendNotification(n: Notification) {
  // 2. Use the guards to narrow the type and log the specific detail
  // Example: "Sending Email to user@example.com: Hello!"
  if (isEmail(n)) {
    console.log(`Sending Email to ${n.emailAddress}: ${n.message}`);
  } 
  // Implement the rest of the logic here...
}

// Test cases
sendNotification({ message: "Hello!", emailAddress: "test@test.com" });
sendNotification({ message: "Hi!", phoneNumber: "555-0123" });
sendNotification({ message: "Alert!", deviceId: "device_99" });
Rating
0 0

There are no comments for now.

to be the first to leave a comment.