TypeScript Type Narrowing

Recently, I was working on a TypeScript project where I had to fetch user data from an object based on dynamic string IDs. In that process, I got a TypeScript compile error stating that we couldn’t index the object with a string.

After searching for the solutions, I came to know that the issue was related to how TypeScript infers types when working with object keys. To fix it, I used type narrowing along with a proper index signature, and with this, I got the desired output.

In this blog post, I’ll explain how to use TypeScript Type Narrowing and safe type patterns in TypeScript.

What is Type Narrowing in TypeScript?

Type narrowing is the process of refining a broader type to a more specific one within a certain code block. This helps TypeScript understand your code better and provides improved type checking and IDE support.

Think of type narrowing as telling TypeScript, “I know this variable is definitely a string right now, even though it could also be a number in other places.”

For example, if you have a function that accepts either a string or a number, type narrowing lets you handle each case appropriately without TypeScript complaining about potential type mismatches.

Method 1: Using Type Guards with typeof

The simplest way to narrow types in TypeScript is by using the typeof operator. This works great for JavaScript’s primitive types.

function processValue(value: string | number) {
    if (typeof value === "string") {
        // TypeScript knows value is a string here
        console.log(value.toUpperCase());
    } else {
        // TypeScript knows value is a number here
        console.log(value.toFixed(2));
    }
}

// Example usage
processValue("New York"); // Outputs: NEW YORK
processValue(42.123);     // Outputs: 42.12

Output:

Type Narrowing in TypeScript

In this example, TypeScript understands that inside the first block, value must be a string (because we checked), so it allows string methods like toUpperCase(). Similarly, in the second block, it knows the value must be a number.

Check out: Iterate Over Objects in TypeScript

Method 2: Narrowing with instanceof

When working with classes, the instanceof operator helps narrow down object types.

class Customer {
    name: string;
    loyaltyPoints: number;

    constructor(name: string, points: number) {
        this.name = name;
        this.loyaltyPoints = points;
    }

    applyDiscount() {
        return this.loyaltyPoints > 100 ? "15% discount" : "5% discount";
    }
}

class Employee {
    name: string;
    department: string;

    constructor(name: string, department: string) {
        this.name = name;
        this.department = department;
    }

    getAccessLevel() {
        return this.department === "Management" ? "High" : "Standard";
    }
}

function processUser(user: Customer | Employee) {
    console.log(`Processing user: ${user.name}`);

    if (user instanceof Customer) {
        // TypeScript knows user is a Customer here
        console.log(`Eligible for: ${user.applyDiscount()}`);
    } else {
        // TypeScript knows user is an Employee here
        console.log(`Access level: ${user.getAccessLevel()}`);
    }
}

// Example usage
processUser(new Customer("John Smith", 150)); 
processUser(new Employee("Sarah Jones", "Marketing"));

Output:

TypeScript Type Narrowing

The instanceof check helps TypeScript understand which specific class type we’re dealing with, enabling access to the appropriate methods.

Check out: Deep Clone an Object in TypeScript

Method 3: Discriminated Unions

Discriminated unions are a powerful pattern where you include a common property (the “discriminant”) that has a different literal type for each variant.

type CashPayment = {
    method: "cash";
    amount: number;
};

type CreditCardPayment = {
    method: "credit";
    cardNumber: string;
    amount: number;
};

type BankTransferPayment = {
    method: "transfer";
    accountNumber: string;
    amount: number;
};

type Payment = CashPayment | CreditCardPayment | BankTransferPayment;

function processPayment(payment: Payment) {
    // Common property access is fine
    console.log(`Processing $${payment.amount} payment`);

    // Type narrowing based on the discriminant
    switch (payment.method) {
        case "cash":
            // TypeScript knows payment is CashPayment here
            console.log("No additional processing needed");
            break;
        case "credit":
            // TypeScript knows payment is CreditCardPayment here
            console.log(`Processing card: ${payment.cardNumber.slice(-4)}`);
            break;
        case "transfer":
            // TypeScript knows payment is BankTransferPayment here
            console.log(`Processing bank account: ${payment.accountNumber}`);
            break;
    }
}

// Example usage
processPayment({ method: "cash", amount: 49.99 });
processPayment({ method: "credit", cardNumber: "4111111111111111", amount: 129.99 });

Output:

Discriminated unions in TypeScript Narrowing

The method property acts as our discriminant, allowing TypeScript to narrow down the exact type based on its value.

Check out: Optional Parameters in TypeScript Interfaces

Method 4: User-Defined Type Guards

Sometimes the built-in type guards aren’t enough. TypeScript allows you to create custom type guards using predicate functions.

interface Product {
    id: string;
    name: string;
    price: number;
}

interface DigitalProduct extends Product {
    downloadUrl: string;
    fileSize: number;
}

interface PhysicalProduct extends Product {
    weight: number;
    dimensions: {
        length: number;
        width: number;
        height: number;
    };
}

// Custom type guard
function isDigitalProduct(product: Product): product is DigitalProduct {
    return (product as DigitalProduct).downloadUrl !== undefined;
}

function processProduct(product: Product) {
    console.log(`Processing ${product.name} (ID: ${product.id})`);

    if (isDigitalProduct(product)) {
        // TypeScript knows product is DigitalProduct here
        console.log(`Prepare download link: ${product.downloadUrl}`);
        console.log(`File size: ${product.fileSize} MB`);
    } else {
        // TypeScript infers this must be a PhysicalProduct
        const physicalProduct = product as PhysicalProduct;
        console.log(`Prepare shipping for item weighing ${physicalProduct.weight} lbs`);
        const { length, width, height } = physicalProduct.dimensions;
        console.log(`Box dimensions: ${length}" × ${width}" × ${height}"`);
    }
}

// Example usage
processProduct({
    id: "DP001",
    name: "Photoshop Course",
    price: 79.99,
    downloadUrl: "https://example.com/downloads/ps-course",
    fileSize: 2500
});

processProduct({
    id: "PP001",
    name: "Ergonomic Keyboard",
    price: 129.99,
    weight: 2.5,
    dimensions: {
        length: 18,
        width: 6,
        height: 1.5
    }
});

The isDigitalProduct function returns a type predicate (product is DigitalProduct), which tells TypeScript that if the function returns true, the argument is definitely a DigitalProduct.

Check out: Extend Interfaces with Classes in TypeScript

Method 5: Type Assertions with the “as” Keyword

While not technically type-narrowing (since you’re bypassing TypeScript’s checks), sometimes you need to use type assertions when you know more about a type than TypeScript can infer.

function getStateData(stateCode: string): { name: string; population: number; capital: string } | null {
  const stateData: { [key: string]: { name: string; population: number; capital: string } } = {
    "NY": { name: "New York", population: 19.45, capital: "Albany" },
    "CA": { name: "California", population: 39.51, capital: "Sacramento" },
    "TX": { name: "Texas", population: 29.0, capital: "Austin" }
  };

  return stateCode in stateData ? stateData[stateCode] : null;
}

function displayStateInfo(stateCode: string) {
  const data = getStateData(stateCode);

  if (data) {
    console.log(`State: ${data.name}`);
    console.log(`Population: ${data.population} million`);
    console.log(`Capital: ${data.capital}`);
  } else {
    console.log(`No data available for state code: ${stateCode}`);
  }
}

displayStateInfo("NY");
displayStateInfo("ZZ");

Output:

Type Narrowing in TypeScript with Assertions

Here, we use as to tell TypeScript about the structure we know the data will have, allowing for proper type checking of property access.

The “!” Non-null Assertion Operator

The non-null assertion operator (!) is a way to tell TypeScript that you know a value won’t be null or undefined, even when TypeScript thinks it might be.

function getUserById(id: string): { name: string; email: string } | null {
  const users: Record<string, { name: string; email: string }> = {
    "1": { name: "John Doe", email: "john@example.com" },
    "2": { name: "Jane Smith", email: "jane@example.com" }
  };

  return id in users ? users[id] : null;
}

function sendWelcomeEmail(userId: string) {
  const user = getUserById(userId);
  const email = user!.email; // not safe, use only if you're 100% sure user exists
  console.log(`Sending welcome email to: ${email}`);
}

function sendWelcomeEmailSafe(userId: string) {
  const user = getUserById(userId);
  if (user) {
    console.log(`Sending welcome email to: ${user.email}`);
  } else {
    console.log(`No user found with ID: ${userId}`);
  }
}

sendWelcomeEmailSafe("1");
sendWelcomeEmailSafe("3");

Output:

Non-null Assertion Operator in TypeScript Narrowing

The first approach with ‘!’ is risky and should only be used when you’re absolutely certain the value won’t be null. The second approach, with proper narrowing, is safer and preferred.

Type narrowing is one of TypeScript’s most powerful features. It allows you to write code that’s both flexible and type-safe. By mastering these techniques, you can create more robust applications with fewer runtime errors.

Check out: TypeScript Constructor Overloading

Conclusion

In this tutorial, we learned how TypeScript’s type narrowing helps us write cleaner code by narrowing down broader types to more specific ones. We learned the different ways to narrow types, such as using typeof and instanceof checks, discriminated unions, and custom type guard functions.

We also learned about the use of type assertions with the’ as’ keyword in cases where we know more about a value than TypeScript does. By using these techniques, we can avoid common TypeScript errors and make our applications more reliable and easier to maintain.

51 Python Programs

51 PYTHON PROGRAMS PDF FREE

Download a FREE PDF (112 Pages) Containing 51 Useful Python Programs.

pyython developer roadmap

Aspiring to be a Python developer?

Download a FREE PDF on how to become a Python developer.

Let’s be friends

Be the first to know about sales and special discounts.