Introduction
When you need to model a value that could be a string, a number, or an object—but not at the same time—you're working with one of TypeScript's most powerful features: union types. Unions and narrowing is a practical guide for developers because it forces you to think clearly about what values are actually possible and then safely extract the type you need. Without proper narrowing, TypeScript (and your team) will struggle to understand which operations are safe at any given point in your code.
Over the next 38 days of learning JavaScript and TypeScript, understanding unions and narrowing will save you from silent bugs and make your code more readable. This guide walks through the mechanics and shows you the patterns we use daily in production systems.
Union types: modeling alternatives
A union type explicitly declares that a value can be one of several types. You express this with the pipe operator (|):
type Response = string | number | null;
type Status = "pending" | "success" | "error";
type Payload = { type: "user"; id: number } | { type: "admin"; role: string };Union types are honest. They tell everyone reading the code: "this function might return different shapes depending on the input or context." That honesty is what makes narrowing necessary and valuable.
Consider a real scenario: you're building a payment processing service. An API endpoint might return different response shapes depending on whether the transaction succeeded, failed temporarily, or hit a validation error:
type PaymentResult =
| { status: "success"; transactionId: string; amount: number }
| { status: "failed"; reason: string; retryable: boolean }
| { status: "pending"; estimatedTime: number };
function handlePayment(result: PaymentResult) {
// At this point, TypeScript knows result is one of three shapes.
// We cannot safely access result.transactionId—only "success" has it.
// We need to narrow first.
}Without a union, you'd be tempted to model this as a single object with optional fields, which loses type safety and makes it easy to forget to check whether a field exists before using it.
typeof and instanceof: narrowing primitives and classes
The typeof operator narrows unions that include primitive types. It's the first tool you reach for when distinguishing between strings, numbers, booleans, and symbols:
function processValue(value: string | number | boolean) {
if (typeof value === "string") {
// TypeScript now knows value is a string
console.log(value.toUpperCase());
} else if (typeof value === "number") {
// value is narrowed to number
console.log(value.toFixed(2));
} else {
// value is narrowed to boolean
console.log(!value);
}
}The guard works because TypeScript's control flow analysis tracks the condition and understands that if you've checked typeof value === "string", then value must be a string within that branch.
For class instances, use instanceof:
class HttpError extends Error {
constructor(public status: number, message: string) {
super(message);
}
}
class ValidationError extends Error {
constructor(public fields: string[], message: string) {
super(message);
}
}
function handleError(error: HttpError | ValidationError | Error) {
if (error instanceof HttpError) {
// error.status is accessible
console.log(`HTTP error: ${error.status}`);
} else if (error instanceof ValidationError) {
// error.fields is accessible
console.log(`Invalid fields: ${error.fields.join(", ")}`);
} else {
// generic Error
console.log(error.message);
}
}instanceof checks the prototype chain, so it works naturally with inheritance and class hierarchies you'll encounter in frameworks like NestJS or when building domain objects in a trading or e-commerce system.
The in operator: narrowing by property existence
When you have a union of objects with different shapes—especially plain objects without a class wrapper—the in operator checks whether a property exists and narrows accordingly:
type Dog = { name: string; bark: () => void };
type Cat = { name: string; meow: () => void };
function makeSound(animal: Dog | Cat) {
if ("bark" in animal) {
// animal is narrowed to Dog
animal.bark();
} else {
// animal is narrowed to Cat
animal.meow();
}
}This pattern shines when you don't want to introduce a formal discriminant (a shared field that identifies the type). However, we often do introduce one intentionally—it's cleaner:
type Notification =
| { type: "email"; recipient: string; subject: string }
| { type: "sms"; phoneNumber: string; content: string }
| { type: "webhook"; url: string; payload: object };
function sendNotification(notif: Notification) {
if (notif.type === "email") {
// notif is narrowed to the email variant
console.log(`Emailing ${notif.recipient}`);
} else if (notif.type === "sms") {
console.log(`Texting ${notif.phoneNumber}`);
} else {
console.log(`Posting to ${notif.url}`);
}
}A discriminant like type is more explicit and scales better than checking for property existence. In a CRM, e-commerce, or event-driven system, this pattern becomes the backbone of your type safety.
Control flow analysis: TypeScript tracks narrowing through branches
TypeScript's control flow analysis is remarkably smart. It understands that after a truthiness check, an optional field is no longer optional within that block:
function processUser(user: { name: string; email?: string }) {
if (user.email) {
// email is narrowed to string (no longer string | undefined)
console.log(user.email.toLowerCase());
}
// Back here, email is string | undefined again
}It also tracks narrowing through early returns, which is a common pattern in guard clauses:
function validateOrder(order: { customerId?: string; items: any[] }) {
if (!order.customerId) {
throw new Error("Customer ID is required");
}
// After the guard, TypeScript knows customerId is string
const customerId = order.customerId;
// ... safe to use customerId
}And it understands type predicates—functions that narrow a type for the caller:
function isString(value: unknown): value is string {
return typeof value === "string";
}
const items: (string | number)[] = ["hello", 42, "world"];
const strings = items.filter(isString);
// strings is now (string)[] due to the type predicateThis matters in production code. When you're filtering API responses, transforming data in a pipeline, or validating incoming requests, control flow analysis keeps your code safe without forcing you to manually assert types.
Common mistakes
- Forgetting to narrow before access. Trying to call a method on a union type without first narrowing it will cause a TypeScript error. Don't rely on runtime behavior to do the narrowing for you—be explicit.
- Using type assertion instead of narrowing.
value as stringtells TypeScript to trust you, but it doesn't actually check anything. Usetypeof,instanceof, or guards instead. Type assertions mask bugs. - Relying on implicit narrowing across function boundaries. If you narrow a union inside a function and then pass it to another function, the second function receives the original union type, not the narrowed version. Return the narrowed value explicitly.
- Mixing discriminants inconsistently. If you define a union with a
typefield as the discriminant, always check that field. Don't mix in checks for other properties—it confuses readers and makes the code fragile. - Over-using optional fields instead of unions. If a value can be one of three different objects with mostly different shapes, a union is clearer than a single object with many optional fields.
Key takeaways
- Union types model alternatives honestly; narrowing lets you safely work with each alternative.
- Use
typeoffor primitives,instanceoffor class instances, and discriminant checks (liketype === "email") for object unions. - The
inoperator narrows by property existence, but a discriminant field is usually clearer and more maintainable. - Control flow analysis is powerful—TypeScript tracks narrowing through if branches, returns, and type predicates automatically.
- Never use type assertions as a shortcut; always narrow explicitly so that readers (and future you) understand what types are actually valid at each point.
- In production systems handling payments, events, notifications, or user roles, clear union types and precise narrowing are investments in correctness and team clarity.






Comments
Loading comments…