Introduction to Narrowing Concept
Typescript documentation explains this topic really well. I am not going to copy and paste the same description here, rather I want to make it even simpler and shorter. Let’s look at a problem and see how the concept of narrowing helps us when coding in TypeScript.
Look at the code very carefully. The explanation is written below the code snippet.
function padLeft(padding: number | string, input: string): string {
return " ".repeat(padding) + input;
}
If padding
is a number
, it will be treated as the number of spaces we want to prepend to input
since padding
passed through repeat means padding
has to be a number value.
Now, the moment padLeft
function is called, it throws an error which looks somewhat like this:
"Argument of type 'string | number' is not assignable to parameter of type 'number'. Type 'string' is not assignable to type 'number'."
TypeScript is warning us that we’re passing a value with type number | string
to the repeat
function, which only accepts a number
, and it’s right. In other words, we haven’t explicitly checked if padding
is a number
first, nor are we handling the case where it’s a string
, so let’s do exactly that.
function padLeft(padding: number | string, input: string): string {
if (typeof padding === "number") {
return " ".repeat(padding) + input;
}
return padding + input;
}
When we write if (typeof padding === "number")
, TypeScript knows we are checking if padding
is a number. This kind of check is called a "type guard." TypeScript looks at the code and tries to figure out the exact type of a value. When TypeScript makes a type more specific through these checks, it’s called "narrowing."
Type Guards
Type Guards are runtime checks that allow TypeScript to infer a more specific type of a variable within a conditional block. TypeScript uses these checks to "guard" against invalid operations by ensuring that the variable has the expected type.
The most common built-in type guards in TypeScript are:
typeof
: Checks if a variable is a primitive type (e.g.,string
,number
,boolean
, etc.)instanceof
: Checks if a variable is an instance of a class or constructor function.
There is an example of typeof
in the previous section where you can see how typeof
narrows down a union type to a specific one.
But here is an example of using instanceof
:
class Dog {
bark() {
console.log("Woof!");
}
}
class Cat {
meow() {
console.log("Meow!");
}
}
function animalSound(animal: Dog | Cat) {
if (animal instanceof Dog) {
animal.bark(); // TypeScript knows `animal` is a `Dog` here
} else {
animal.meow(); // TypeScript knows `animal` is a `Cat` here
}
}
In this example, instanceof
is used to narrow the type of animal
to either Dog
or Cat
. This allows us to safely call the appropriate method (bark()
or meow()
) based on the type of animal
.
Truthiness narrowing
Truthiness might not be a word you’ll find in the dictionary, but it’s very much something you’ll hear about in JavaScript.
Truthiness narrowing in TypeScript refers to the way TypeScript refines types based on conditions (&&
s, ||
s, if
statements, Boolean negations (!
), and more) that check if a value is "truthy" or "falsy." Values like false
, 0
, null
, undefined
, NaN
, and ""
(empty string) are considered falsy, while all other values are truthy.
When you use an if
statement, TypeScript automatically narrows the type by excluding falsy
values from the possible types.
function printMessage(message: string | null) {
if (message) {
// TypeScript knows 'message' can't be null here, it's narrowed to 'string'
console.log(message.toUpperCase());
} else {
console.log("No message provided.");
}
}
printMessage("Hello"); // Output: HELLO
printMessage(null); // Output: No message provided.
In the if (message)
block, TypeScript narrows the type from string | null
to just string
, since null
is falsy and won't pass the condition.
You can always convert a value to a boolean by using the Boolean
function or by using !!
(double negation). The !!
method has an advantage: TypeScript understands it as a strict true
or false
, while the Boolean
function just gives a general boolean
type.
const value = "Hello";
// Using Boolean function
const bool1 = Boolean(value); // type: boolean
// Using double negation (!!)
const bool2 = !!value; // type: true
To be specific understand this:
bool1
's value is a boolean type value, meaning it can be eithertrue
orfalse
. TypeScript just knows it's aboolean
, but it doesn't specify which one.bool2
's value is considered a literal type—either exactlytrue
or exactlyfalse
, depending on the value of!!value
. In this case, since"Hello"
is truthy,bool2
will be of typetrue
.
Equality Narrowing
Equality narrowing in TypeScript happens when TypeScript refines the type of a variable based on an equality check (like ===
or !==
). This means that after the check, TypeScript can "narrow" the possible types of a variable because it now knows more about it.
function example(value: string | number) {
if (typeof value === "number") {
// Here, TypeScript knows value is a number
console.log(value.toFixed(2)); // Safe to use number methods
} else {
// In this block, TypeScript narrows value to string
console.log(value.toUpperCase()); // Safe to use string methods
}
}
The in
operator narrowing
The in
operator narrowing in TypeScript helps us figure out if an object has a specific property, and it also helps TypeScript refine (narrow) the types based on that check.
When you use "propertyName" in object
, TypeScript checks if the object (or its prototype) has the property. Based on whether the check is true
or false
, TypeScript can understand more about the type of that object.
If the check is true (the property exists), TypeScript narrows the object’s type to include the types that have this property (either required or optional).
If the check is false (the property doesn't exist), TypeScript narrows the type to exclude types that have this property.
type Cat = { meow: () => void };
type Dog = { bark: () => void };
function speak(animal: Cat | Dog) {
if ("meow" in animal) {
// TypeScript now knows 'animal' must be a Cat
animal.meow();
} else {
// TypeScript now knows 'animal' must be a Dog
animal.bark();
}
}
In the if ("meow" in animal)
check, TypeScript checks if the animal
has the meow
method. If it does, TypeScript knows the animal is a Cat. If not, TypeScript knows it’s a Dog.
Using type predicates
Type predicates in TypeScript are a way to narrow down the type of a variable using a function that returns a boolean. They help TypeScript understand what type a variable is after the check.
A type predicate is written as parameterName is Type
. When you use this in a function, TypeScript knows that if the function returns true
, the parameter is of that specific type.
type Fish = { swim: () => void };
type Bird = { fly: () => void };
function isFish(animal: Fish | Bird): animal is Fish {
return (animal as Fish).swim !== undefined; // Check if animal has a swim method
}
function move(animal: Fish | Bird) {
if (isFish(animal)) {
// TypeScript knows 'animal' is a Fish here
animal.swim();
} else {
// TypeScript knows 'animal' is a Bird here
animal.fly();
}
}
Simple explanation:
The
isFish
function checks if theanimal
has aswim
method.If it does, the function returns
true
, and TypeScript understands thatanimal
is a Fish.If it doesn't, TypeScript knows it's a Bird.
Discriminated unions
Discriminated Unions are a pattern in TypeScript where you can use a common property (called a "discriminant") to differentiate between different object types in a union.
Look at the following example.
interface Car {
kind: "car";
drive: () => void;
}
interface Bike {
kind: "bike";
pedal: () => void;
}
type Vehicle = Car | Bike;
function operateVehicle(vehicle: Vehicle) {
switch (vehicle.kind) {
case "car":
vehicle.drive(); // TypeScript knows `vehicle` is a `Car` here
break;
case "bike":
vehicle.pedal(); // TypeScript knows `vehicle` is a `Bike` here
break;
}
}
Here, the kind
property is the discriminant. TypeScript uses it to narrow the Vehicle
type to either Car
or Bike
based on the value of kind
.
Exhaustiveness checking
Exhaustiveness checking in TypeScript ensures that all possible cases of a union type are handled in your code. When you use type narrowing (like with if
statements or switch
cases), TypeScript checks if all types in a union have been considered. If any case is missing, TypeScript will give you an error, helping you catch potential bugs.
type Cat = { type: "cat"; meow: () => void };
type Dog = { type: "dog"; bark: () => void };
type Animal = Cat | Dog;
function makeSound(animal: Animal) {
switch (animal.type) {
case "cat":
animal.meow(); // TypeScript knows 'animal' is Cat here
break;
case "dog":
animal.bark(); // TypeScript knows 'animal' is Dog here
break;
default:
// This will give an error if we add more animal types
const _exhaustiveCheck: never = animal;
throw new Error(`Unknown animal: ${animal}`);
}
}
The Animal
type is a union of Cat
and Dog
. In the makeSound
function, we check the type
property of animal
.
Exhaustiveness Check:
If we handle both
cat
anddog
, everything is fine.If we later add another animal type, like
Bird
, and forget to update the switch statement, TypeScript will show an error at thedefault
case. This error happens because thedefault
case is assigned anever
type, meaning it shouldn’t happen if all possible types are accounted for.
Other Ways of Type Narrowing
Assignments: when we assign to any variable, TypeScript looks at the right side of the assignment and narrows the left side appropriately.
let x = Math.random() < 0.5 ? 10 : "hello world!";
Type narrowing with never type: The never
type in TypeScript represents values that never occur. It is often used to indicate unreachable code, such as when a function always throws an error or has an infinite loop. When TypeScript recognizes a never
type, it can narrow down types effectively.
function assertIsString(value: string | number) {
if (typeof value !== "string") {
// If value is not a string, throw an error
throw new Error("Not a string!");
}
// Here, TypeScript knows 'value' is a string
console.log(value.toUpperCase());
}
In the assertIsString
function, if value
is not a string, we throw an error. TypeScript understands that if it reaches the console.log
, value
must be a string. If it were anything else, the function would not complete normally, leading to a never
type.
Last Words
I am sure you got the point of narrowing concept. We can have other possible ways to narrow down types. But I believe the explanation and knowledge you have gained so far from this article is more than enough to grasp the concept and utilize it. Let me know if this was beneficial for you.
Top comments (0)