Skip to main content

You have a value. You don't know what it is. It could be a string. It could be a number. It could be an object with seventeen properties, only three of which you care about. But you need to DO something with it — and what you do depends on what it is.

This is the fundamental problem of programming with union types. Unions tell you what a value COULD be. Narrowing tells you what it IS — right here, right now, in this specific code path.

TypeScript's narrowing is what makes union types usable. Without it, you'd have a value of type string | number and no way to call .toUpperCase() (because it might be a number) or .toFixed() (because it might be a string). Narrowing is how you prove to the compiler that, in this branch, the value is definitely a string.

typeof Narrowing​

The simplest and most common narrowing uses JavaScript's typeof operator:

function processValue(value: string | number): string {
if (typeof value === "string") {
// In this branch, value is string
return value.toUpperCase();
}
// In this branch, value is number
return value.toFixed(2);
}

TypeScript understands typeof checks. After typeof value === "string", the type narrows to string. After the if block, the type narrows to number (the remaining possibility).

typeof works with these types:

  • "string"
  • "number"
  • "bigint"
  • "boolean"
  • "symbol"
  • "undefined"
  • "object"
  • "function"

Watch out: typeof null === "object" in JavaScript. TypeScript knows this and handles it correctly:

function processValue(value: string | null): string {
if (typeof value === "object") {
// TypeScript narrows to null (not object)
return "null";
}
// value is string
return value.toUpperCase();
}

Truthiness Narrowing​

TypeScript narrows based on truthiness checks:

function processValue(value: string | null | undefined): string {
if (value) {
// value is string (null and undefined are falsy)
return value.toUpperCase();
}
// value is null | undefined
return "No value";
}

Be careful: empty string "" and 0 are falsy. If those are valid values, use an explicit null check instead:

function processValue(value: string | null): string {
if (value !== null) {
return value.toUpperCase(); // Works even for empty string
}
return "No value";
}

Equality Narrowing​

TypeScript narrows based on equality checks:

function processValue(x: string | number, y: string | boolean): void {
if (x === y) {
// x and y must both be string (the only type they share)
console.log(x.toUpperCase());
}
}

This also works with !==, ==, and !=.

in Operator Narrowing​

The in operator checks if a property exists on an object:

type Fish = { swim: () => void };
type Bird = { fly: () => void };

function move(animal: Fish | Bird): void {
if ("swim" in animal) {
animal.swim(); // animal is Fish
} else {
animal.fly(); // animal is Bird
}
}

in is useful when your union members have different properties. It's less precise than a discriminated union (which uses a shared discriminant property), but it works with types you don't control.

instanceof Narrowing​

instanceof checks if a value is an instance of a class:

class ApiError extends Error {
constructor(public statusCode: number, message: string) {
super(message);
}
}

class ValidationError extends Error {
constructor(public fields: string[], message: string) {
super(message);
}
}

function handleError(error: Error): string {
if (error instanceof ApiError) {
return `API Error ${error.statusCode}: ${error.message}`;
}
if (error instanceof ValidationError) {
return `Validation failed for: ${error.fields.join(", ")}`;
}
return `Unknown error: ${error.message}`;
}

instanceof works with classes, not interfaces or type aliases. If you're using interfaces, use discriminated unions or type predicates instead.

Type Predicates: Custom Narrowing Functions​

Sometimes the built-in narrowing isn't enough. You need to tell TypeScript: "if this function returns true, the argument is this type." Type predicates do exactly that:

type User = { name: string; email: string };
type Admin = { name: string; email: string; role: "admin"; permissions: string[] };

function isAdmin(user: User | Admin): user is Admin {
return "role" in user && user.role === "admin";
}

function processUser(user: User | Admin): void {
if (isAdmin(user)) {
// user is Admin
console.log(user.permissions);
} else {
// user is User
console.log(user.email);
}
}

The user is Admin return type is the type predicate. It says "if this function returns true, the argument is Admin." TypeScript uses this information to narrow the type in the calling code.

Type Predicates for Array Filtering​

Type predicates are especially useful with .filter():

const values: (string | null | undefined)[] = ["a", null, "b", undefined, "c"];

// Without type predicate — TypeScript can't narrow
const filtered1 = values.filter(v => v !== null);
// filtered1 is still (string | null | undefined)[]

// With type predicate — TypeScript narrows
function isNotNull<T>(value: T | null | undefined): value is T {
return value !== null && value !== undefined;
}

const filtered2 = values.filter(isNotNull);
// filtered2 is string[] — TypeScript knows null and undefined are removed

This is one of the most practical uses of type predicates. Write a type predicate once, use it everywhere you filter arrays.

Assertion Functions​

Type predicates narrow in conditional branches. Assertion functions narrow unconditionally — they throw if the condition isn't met:

function assertIsString(value: unknown): asserts value is string {
if (typeof value !== "string") {
throw new Error(`Expected string, got ${typeof value}`);
}
}

function processValue(value: unknown): string {
assertIsString(value);
// After the assertion, value is string
return value.toUpperCase();
}

The asserts value is string return type tells TypeScript: "after this function returns normally, the argument is string." If the function throws, the code after it doesn't execute, so the narrowing is safe.

Assertions for Node.js​

Assertion functions are perfect for validating environment variables and configuration:

function assertIsDefined<T>(value: T | undefined, name: string): asserts value is T {
if (value === undefined) {
throw new Error(`${name} is not defined`);
}
}

const apiKey = process.env.API_KEY;
assertIsDefined(apiKey, "API_KEY");
// apiKey is string (not string | undefined)
console.log(apiKey.toUpperCase());

Exhaustiveness Checking​

When you have a discriminated union, you want to be SURE you've handled every variant. Exhaustiveness checking guarantees it:

type Shape = Circle | Square | Triangle;

function getArea(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.sideLength ** 2;
case "triangle":
return (shape.base * shape.height) / 2;
default:
const _exhaustive: never = shape;
return _exhaustive;
}
}

If you add a new variant to Shape (say, Rectangle), the default branch will error because shape is no longer never — it's Rectangle. TypeScript forces you to handle the new variant.

This is the compiler as your safety net. You can't forget to update a switch statement. You can't ship code that silently ignores a new variant. The type system has your back.

Narrowing with switch Statements​

switch statements with discriminated unions are the most common narrowing pattern:

type Action =
| { type: "CREATE"; payload: { name: string } }
| { type: "UPDATE"; payload: { id: number; name: string } }
| { type: "DELETE"; payload: { id: number } };

function reducer(state: State, action: Action): State {
switch (action.type) {
case "CREATE":
return { ...state, items: [...state.items, { id: nextId(), name: action.payload.name }] };
case "UPDATE":
return {
...state,
items: state.items.map(item =>
item.id === action.payload.id ? { ...item, name: action.payload.name } : item
),
};
case "DELETE":
return { ...state, items: state.items.filter(item => item.id !== action.payload.id) };
}
}

In each case, TypeScript narrows action to the specific variant. You get autocomplete for action.payload.name in the CREATE case but not in the DELETE case.

Narrowing with if and Early Returns​

Early returns are a clean way to narrow types:

function processValue(value: string | number | null): string {
if (value === null) {
return "No value";
}

if (typeof value === "number") {
return value.toFixed(2);
}

// value is string
return value.toUpperCase();
}

Each early return removes a variant from the union. After the null check, value is string | number. After the number check, value is string. The code reads top to bottom, each branch handling one case.

The never-Narrowing Pattern​

Sometimes you want to REMOVE types from a union:

type NonNullable<T> = T extends null | undefined ? never : T;

type A = NonNullable<string | null | undefined>;
// = string (null and undefined are removed)

This uses conditional types (Chapter 14) to filter a union. never in a union disappears because never represents the empty set — string | never is just string.

Try This: Narrowing​

  1. Write a function that takes string | number | boolean and returns different strings based on the type. Use typeof narrowing.
  2. Create a discriminated union for ApiResponse (loading, success, error). Write a function that narrows each variant and returns appropriate UI strings.
  3. Write a type predicate isValidEmail(value: unknown): value is string that checks if a value is a string containing @.
  4. Write an assertion function assertIsNumber and use it to narrow an unknown value.
  5. Create a discriminated union with 4 variants. Write a switch statement with exhaustiveness checking. Add a 5th variant and see the compiler error.

Time needed: 20 minutes.

What to notice: How each narrowing technique fits a different scenario. typeof for primitives. in for objects with different properties. Discriminated unions for the most precise control. Type predicates for reusable narrowing logic.

The Bridge​

You now know how to define types and narrow them. You can model complex states with discriminated unions. You can prove to the compiler what type a value is at any point in your code.

But there's a problem. Look at the functions you've written in this chapter. They all work with SPECIFIC types — string | number, Shape, Action. What if you want to write a function that works with ANY type? A function that takes an array of T and returns T? A function that works the same way whether T is a string, a number, or a User?

That's generics. And they're the subject of the next chapter.


In the next chapter: generic functions, type parameters, constraints, and the art of writing functions that work with any type while preserving type safety.