TypeScript’s type system can feel like a straitjacket sometimes, but when you need to deal with situations where a variable could be one of several types, it’s actually a superpower. This is where type narrowing comes in, and in, typeof, and custom type guards are your best friends.
Let’s see this in action. Imagine you have a function that processes different kinds of shapes: circles and squares.
interface Circle {
kind: "circle";
radius: number;
}
interface Square {
kind: "square";
sideLength: number;
}
type Shape = Circle | Square;
function getArea(shape: Shape): number {
// We need to know WHICH shape it is to calculate the area correctly.
// This is where narrowing happens.
if (shape.kind === "circle") {
// TypeScript now KNOWS shape is a Circle here.
return Math.PI * shape.radius ** 2;
} else {
// And here, TypeScript knows it's a Square.
return shape.sideLength ** 2;
}
}
const myCircle: Shape = { kind: "circle", radius: 5 };
const mySquare: Shape = { kind: "square", sideLength: 10 };
console.log(`Circle area: ${getArea(myCircle)}`);
console.log(`Square area: ${getArea(mySquare)}`);
See how inside the if and else blocks, TypeScript automatically knows the specific type of shape? That’s type narrowing. It takes a union type (Shape in this case, which is Circle | Square) and, based on some condition, reduces it to a more specific type within a certain scope. This prevents you from accidentally trying to access radius on a Square or sideLength on a Circle.
The most common way to achieve this narrowing is by checking a literal property. In our Shape example, we used the kind property. This is often called a "discriminated union" or "tagged union." The kind property acts as the "tag" that tells TypeScript which variant of the union you’re dealing with.
However, you’re not limited to just checking literal properties. The typeof operator is another built-in way to narrow types, particularly useful for primitive types.
function processValue(value: string | number): string {
if (typeof value === "string") {
// Inside this block, TypeScript knows 'value' is a string.
return value.toUpperCase();
} else {
// And here, TypeScript knows 'value' is a number.
return `Number: ${value.toFixed(2)}`;
}
}
console.log(processValue("hello"));
console.log(processValue(123.456));
Here, typeof value === "string" tells TypeScript that value is definitely a string within that if block. Similarly, typeof value === "number" would narrow it to number. This works for "string", "number", "boolean", "symbol", and "bigint". It also works for "object" and "function", but these are less precise. For instance, typeof null is "object", which is a historical quirk.
When typeof or literal property checks aren’t enough, you can create your own type guards. A type guard is a function that returns a boolean and, crucially, has a special return type predicate: parameterName is Type.
Let’s say you have an Admin and a User type, and you want to check if an object has an isAdmin property and if it’s true.
interface User {
name: string;
}
interface Admin extends User {
isAdmin: true;
permissions: string[];
}
type UserOrAdmin = User | Admin;
function isUser(user: UserOrAdmin): user is User {
return !("isAdmin" in user); // If it doesn't have 'isAdmin', it's a User.
}
function greet(user: UserOrAdmin) {
if (isUser(user)) {
// TypeScript knows 'user' is a User here.
console.log(`Hello, User ${user.name}`);
} else {
// TypeScript knows 'user' is an Admin here.
console.log(`Hello, Admin ${user.name} with ${user.permissions.length} permissions.`);
}
}
const regularUser: UserOrAdmin = { name: "Alice" };
const siteAdmin: UserOrAdmin = { name: "Bob", isAdmin: true, permissions: ["read", "write"] };
greet(regularUser);
greet(siteAdmin);
In isUser, the user is User return type tells TypeScript: "If this function returns true, then the user argument passed into it must be of type User." This allows TypeScript to narrow down UserOrAdmin to User within the if (isUser(user)) block.
The in operator, as used in isUser with !("isAdmin" in user), is another powerful narrowing tool. It checks for the presence of a property on an object. This is invaluable when you have objects that might have optional properties or when you can’t rely on a discriminating literal.
interface Cat {
name: string;
meow(): void;
}
interface Dog {
name: string;
bark(): void;
}
type Pet = Cat | Dog;
function makeSound(pet: Pet) {
if ("meow" in pet) {
// TypeScript knows 'pet' is a Cat here.
pet.meow();
} else {
// TypeScript knows 'pet' is a Dog here.
pet.bark();
}
}
const myCat: Pet = { name: "Whiskers", meow: () => console.log("Meow!") };
const myDog: Pet = { name: "Buddy", bark: () => console.log("Woof!") };
makeSound(myCat);
makeSound(myDog);
Here, "meow" in pet checks if the pet object has a property named meow. If it does, TypeScript narrows pet to Cat within the if block. If not, it assumes it’s a Dog in the else block. This is a very robust way to differentiate between object shapes.
The combination of discriminated unions, typeof checks, in operator checks, and custom type guards gives you immense flexibility in handling complex types. The core idea is always the same: provide a condition that allows TypeScript to definitively know the specific type of a variable within a given scope, thereby enabling type-safe access to its members.
The next hurdle you’ll likely encounter is dealing with null and undefined in a type-safe way, especially when strict null checks are enabled.