Type Narrowing
Discriminated unions, type guards, typeof, and instanceof for safely accessing properties. You'll hit this when TypeScript complains about a property that might not exist on a union type.
function double(value: string | number) {
// No narrowing, just cast
return (value as number) * 2;
}
double("hello"); // NaN at runtimefunction double(value: string | number) {
// No narrowing, just cast
return (value as number) * 2;
}
double("hello"); // NaN at runtimefunction double(value: string | number) {
if (typeof value === "number") {
return value * 2;
}
return value.repeat(2);
}
double("hello"); // "hellohello"function double(value: string | number) {
if (typeof value === "number") {
return value * 2;
}
return value.repeat(2);
}
double("hello"); // "hellohello"Casting with as number silences the compiler but does nothing at runtime. If value is a string, multiplying it produces NaN. Type assertions bypass safety checks instead of adding them.
Using typeof to check the type at runtime lets TypeScript narrow the type inside each branch. In the number branch, value is known to be a number, so multiplication is safe. In the string branch, string methods are available. No type assertions needed.
function greet(name?: string) {
// Might be undefined
return `Hello, ${name.toUpperCase()}!`;
}
greet(); // Runtime errorfunction greet(name?: string) {
// Might be undefined
return `Hello, ${name.toUpperCase()}!`;
}
greet(); // Runtime errorfunction greet(name?: string) {
if (name) {
return `Hello, ${name.toUpperCase()}!`;
}
return "Hello, stranger!";
}
greet(); // "Hello, stranger!"function greet(name?: string) {
if (name) {
return `Hello, ${name.toUpperCase()}!`;
}
return "Hello, stranger!";
}
greet(); // "Hello, stranger!"When name is undefined, calling .toUpperCase() on it throws a TypeError at runtime. TypeScript warns about this with strictNullChecks enabled. Always check optional values before using them.
Checking if (name) filters out both undefined and empty strings. Inside the truthy branch, TypeScript knows name is a non-empty string, so .toUpperCase() is safe. This is the simplest form of narrowing.
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rect"; width: number; height: number };
function area(shape: Shape) {
// Unsafe access
return shape.width * shape.height;
// Error: Property 'width' does not exist
// on type '{ kind: "circle"; radius: number }'
}type Shape =
| { kind: "circle"; radius: number }
| { kind: "rect"; width: number; height: number };
function area(shape: Shape) {
// Unsafe access
return shape.width * shape.height;
// Error: Property 'width' does not exist
// on type '{ kind: "circle"; radius: number }'
}type Shape =
| { kind: "circle"; radius: number }
| { kind: "rect"; width: number; height: number };
function area(shape: Shape) {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rect":
return shape.width * shape.height;
}
}type Shape =
| { kind: "circle"; radius: number }
| { kind: "rect"; width: number; height: number };
function area(shape: Shape) {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rect":
return shape.width * shape.height;
}
}Without checking kind, TypeScript sees the full union and only allows access to properties shared by all variants. width only exists on the rect variant, so accessing it directly is a compile error.
Discriminated unions use a shared literal property (here kind) to tell variants apart. When you switch on shape.kind, TypeScript narrows each case to the correct variant. You get full autocompletion and type safety in every branch.
type Fish = { swim: () => void };
type Bird = { fly: () => void };
function move(animal: Fish | Bird) {
// Guessing without checking
(animal as Fish).swim();
}
const bird: Bird = { fly: () => {} };
move(bird); // Runtime error: swim is not a functiontype Fish = { swim: () => void };
type Bird = { fly: () => void };
function move(animal: Fish | Bird) {
// Guessing without checking
(animal as Fish).swim();
}
const bird: Bird = { fly: () => {} };
move(bird); // Runtime error: swim is not a functiontype Fish = { swim: () => void };
type Bird = { fly: () => void };
function move(animal: Fish | Bird) {
if ("swim" in animal) {
animal.swim();
} else {
animal.fly();
}
}type Fish = { swim: () => void };
type Bird = { fly: () => void };
function move(animal: Fish | Bird) {
if ("swim" in animal) {
animal.swim();
} else {
animal.fly();
}
}Using as Fish tells the compiler to trust you, but at runtime there is no check. If the actual value is a Bird, calling .swim() throws because that method does not exist. Type assertions should be a last resort, not a substitute for runtime checks.
The in operator checks whether a property exists on an object at runtime. TypeScript uses this to narrow the type: inside the "swim" in animal branch, animal is narrowed to Fish. This works well when union members have distinct properties but no shared discriminant.
function handleError(err: Error | string) {
// Assumes it's always an Error
console.log(err.message);
console.log(err.stack);
// Error if err is a string
}function handleError(err: Error | string) {
// Assumes it's always an Error
console.log(err.message);
console.log(err.stack);
// Error if err is a string
}function handleError(err: Error | string) {
if (err instanceof Error) {
console.log(err.message);
console.log(err.stack);
} else {
console.log(err);
}
}function handleError(err: Error | string) {
if (err instanceof Error) {
console.log(err.message);
console.log(err.stack);
} else {
console.log(err);
}
}Accessing .message on a string value fails because strings do not have a .message property. In JavaScript, both throw new Error("oops") and throw "oops" are valid, so catch blocks often receive Error | string (or unknown).
instanceof checks the prototype chain at runtime. Inside the instanceof Error branch, TypeScript knows err is an Error with .message and .stack. In the else branch, it is narrowed to string. This is especially useful for class hierarchies.
type Cat = { meow: () => void; purr: () => void };
type Dog = { bark: () => void; fetch: () => void };
function isCat(pet: Cat | Dog) {
return "meow" in pet;
}
function handle(pet: Cat | Dog) {
if (isCat(pet)) {
// TypeScript still sees Cat | Dog here
pet.purr(); // Error
}
}type Cat = { meow: () => void; purr: () => void };
type Dog = { bark: () => void; fetch: () => void };
function isCat(pet: Cat | Dog) {
return "meow" in pet;
}
function handle(pet: Cat | Dog) {
if (isCat(pet)) {
// TypeScript still sees Cat | Dog here
pet.purr(); // Error
}
}type Cat = { meow: () => void; purr: () => void };
type Dog = { bark: () => void; fetch: () => void };
function isCat(pet: Cat | Dog): pet is Cat {
return "meow" in pet;
}
function handle(pet: Cat | Dog) {
if (isCat(pet)) {
// TypeScript knows pet is Cat
pet.purr(); // OK
}
}type Cat = { meow: () => void; purr: () => void };
type Dog = { bark: () => void; fetch: () => void };
function isCat(pet: Cat | Dog): pet is Cat {
return "meow" in pet;
}
function handle(pet: Cat | Dog) {
if (isCat(pet)) {
// TypeScript knows pet is Cat
pet.purr(); // OK
}
}A regular boolean return type tells TypeScript nothing about the argument's type. Even though the function checks "meow" in pet, TypeScript does not propagate that narrowing back to the caller. The type stays Cat | Dog in the if-block.
A user-defined type guard uses the pet is Cat return type to tell TypeScript that when the function returns true, the argument is a specific type. Without this annotation, TypeScript cannot infer the narrowing across function boundaries. The return type pet is Cat bridges runtime logic and compile-time types.