TypeScript Type Narrowing

In TypeScript, variables can sometimes have union types, meaning they could be one of several types. For example,

let userInput: string | null = prompt("Enter your name:");

Here, the variable userInput can either be a string or a null. Depending on its type, the behavior of userInput can be totally different.

For instance, you won't be able to use string methods like toUpperCase() on userInput if it is null.

So, how do we perform type-specific operations safely on variables whose type is uncertain? That's where type narrowing comes in.

What is Type Narrowing?

Narrowing is how TypeScript determines the specific type of a variable at compile time, based on code logic like if...else statements. For example,

function printValue(value: string | number) {

    if (typeof value === "string") {
        
        // TypeScript knows 'value' is a string here
        // So, we can use toUpperCase()
        console.log(value.toUpperCase());
    }
    else {

        // At this point, TypeScript knows 'value' is a number
        console.log(value.toFixed(2));
    }
}

// Pass string argument
printValue("TypeScript");

// Pass number argument
printValue(36.36589);

Output

TYPESCRIPT
36.37

Here, the function parameter value is a union type that can either be a string or a number.

Before we can safely use value with methods like toUpperCase() or toFixed(), we need to determine (or narrow) its type.

So, we narrow down the type using an if...else statement, which determines what action to take based on the data type of value.

In our case, when value is a

  • String - The if statement prints value in uppercase.
  • Number - The else statement prints value up to two decimal places.

Narrowing with typeof

We usually use typeof to narrow down primitive types like string, number, boolean, or symbol. For example,

function checkInput(input: boolean | string) {
    if (typeof input === "boolean") {
        console.log(`Boolean detected: ${input}`);
    }
    else {
        console.log(`String detected: ${input.toUpperCase()}`);
    }
}

// Pass boolean argument
checkInput(true);

// Pass string argument
checkInput("TypeScript");

Output

Boolean detected: true
String detected: TYPESCRIPT

Here, we've used typeof to narrow the type of the input parameter. The program then executes different code based on the result.


Narrowing with Truthy/Falsy Checks

Another common way to narrow types is by checking if a value exists, i.e., it's not null or undefined. For example,

// Function with optional parameter 'name'
function greet(name?: string) {
    
    // Check if an argument has been passed
    if (name) {
        console.log(`Hello, ${name}!`);
    }
    else {
        console.log("No name provided.");
    }
}

// Provide string argument
greet("James Bond");

// Provide no argument
greet();

Output

Hello, James Bond!
No name provided.

In this program, name is an optional parameter, i.e., it's not required to pass an argument to the greet() function (but you can if you want to).

Inside the function, we've used type narrowing to check if an argument has been passed.

// Check if an argument has been passed
if (name) {
    console.log(`Hello, ${name}!`);
}
else {
    console.log("No name provided.");
}

When an argument is passed, if (name) evaluates to a truthy value. If nothing is passed, it evaluates to a falsy value.


Narrowing with Equality Checks

TypeScript can use equality checks (===, !==) to narrow types, especially between different union members. For example,

function compare(x: string | number, y: string | boolean) {
    if (x === y) {
        // TypeScript knows x and y are both strings
        console.log(`Identical String Arguments: ${x.toUpperCase()}`);
    }
    else {
        console.log("Arguments are not identical in value or type or both.");
    }
}

// Pass two identical string arguments
compare("Saturday", "Saturday");

// Pass different string arguments
compare("Saturday", "Monday");

// Pass a number and a string
compare(7, "Saturday");

// Pass a string and a boolean
compare("Saturday", false);

Output

Identical String Arguments: SATURDAY
Arguments are not identical in value or type or both.
Arguments are not identical in value or type or both.
Arguments are not identical in value or type or both.

In this example, we've narrowed the type of the arguments using the strict equal to operator ===:

if (x === y) {
    // Code
}
else {
    // Code
}

Since === compares both the value and the type of the operands, we know that both x and y are strings when x === y is true.

This allows us to safely treat both x and y as strings within that code block.


Narrowing with the in Operator

If your types are objects with different properties, the in operator can help distinguish them. For example,

type Admin = { role: string };
type Guest = { guestToken: string };

function handleUser(user: Admin | Guest) {
    if ("role" in user) {
        console.log("Admin Role:", user.role);
    }
    else {
        console.log("Guest Token:", user.guestToken);
    }
}

// Create object of Admin type
let admin: Admin = { role: "Maintenance" };

// Create object of Guest Type
let guest: Guest = { guestToken : "token1" };

// Pass admin as argument to handleUser()
handleUser(admin);

// Pass guest as argument to handleUser()
handleUser(guest);

Output

Admin Role: Maintenance
Guest Token: token1

The above program checks if role exists in user.

  • If it does, we know user is of Admin type.
  • Otherwise, user is of Guest type.

Narrowing with instanceof

For class instances or built-in objects like Date, use instanceof to check the type.

Example 1: Using a Date Instance

function dateString(input: Date | string) {
    if (input instanceof Date) {
        console.log(`Year: ${input.getFullYear()}`);
    }
    else {
        console.log(`String: ${input.toLowerCase()}`);
    }
}

// Create a Date object
let today: Date = new Date();

// Pass the Date object as argument
dateString(today);

// Pass a string as argument
dateString("This is a string");

Output

Year: 2025
String: this is a string

Here, we've used instanceof to determine if the argument is an instance of Date.

Example 2: Using a Custom Class

// Create a class
class Admin { 
    constructor(public role: string) {}
}

function instanceString(input: Admin | string) {
    if (input instanceof Admin) {
        console.log(`Admin Role: ${input.role}`);
    }
    else {
        console.log(`String: ${input.toLowerCase()}`);
    }
}

// Create an instance of Admin
let admin: Admin = new Admin("Maintenance");

// Pass the instance as argument
instanceString(admin);

// Pass a string as argument
instanceString("This is a string");

Output

Admin Role: Maintenance
String: this is a string

Here, we've used instanceof to determine if the argument is an instance of the class Admin.


More on Type Narrowing

Why do we need Type Narrowing in TypeScript?

Type narrowing is important because it helps you safely work with variables that can have more than one type. It makes your code smarter and safer by:

  1. Preventing errors before they happen

You can check the type and only use methods that match.

function print(val: string | number) {
    if (typeof val === "string") {
        // Safe for string
        console.log(val.toUpperCase()); 
    }
}
  1. Get better code suggestions in your editor

Once the type is narrowed, you'll see the right suggestions.

function check(input: boolean | string) {
    if (typeof input === "string") {
        // Editor knows it's a string
        input.toLowerCase(); 
    }
}
  1. Keeping your code clean and safe

You don't need to force types or add unnecessary checks.

function show(val: Date | string) {
    if (val instanceof Date) {
        // Works because it's a Date
        console.log(val.getTime()); 
    }
}
Discriminated Union Narrowing

When different types share a common property (called a discriminant), TypeScript can use that property to narrow types. For example,

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

function area(shape: Circle | Square): number {
    if (shape.kind === "circle") {
        return Math.PI * shape.radius ** 2;
    }
    else {
        return shape.size * shape.size;
    }
}

// Create instances
const circle: Circle = { kind: "circle", radius: 5 };
const square: Square = { kind: "square", size: 4 };

// Call function and log outputs
let circleArea: number = area(circle);
let squareArea: number = area(square);

console.log(`Circle Area: ${circleArea}`);
console.log(`Square Area: ${squareArea}`);

Output

Circle Area: 78.53981633974483
Square Area: 16

This program uses a common kind property to narrow between Circle and Square types. Then, it calculates the appropriate area based on the shape.

Custom Type Guards

You can define your own functions to help TypeScript narrow types. For example,

// Function to check if argument is a string
function isString(value: unknown): value is string {
    return typeof value === "string";
}

function handle(value: unknown) {
    
    // Use isString() to check for type
    if (isString(value)) {
        console.log(value.toUpperCase());
    }
    else {
        console.log("Not a string.");
    }
}

// Call function with different types
handle("hello world");
handle(42);

Output

HELLO WORLD
Not a string.

Here, isString() is a custom type guard that tells TypeScript the exact type, allowing safe use of string methods like toUpperCase().


Also Read:

Did you find this article helpful?

Our premium learning platform, created with over a decade of experience and thousands of feedbacks.

Learn and improve your coding skills like never before.

Try Programiz PRO
  • Interactive Courses
  • Certificates
  • AI Help
  • 2000+ Challenges