TypeScript is a superset of JavaScript that adds static types to the dynamic language. One of the features that sets TypeScript apart from JavaScript is its support for type narrowing, which allows you to access properties and methods that are only available on certain types, and also helps TypeScript to catch errors and bugs at compile time.
What is type narrowing?
Type narrowing is the process of refining the type of a variable based on a condition. This can be useful when you have a variable that could have multiple possible types, but you want to perform operations on it that are only valid for a specific type.
TypeScript uses control flow analysis to narrow types based on conditional statements, loops, truthiness checks. Type narrowing is typically done using conditional statements, such as if statements or switch statements.
function printLength(strOrArray: string | string[]) {
if (typeof strOrArray === "string") {
console.log(strOrArray.length);
} else {
console.log(strOrArray.length);
}
}
printLength("hello"); // prints 5
printLength(["hello", "world"]); // prints 2
Here’s an example using switch
statements:
interface Circle {
kind: "circle";
radius: number;
}
interface Square {
kind: "square";
sideLength: number;
}
type Shape = Circle | Square;
function getArea(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.sideLength ** 2;
default:
throw new Error(`Invalid shape: ${shape}`);
}
}
const circle: Circle = { kind: "circle", radius: 5 };
const square: Square = { kind: "square", sideLength: 4 };
console.log(getArea(circle)); // prints 78.53981633974483
console.log(getArea(square)); // prints 16
Type narrowing vs type casting
Type narrowing and type casting are related but different concepts.
Type narrowing is the process of refining a value of multiple types into a single, specific type based on some condition or check.
Type casting is the syntax or operation of converting a value of one type to another type. Type casting can be either widening or narrowing, depending on whether the destination type has a larger or smaller range or precision than the source type.
For example, in TypeScript, you can use as to cast a value to a different type:
let x: any = "hello";
let y = x as string; // cast x to string
This is an example of type casting, but not type narrowing, because x
is still of type any
after the cast. To narrow x
to a string, you need to use a type guard:
let x: any = "hello";
if (typeof x === "string") {
// x is narrowed to string
let y = x; // y is string
}
One key difference between type narrowing and type casting is that type narrowing is always type-safe, meaning that the type system guarantees that the narrowed type is a valid subtype of the original type. Type casting, on the other hand, is not always type-safe, and can result in runtime errors if the value being cast is not actually of the expected type. For example:
function padLeft(padding: number | string, input: string) {
if (typeof padding === "number") {
// padding is narrowed to number
return " ".repeat(padding) + input;
}
// padding is narrowed to string
return padding + input;
}
function padRight(padding: number | string, input: string) {
return input + (padding as string); // cast padding to string
}
let x: number | string = Math.random() < 0.5 ? 10 : "hello";
console.log(padLeft(x, "world")); // works fine
console.log(padRight(x, "world")); // may throw an error
The padRight
function uses type casting to convert padding
to a string regardless of its actual type. This may cause a runtime error if padding
is actually a number, as numbers do not have a toString
method.
Another difference is that type narrowing can be done without changing the type of the variable or expression, whereas type casting always requires changing the type of the value. This means that type narrowing is generally a more lightweight and less intrusive operation than type casting.
Common ways to narrow a type
TypeScript provides various ways to create type guards, which are expressions that perform a runtime check that guarantees the type in some scope. Some of the built-in type guards are typeof
, instanceof
, and in
, but we can also create our own custom type guard functions using type predicates.
- Using the
typeof
operator, which returns a string that represents the type of the value.
function printValue(value: string | number) {
if (typeof value === "string") {
console.log(`The string is ${value}`);
} else {
console.log(`The number is ${value}`);
}
}
printValue("hello"); // prints "The string is hello"
- Using
instanceof
operator, which checks if an object is an instance of a specific class or constructor function.
class Animal {
speak() {
console.log("The animal speaks");
}
}
class Dog extends Animal {
bark() {
console.log("The dog barks");
}
}
function callSpeakOrBark(animal: Animal) {
if (animal instanceof Dog) {
animal.bark();
} else {
animal.speak();
}
}
const animal = new Animal();
const dog = new Dog();
callSpeakOrBark(animal); // prints "The animal speaks"
callSpeakOrBark(dog); // prints "The dog barks"
- Using
in
operator, which returntrue
if a property name or index is present in an object or array.
interface Cat {
name: string;
meow(): void;
}
interface Dog {
name: string;
bark(): void;
}
type Pet = Cat | Dog;
function greet(pet: Pet) {
if ("meow" in pet) {
// pet is narrowed to Cat
pet.meow();
} else {
// pet is narrowed to Dog
pet.bark();
}
}
- Using user-defined type guard, which is a function that returns a boolean and has a type predicate as its return type. A type predicate is an expression that takes the form
parameterName is Type
, whereparameterName
must be the name of a parameter from the current function signature.
interface Fish {
swim(): void;
}
interface Bird {
fly(): void;
}
function isFish(pet: Fish | Bird): pet is Fish {
return (pet as Fish).swim !== undefined;
}
function move(pet: Fish | Bird) {
if (isFish(pet)) {
// pet is narrowed to Fish
pet.swim();
} else {
// pet is narrowed to Bird
pet.fly();
}
}