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 ofAdmin
type. - Otherwise,
user
is ofGuest
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
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:
- 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());
}
}
- 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();
}
}
- 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());
}
}
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.
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: