TypeScript is very smart, but sometimes it needs “clues” to know exactly what type a variable is at a given moment. This process of refining a broad type (like string | number) to a more specific one (like string) is called Narrowing.
And to achieve this, we use Type Guards.
1. The problem: Uncertainty
Imagine you have a function that accepts an ID, but this ID can be a number (SQL Database) or a text (UUID).
function printId(id: string | number) {
// Error: Property 'toUpperCase' does not exist on type 'string | number'.
// Property 'toUpperCase' does not exist on type 'number'.
console.log(id.toUpperCase());
}
printId("abc");function printId(id: string | number) {
// Error: Property 'toUpperCase' does not exist on type 'string | number'.
// Property 'toUpperCase' does not exist on type 'number'.
console.log(id.toUpperCase());
}
printId("abc");TypeScript complains because it is not sure that id is a string. If it were a number, .toUpperCase() would crash at runtime. 💥
2. Solution: typeof Guards
The most common form of narrowing is using the JavaScript typeof operator inside an if conditional. TypeScript understands this code and “reduces” the type inside the block.
function printId(id: string | number) {
if (typeof id === "string") {
// Inside here TypeScript KNOWS that id is string!
console.log("Your ID is: " + id.toUpperCase());
} else {
// And here it knows it HAS to be number
console.log("Your ID is: " + id.toFixed(2));
}
}
printId("Pizza!");
printId(123.456);function printId(id: string | number) {
if (typeof id === "string") {
// Inside here TypeScript KNOWS that id is string!
console.log("Your ID is: " + id.toUpperCase());
} else {
// And here it knows it HAS to be number
console.log("Your ID is: " + id.toFixed(2));
}
}
printId("Pizza!");
printId(123.456);Types supported by typeof
Remember that typeof only works with basic primitives: "string", "number", "boolean", "symbol", "undefined", "object", and "function".
Watch out! typeof null returns "object", which is a historical JS bug.
3. Truthiness Narrowing
Sometimes you don’t need to know the exact type, only if the value exists (is not null or undefined).
function printName(name?: string) {
// name is string | undefined
if (name) {
// Here name is string (because undefined is falsy)
console.log(name.toUpperCase());
}
}function printName(name?: string) {
// name is string | undefined
if (name) {
// Here name is string (because undefined is falsy)
console.log(name.toUpperCase());
}
}4. instanceof Narrowing
For objects built with classes, typeof is not very useful (everything is “object”). Here instanceof comes in using new.
function logValue(x: Date | string) {
if (x instanceof Date) {
console.log(x.toUTCString()); // x is Date
} else {
console.log(x.toUpperCase()); // x is string
}
}function logValue(x: Date | string) {
if (x instanceof Date) {
console.log(x.toUTCString()); // x is Date
} else {
console.log(x.toUpperCase()); // x is string
}
}5. Type Predicates (is)
This is the most “Pro” part. What if we have a custom verification logic?
We can create a function that returns a Type Predicate: param is Type.
interface Fish { swim: () => void }
interface Bird { fly: () => void }
// Narrowing Function
function isFish(pet: Fish | Bird): pet is Fish {
return (pet as Fish).swim !== undefined;
}
// Usage
function move(pet: Fish | Bird) {
if (isFish(pet)) {
pet.swim(); // TS knows it is Fish
} else {
pet.fly(); // TS knows it HAS to be Bird
}
}interface Fish { swim: () => void }
interface Bird { fly: () => void }
// Narrowing Function
function isFish(pet: Fish | Bird): pet is Fish {
return (pet as Fish).swim !== undefined;
}
// Usage
function move(pet: Fish | Bird) {
if (isFish(pet)) {
pet.swim(); // TS knows it is Fish
} else {
pet.fly(); // TS knows it HAS to be Bird
}
}6. Exercise: The Universal Formatter
You have a function that receives safe inputs, but you don’t know if they will come as string or number. Your job is to format them correctly without TypeScript complaining.
Ideally make use of typeof