A union type in TypeScript is a way of saying “this value could be one of several things.” A variable might be a string or a number. A function might return a User or null. That flexibility is useful, but it comes with a catch: when a value could be several types at once, TypeScript won’t let you treat it as any single one of them. You can’t call .toUpperCase() on something that might be a number, and TypeScript is right to stop you.
So how do you actually use a union? The answer is type narrowing. Narrowing is the process of going from a broad type (“string or number”) to a specific one (“definitely a string, right here”) by writing a check that TypeScript understands. Once you prove which case you’re in, TypeScript opens up the full set of properties and methods for that type. This article walks through every common way to narrow, one step at a time, with examples you can read top to bottom.
What problem narrowing solves
Let’s start with a function that takes a value that could be a string or a number, and we want to format it.
function format(value: string | number) {
// value.toUpperCase() // Error: Property 'toUpperCase' does not exist on type 'string | number'.
return value;
}
TypeScript blocks value.toUpperCase() because toUpperCase exists on string but not on number. As far as the type system knows, value could still be either. The union is the broad type. Our job is to narrow it so that inside one branch of code, TypeScript is convinced the value is a string and only a string.
Here’s the same function, fixed:
function format(value: string | number) {
if (typeof value === "string") {
return value.toUpperCase(); // OK: value is string here
}
return value.toFixed(2); // OK: value is number here
}
Notice that we never told TypeScript “trust me, it’s a string.” We wrote a real runtime check, and TypeScript followed our logic. That is the heart of narrowing: you write ordinary JavaScript checks, and the type system reads along with you.
Narrowing is about flow, not casting
Narrowing changes what TypeScript believes a value is within a specific block of code, based on a check that also runs at runtime. It is not the same as a type assertion (value as string), which silences the compiler without any real check. Prefer narrowing whenever you can.
Narrowing with typeof
The typeof operator is JavaScript’s built-in way to ask “what primitive kind of value is this?” TypeScript recognizes typeof checks and uses them to narrow. The strings typeof can return that matter most are "string", "number", "boolean", "object", "function", "undefined", "bigint", and "symbol".
function describe(value: string | number | boolean) {
if (typeof value === "string") {
return `text of length ${value.length}`;
}
if (typeof value === "number") {
return `number rounded to ${Math.round(value)}`;
}
return `boolean that is ${value ? "true" : "false"}`;
}
Inside the first if, value is a string. Inside the second, it’s a number. After both checks fail, only boolean remains, so TypeScript narrows it to boolean automatically in the final line. That last point is worth remembering: narrowing also works by elimination.
typeof null is 'object'
A famous JavaScript quirk: typeof null returns "object", not "null". So typeof value === "object" does not guarantee you have a real object, it might be null. To narrow out null, use a different check, which we cover below.
Narrowing with truthiness
Often the union you’re dealing with is something like string | null or User | undefined. You don’t need typeof for that, a plain truthiness check is enough. In JavaScript, values like null, undefined, 0, "", NaN, and false are “falsy”; everything else is “truthy.” TypeScript uses a truthiness check to remove the falsy possibilities from the type.
function greet(name: string | null) {
if (name) {
// name is string here, null has been ruled out
return `Hello, ${name.toUpperCase()}!`;
}
return "Hello, guest!";
}
This is the most common narrowing you’ll write in real code, because functions return null or undefined all the time. Be a little careful, though: a truthiness check on a string also removes the empty string "", and on a number it removes 0. If 0 or "" are valid values you care about, check explicitly with name !== null instead.
Narrowing with equality
When you compare two values with ===, !==, ==, or !=, TypeScript can narrow both sides. If two values are strictly equal, they must share a type, so the type system intersects what it knows about each.
function compare(a: string | number, b: string | boolean) {
if (a === b) {
// The only type both can share is string,
// so here a is string and b is string.
return a.toUpperCase() === b.toUpperCase();
}
return false;
}
Equality narrowing also shines when comparing against a literal. Checking value === "loading" narrows value to that exact string. And comparing against null or undefined is a clean way to remove them:
function trim(value: string | undefined) {
if (value !== undefined) {
return value.trim(); // value is string
}
return "";
}
Narrowing with the in operator
The in operator checks whether an object has a property with a given name. When your union is made of object types with different shapes, in is a tidy way to tell them apart.
type Admin = { role: "admin"; permissions: string[] };
type Guest = { role: "guest"; sessionId: string };
function summarize(account: Admin | Guest) {
if ("permissions" in account) {
// account is Admin, because only Admin has 'permissions'
return `Admin with ${account.permissions.length} permissions`;
}
// account is Guest here
return `Guest session ${account.sessionId}`;
}
This works because the two shapes have different keys. The moment TypeScript confirms the permissions key exists, it knows you must be holding an Admin.
Narrowing with instanceof
For values created from a class, instanceof asks “was this object built from this constructor?” TypeScript narrows accordingly. This is handy with built-in classes like Date and Error, as well as your own classes.
function formatDate(input: Date | string) {
if (input instanceof Date) {
return input.toISOString(); // input is Date
}
return input; // input is string
}
A common real-world use is error handling. In a catch block, the caught value has type unknown, and instanceof Error is the safe way to confirm you actually have an Error before reading .message.
try {
doSomethingRisky();
} catch (err) {
if (err instanceof Error) {
console.error(err.message);
} else {
console.error("Unknown failure", err);
}
}
Discriminated unions: the cleanest pattern
When you control the shape of your types, there’s a pattern that makes narrowing almost effortless: the discriminated union. The idea is to give every member of the union a shared property, usually called something like kind, type, or status, that holds a unique literal value. That shared property is the “discriminant,” and checking it narrows the whole object in one move.
type Circle = { kind: "circle"; radius: number };
type Square = { kind: "square"; side: number };
type Rectangle = { kind: "rectangle"; width: number; height: number };
type Shape = Circle | Square | Rectangle;
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2; // shape is Circle
case "square":
return shape.side ** 2; // shape is Square
case "rectangle":
return shape.width * shape.height; // shape is Rectangle
}
}
Here’s the flow in plain terms:
shape: Circle | Square | Rectangle
│
▼ switch (shape.kind)
┌────┴─────┬──────────┐
"circle" "square" "rectangle"
│ │ │
radius side width,height
Because each kind value is unique, TypeScript can match a single property and unlock exactly the right shape inside each case. Discriminated unions scale beautifully: API responses ({ status: "success" } | { status: "error" }), Redux-style actions, and state machines all use this pattern.
Let TypeScript catch missing cases
Add a default branch with const _exhaustive: never = shape;. If you later add a new shape to the union but forget to handle it, TypeScript will report an error there, because the leftover type is no longer assignable to never. This turns a forgotten case into a compile-time error instead of a silent bug.
Type guards: narrowing you write yourself
Sometimes the check you need is too specific for typeof or in. You can teach TypeScript a custom check by writing a function whose return type is a type predicate, written as value is SomeType. When that function returns true, TypeScript narrows the argument to SomeType.
type User = { name: string; email: string };
function isUser(value: unknown): value is User {
return (
typeof value === "object" &&
value !== null &&
"name" in value &&
"email" in value
);
}
function welcome(value: unknown) {
if (isUser(value)) {
// value is User here, thanks to the predicate
return `Welcome, ${value.name}`;
}
return "Unknown visitor";
}
The body of isUser is ordinary runtime code. The special part is the return type value is User. That promise tells the compiler: “if I return true, treat the argument as a User from here on.” Custom type guards are how you validate data coming from outside your program, like a JSON response from an API, and convert unknown into something typed and safe.
A type predicate is a promise you must keep
TypeScript trusts your value is User predicate without verifying it. If your function returns true for something that is not actually a User, the type system will be wrong and you’ll get runtime errors. Make the body genuinely check every property the type claims to have.
Putting the methods side by side
| Check | Best for | Example |
|---|---|---|
typeof |
Primitives (string, number, boolean…) | typeof x === "string" |
| Truthiness | Removing null / undefined / falsy |
if (value) |
| Equality | Literals, ruling out specific values | x === "ready" |
in |
Objects with different keys | "id" in obj |
instanceof |
Class instances, Error, Date |
e instanceof Error |
| Discriminated union | Object unions you designed | switch (s.kind) |
| Type guard | Custom or external-data checks | isUser(value) |
Recap
Narrowing is the bridge between flexible union types and safe, specific code. A union tells TypeScript “this could be several things,” and TypeScript refuses to assume which until you prove it. Every technique here is a way of giving that proof:
typeofnarrows primitives, and narrowing-by-elimination handles the leftover case.- Truthiness is the quick way to drop
nullandundefined, just watch out for0and"". - Equality narrows both sides of a comparison and is great with literals.
inseparates object shapes by their keys, whileinstanceofseparates class instances.- Discriminated unions are the cleanest pattern when you design the types: one shared literal property unlocks the rest.
- Type guards with a
value is Typepredicate let you write checks TypeScript can’t infer on its own, which is essential for validating outside data.
Once narrowing clicks, unions stop feeling fragile and start feeling like exactly the right tool: you describe every possibility up front, then handle each one with confidence. The compiler walks through your logic with you and keeps you honest in every branch.