Type Narrowing
You have a value typed as string | number | null. Before you can call .toUpperCase() on it, TypeScript needs proof that it's actually a string. That proof is called type narrowing — the process of refining a broad type into something more specific based on runtime checks. TypeScript's control flow analysis watches your conditionals and automatically tracks which types are possible at each point in your code.
The typeof Guard
The most basic form of narrowing uses JavaScript's typeof operator. TypeScript understands typeof checks and uses them to narrow primitive types.
function formatValue(value: string | number | boolean): string {
if (typeof value === "string") {
// TypeScript knows: value is string here
return value.toUpperCase();
}
if (typeof value === "number") {
// TypeScript knows: value is number here
return value.toFixed(2);
}
// TypeScript knows: value is boolean here
return value ? "Yes" : "No";
}
console.log(formatValue("hello")); // "HELLO"
console.log(formatValue(3.14159)); // "3.14"
console.log(formatValue(true)); // "Yes"
console.log(formatValue(false)); // "No"
Notice there's no else branch needed — TypeScript tracks which types have already been handled and narrows the remaining possibilities automatically. This is called control flow analysis.
Truthiness Narrowing and Nullish Checks
When a value can be null or undefined, a simple truthiness check narrows it away. This is the most common pattern you'll write in real code.
interface User {
name: string;
bio: string | null;
website?: string;
}
function renderProfile(user: User): string {
// Nullish check: bio could be null
const bioLine = user.bio !== null
? `Bio: ${user.bio}`
: "No bio provided.";
// Truthiness check: website could be undefined
const websiteLine = user.website
? `Website: ${user.website}`
: "No website.";
return [user.name, bioLine, websiteLine].join("\n");
}
const alice: User = { name: "Alice", bio: "Engineer at Acme", website: "https://alice.dev" };
const bob: User = { name: "Bob", bio: null };
console.log(renderProfile(alice));
console.log("---");
console.log(renderProfile(bob));
The instanceof Guard
For class instances, instanceof is the right tool. TypeScript narrows the type to the specific class inside the branch.
class NetworkError extends Error {
statusCode: number;
constructor(message: string, statusCode: number) {
super(message);
this.statusCode = statusCode;
}
}
class ValidationError extends Error {
field: string;
constructor(message: string, field: string) {
super(message);
this.field = field;
}
}
function handleError(error: NetworkError | ValidationError | Error): string {
if (error instanceof NetworkError) {
// TypeScript knows: error has statusCode
return `Network error ${error.statusCode}: ${error.message}`;
}
if (error instanceof ValidationError) {
// TypeScript knows: error has field
return `Validation failed on "${error.field}": ${error.message}`;
}
// TypeScript knows: error is base Error
return `Unexpected error: ${error.message}`;
}
console.log(handleError(new NetworkError("Not Found", 404)));
console.log(handleError(new ValidationError("Required", "email")));
console.log(handleError(new Error("Something went wrong")));
Discriminated Unions
Discriminated unions are the most powerful narrowing pattern in TypeScript. By adding a shared literal type field (often called a "tag" or "discriminant") to each member of a union, TypeScript can narrow exhaustively.
type LoadingState = { status: "loading" };
type SuccessState = { status: "success"; data: string[] };
type ErrorState = { status: "error"; message: string; retryable: boolean };
type AsyncState = LoadingState | SuccessState | ErrorState;
function renderState(state: AsyncState): string {
switch (state.status) {
case "loading":
return "Loading...";
case "success":
// TypeScript knows: state.data exists
return `Loaded ${state.data.length} items: ${state.data.join(", ")}`;
case "error":
// TypeScript knows: state.message and state.retryable exist
const hint = state.retryable ? " (click to retry)" : "";
return `Error: ${state.message}${hint}`;
}
}
const loading: AsyncState = { status: "loading" };
const success: AsyncState = { status: "success", data: ["apple", "banana", "cherry"] };
const error: AsyncState = { status: "error", message: "Timeout", retryable: true };
console.log(renderState(loading));
console.log(renderState(success));
console.log(renderState(error));
The switch on state.status is exhaustive — if you add a new member to the union later and forget to handle it, TypeScript will tell you at compile time.
The in Operator
When you don't control the type definitions (or can't add a discriminant), the in operator checks for property existence and narrows accordingly.
interface Circle {
radius: number;
}
interface Rectangle {
width: number;
height: number;
}
interface Triangle {
base: number;
height: number;
type: "triangle";
}
type Shape = Circle | Rectangle | Triangle;
function area(shape: Shape): number {
if ("radius" in shape) {
// TypeScript knows: shape is Circle
return Math.PI * shape.radius ** 2;
}
if ("type" in shape && shape.type === "triangle") {
// TypeScript knows: shape is Triangle
return 0.5 * shape.base * shape.height;
}
// TypeScript knows: shape is Rectangle
return shape.width * shape.height;
}
console.log(area({ radius: 5 }).toFixed(2)); // "78.54"
console.log(area({ width: 4, height: 6 })); // 24
console.log(area({ base: 3, height: 8, type: "triangle" })); // 12
User-Defined Type Guards
Sometimes none of the built-in narrowing techniques are expressive enough. You can write your own type predicate — a function that returns value is SomeType — to teach TypeScript how to narrow.
interface Cat {
kind: "cat";
name: string;
lives: number;
}
interface Dog {
kind: "dog";
name: string;
breed: string;
}
type Pet = Cat | Dog;
// The return type "pet is Cat" is the type predicate
function isCat(pet: Pet): pet is Cat {
return pet.kind === "cat";
}
function describeLifespan(pet: Pet): string {
if (isCat(pet)) {
// TypeScript knows: pet is Cat — pet.lives is available
return `${pet.name} has ${pet.lives} lives.`;
}
// TypeScript knows: pet is Dog — pet.breed is available
return `${pet.name} is a ${pet.breed}.`;
}
const whiskers: Pet = { kind: "cat", name: "Whiskers", lives: 9 };
const rex: Pet = { kind: "dog", name: "Rex", breed: "German Shepherd" };
console.log(describeLifespan(whiskers));
console.log(describeLifespan(rex));
// Type guards also work in array filters
const pets: Pet[] = [whiskers, rex, { kind: "cat", name: "Luna", lives: 7 }];
const cats = pets.filter(isCat); // TypeScript infers Cat[]
console.log(`Cats: ${cats.map(c => c.name).join(", ")}`);
The pets.filter(isCat) example shows why type predicates are especially useful — without the predicate, .filter() would return Pet[], leaving you stuck with the full union type.
Try It Yourself
Apply everything you've learned. This playground contains a union of three notification types — implement formatNotification so it handles each one correctly.
type EmailNotification = {
channel: "email";
to: string;
subject: string;
body: string;
};
type SMSNotification = {
channel: "sms";
to: string;
message: string;
};
type PushNotification = {
channel: "push";
title: string;
body: string;
badge?: number;
};
type Notification = EmailNotification | SMSNotification | PushNotification;
function formatNotification(n: Notification): string {
switch (n.channel) {
case "email":
return `[Email → ${n.to}] ${n.subject}: ${n.body}`;
case "sms":
return `[SMS → ${n.to}] ${n.message}`;
case "push":
const badgeInfo = n.badge !== undefined ? ` (badge: ${n.badge})` : "";
return `[Push] ${n.title}: ${n.body}${badgeInfo}`;
}
}
const email: Notification = {
channel: "email",
to: "user@example.com",
subject: "Welcome!",
body: "Thanks for signing up.",
};
const sms: Notification = {
channel: "sms",
to: "+15551234567",
message: "Your code is 482910",
};
const push: Notification = {
channel: "push",
title: "New message",
body: "Alice sent you a message.",
badge: 3,
};
console.log(formatNotification(email));
console.log(formatNotification(sms));
console.log(formatNotification(push));
Try extending the Notification union with a new "webhook" channel type and add a case for it — notice how TypeScript flags any missing cases if you add an exhaustiveness check in a default branch using never.
Key Takeaways
typeofnarrows primitive types:string,number,boolean,bigint,symbol,undefined,function, andobject.- Truthiness and nullish checks (
!== null,!== undefined,if (value)) eliminatenullandundefinedfrom a type. instanceofnarrows class instances and is ideal for handling error hierarchies.- Discriminated unions use a shared literal-type field so TypeScript can narrow exhaustively in a
switch— prefer these for domain modelling. innarrows by property presence and is useful when you can't add a discriminant to existing types.- User-defined type guards (
value is T) teach TypeScript about custom narrowing logic and enable properly-typed.filter()calls. - TypeScript's control flow analysis is path-sensitive — it tracks possible types independently on each branch, so you never need to cast with
asjust to call a method.
Pro Tip: If you ever feel tempted to write
as SomeType, stop and ask whether a discriminated union or type guard would solve the problem instead. Casts silence the compiler without giving it new information — narrowing teaches the compiler, making the rest of your code safer as a result.
Next Steps
Type narrowing works hand-in-hand with classes, where instanceof checks are the most natural way to narrow. Next, you'll learn how to define classes with access modifiers, inheritance, abstract methods, and interface contracts.
Next lesson
Classes And OOP
Learn TypeScript classes and OOP with constructors, inheritance, access modifiers, and interfaces for type-safe object-oriented code.
28 min