TypeScriptType Narrowing

Type Narrowing in TypeScript

Type narrowing is the process TypeScript uses to refine a broad type into a more specific one based on runtime checks in your code. When you have a value that could be one of several types, narrowing lets you safely access properties and methods that only exist on one of those types.

Think of it like a detective narrowing down suspects — you start with a wide set of possibilities and use evidence (runtime checks) to eliminate options until you know exactly what you are dealing with.

Without narrowing, TypeScript would reject your code any time you tried to use a type-specific feature on a union type. With narrowing, you teach the compiler what the runtime check guarantees.

Why TypeScript Needs Narrowing

Consider a function that receives a value that might be a string or a number. TypeScript will not let you call .toUpperCase() directly because that method does not exist on number. You need to narrow first.

TS
// Without narrowing — TypeScript error
function formatValue(value: string | number) {
  return value.toUpperCase(); // Error: Property 'toUpperCase' does not exist on type 'number'
}

// With narrowing — safe and correct
function formatValue(value: string | number) {
  if (typeof value === 'string') {
    return value.toUpperCase(); // TypeScript knows value is string here
  }
  return value.toFixed(2); // TypeScript knows value is number here
}
Note
TypeScript performs narrowing through control flow analysis — it reads your if/else blocks, switch statements, and early returns to track exactly which types are possible at each point in your code.
typeof Narrowing

The typeof operator is the most common narrowing tool. TypeScript understands these typeof guard strings: "string", "number", "bigint", "boolean", "symbol", "undefined", "object", and "function".

TS
function describeType(value: string | number | boolean | null) {
  if (typeof value === 'string') {
    console.log(`String with ${value.length} characters: "${value}"`);
  } else if (typeof value === 'number') {
    console.log(`Number: ${value.toFixed(2)}`);
  } else if (typeof value === 'boolean') {
    console.log(`Boolean: ${value ? 'true' : 'false'}`);
  } else {
    console.log('Value is null');
  }
}

describeType('hello');   // String with 5 characters: "hello"
describeType(3.14159);   // Number: 3.14
describeType(true);      // Boolean: true
describeType(null);      // Value is null
Warning
Watch out: typeof null === "object" is a famous JavaScript quirk. Always check for null explicitly before assuming an object type from a typeof check.
Truthiness Narrowing

JavaScript's truthy/falsy coercion doubles as a narrowing mechanism. Any value used in a boolean context eliminates the falsy possibilities — false, 0, "", null, undefined, NaN, and 0n.

This is especially useful for narrowing away null and undefined from optional values.

TS
function greetUser(name: string | null | undefined) {
  if (name) {
    // name is string here — null and undefined are falsy
    console.log(`Hello, ${name.toUpperCase()}!`);
  } else {
    console.log('Hello, stranger!');
  }
}

// Practical: processing an optional array
function sumItems(items: number[] | null) {
  if (!items) return 0;
  // items is number[] here
  return items.reduce((acc, n) => acc + n, 0);
}

// Filtering out falsy values — the naive approach loses type info
const mixed = ['hello', '', null, 'world', undefined, 0, 'TypeScript'];
const loose = mixed.filter(Boolean); // type is still (string | 0 | null | undefined)[]

// Better: use a type predicate for precise narrowing
const truthy = mixed.filter((x): x is string => typeof x === 'string' && x.length > 0);
// truthy is string[]
Tip
The Boolean constructor as a filter callback does NOT narrow the type automatically in TypeScript. Use a type predicate (shown above) for precise narrowing when filtering arrays.
Equality Narrowing

TypeScript narrows types through strict equality (===, !==) and loose equality (==, !=). Comparing two variables that share a type in their union narrows both.

Loose equality is mostly useful for the null/undefined dual-check shorthand: value == null catches both null and undefined in one check.

TS
function processId(id: string | number, otherId: string | boolean) {
  if (id === otherId) {
    // The only overlap between (string | number) and (string | boolean) is string
    // TypeScript narrows both to string here
    console.log(id.toUpperCase());      // safe
    console.log(otherId.toUpperCase()); // safe
  }
}

// Loose equality: x == null catches both null and undefined
function handleOptional(value: string | null | undefined) {
  if (value == null) {
    // value is null | undefined here
    return 'no value';
  }
  // value is string here
  return value.trim();
}

// Literal type narrowing via switch
type Direction = 'north' | 'south' | 'east' | 'west';

function getOpposite(dir: Direction): Direction {
  switch (dir) {
    case 'north': return 'south';
    case 'south': return 'north';
    case 'east':  return 'west';
    case 'west':  return 'east';
  }
}
The in Operator Narrowing

The in operator checks whether a property exists on an object. TypeScript uses this to narrow union types when the union members have distinct properties.

This is particularly handy when you have different object shapes that do not share a common tag property.

TS
type Cat = { meow: () => void; purr: () => void };
type Dog = { bark: () => void; fetch: () => void };
type Pet = Cat | Dog;

function makeSound(pet: Pet) {
  if ('meow' in pet) {
    // pet is Cat here
    pet.meow();
    pet.purr();
  } else {
    // pet is Dog here
    pet.bark();
  }
}

// Real-world: handling different API response shapes
type SuccessResponse = { data: unknown; status: 'ok' };
type ErrorResponse   = { error: string; code: number };
type ApiResponse     = SuccessResponse | ErrorResponse;

function handleResponse(res: ApiResponse) {
  if ('data' in res) {
    console.log('Success:', res.data);
  } else {
    console.error(`Error ${res.code}: ${res.error}`);
  }
}
Note
The in guard works even when the property might be undefined — it checks for existence, not truthiness. Use it to distinguish shapes, not to validate values.
instanceof Narrowing

instanceof narrows a value to a specific class. TypeScript understands this and refines the type inside the block. This is ideal when working with class hierarchies or when distinguishing built-in types like Date, Error, Map, and Set.

TS
class NetworkError extends Error {
  constructor(public statusCode: number, message: string) {
    super(message);
    this.name = 'NetworkError';
  }
}

class ValidationError extends Error {
  constructor(public field: string, message: string) {
    super(message);
    this.name = 'ValidationError';
  }
}

function handleError(err: unknown) {
  if (err instanceof NetworkError) {
    // err is NetworkError — statusCode is available
    console.error(`HTTP ${err.statusCode}: ${err.message}`);
    if (err.statusCode >= 500) retry();
  } else if (err instanceof ValidationError) {
    // err is ValidationError — field is available
    showFieldError(err.field, err.message);
  } else if (err instanceof Error) {
    // err is Error — only standard Error properties
    console.error('Unexpected error:', err.message);
  } else {
    console.error('Unknown thrown value:', err);
  }
}

declare function retry(): void;
declare function showFieldError(field: string, msg: string): void;

// Works with built-ins too
function processDate(value: Date | string) {
  if (value instanceof Date) {
    return value.toISOString(); // value is Date
  }
  return new Date(value).toISOString(); // value is string
}
Assignment Narrowing

TypeScript narrows the type of a variable based on what you assign to it. When you declare a variable with a union type, its type is narrowed each time you assign a concrete value — until another assignment widens it again.

TS
let id: string | number;

id = '123-abc';
console.log(id.toUpperCase()); // id is string — safe

id = 42;
console.log(id.toFixed(2));    // id is number — safe

// Practical: building a result incrementally
function parsePort(input: string): number | null {
  let result: number | null = null; // null initially

  const parsed = parseInt(input, 10);
  if (!isNaN(parsed) && parsed > 0 && parsed <= 65535) {
    result = parsed; // narrowed to number after this assignment
  }

  return result;
}

const port = parsePort('8080');
if (port !== null) {
  console.log(`Listening on port ${port}`); // port is number here
}
Discriminated Unions — The Most Powerful Pattern

A discriminated union (also called a tagged union) is a union of object types where each member has a shared literal property — the discriminant — with a unique value. TypeScript narrows to the exact member when you check that property.

This is the recommended way to model "one of several shapes" in TypeScript. It is more explicit than in narrowing and works beautifully with switch.

TS
// The discriminant is the 'kind' property
type Circle    = { kind: 'circle';    radius: number };
type Rectangle = { kind: 'rectangle'; width: number; height: number };
type Triangle  = { kind: 'triangle';  base: number; height: number };
type Shape     = Circle | Rectangle | Triangle;

function getArea(shape: Shape): number {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2;      // shape is Circle
    case 'rectangle':
      return shape.width * shape.height;       // shape is Rectangle
    case 'triangle':
      return 0.5 * shape.base * shape.height;  // shape is Triangle
  }
}

// Real-world: Redux-style action types
type FetchAction =
  | { type: 'FETCH_START' }
  | { type: 'FETCH_SUCCESS'; payload: string[] }
  | { type: 'FETCH_ERROR';   error: string };

function reducer(state: string[], action: FetchAction): string[] {
  switch (action.type) {
    case 'FETCH_START':
      return [];                   // no extra properties
    case 'FETCH_SUCCESS':
      return action.payload;       // payload: string[] is available
    case 'FETCH_ERROR':
      console.error(action.error); // error: string is available
      return state;
  }
}
Success
Discriminated unions are the go-to pattern for modelling state machines, API responses, form states, Redux actions, and anything where a value is "one of several distinct shapes". They give you exhaustive compile-time safety with zero runtime overhead.
Control Flow Analysis

TypeScript's compiler performs control flow analysis — it simulates the possible execution paths through your code and tracks which types remain possible after each branch.

After an early return, throw, or break, TypeScript removes the handled types from what is possible in the remaining code. This lets you write narrowing code in the "assert early, operate confidently" style.

TS
function processUser(user: { name: string; age: number | null; email?: string }) {
  // After these guards, TypeScript knows the constraints hold for the rest of the function
  if (user.age === null) {
    throw new Error('User age is required');
  }
  // user.age is number from here on

  if (!user.email) {
    return { name: user.name, age: user.age }; // early return
  }
  // user.email is string from here on (undefined was eliminated by truthiness check)

  return {
    name: user.name,
    age: user.age,
    email: user.email.toLowerCase(), // safe: email is string
  };
}

// Control flow works across assignments too
function demonstrateCFA(flag: boolean) {
  let x: string | number = flag ? 'hello' : 42;

  if (typeof x === 'string') {
    x = x.toUpperCase(); // x is string inside this branch
  }
  // After the if block TypeScript treats x as string | number again,
  // because the assignment inside the branch might not have run
  console.log(x);
}
Tip
Prefer early returns (the "guard clause" pattern) over deeply nested if/else. It keeps narrowed types flat and readable — each guard removes one possibility so the happy path sits at the bottom with the most specific types.
The never Type and Exhaustiveness Checking

When TypeScript narrows a union down completely — every possible type has been handled — the remaining type is never. You can exploit this to write exhaustiveness checks that produce a compile-time error if you forget to handle a new union member.

TS
type Shape = Circle | Rectangle | Triangle; // from earlier

function assertNever(value: never): never {
  throw new Error(`Unhandled discriminant value: ${JSON.stringify(value)}`);
}

function describeShape(shape: Shape): string {
  switch (shape.kind) {
    case 'circle':
      return `Circle with radius ${shape.radius}`;
    case 'rectangle':
      return `${shape.width}x${shape.height} rectangle`;
    case 'triangle':
      return `Triangle with base ${shape.base}`;
    default:
      // If you add a new Shape variant and forget to handle it,
      // TypeScript will error here because shape will not be 'never'
      return assertNever(shape);
  }
}

// Example: adding a new shape variant
// type Ellipse = { kind: 'ellipse'; rx: number; ry: number };
// type Shape = Circle | Rectangle | Triangle | Ellipse;
//
// Uncommenting the lines above causes a compile error in describeShape
// until you add a 'case ellipse:' branch — that is the whole point!
Warning
Without an exhaustiveness check, adding a new union member silently falls through to your default case (or returns undefined). The assertNever pattern catches this at compile time, not at runtime in production.
Type Predicates — User-Defined Type Guards

Sometimes TypeScript cannot infer a narrowing from a plain boolean expression. You can teach it by writing a function with a type predicate return type: value is SomeType. When the function returns true, TypeScript narrows the caller's type to SomeType.

TS
// Without a predicate — TypeScript cannot narrow at the call site
function isStringPlain(x: unknown) {
  return typeof x === 'string'; // returns boolean, no narrowing at call site
}

// With a predicate — TypeScript narrows wherever this function is called
function isString(x: unknown): x is string {
  return typeof x === 'string';
}

const val: unknown = 'hello';
if (isString(val)) {
  console.log(val.toUpperCase()); // val is string here
}

// Practical: validating API responses
interface User {
  id: number;
  name: string;
  email: string;
}

function isUser(obj: unknown): obj is User {
  if (typeof obj !== 'object' || obj === null) return false;
  const o = obj as Record<string, unknown>;
  return (
    typeof o.id === 'number' &&
    typeof o.name === 'string' &&
    typeof o.email === 'string'
  );
}

async function fetchUser(id: number): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  const data: unknown = await res.json();

  if (!isUser(data)) {
    throw new Error('Unexpected API response shape');
  }
  // data is User here — fully typed
  return data;
}
Practical Example: API Response and Form Validation

Combining several narrowing techniques, here is a realistic pattern for handling typed API responses with discriminated unions and exhaustiveness checking, plus a form validation pattern using a result type.

TS
// Model the possible states of an async operation
type AsyncState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error';   message: string; code?: number };

interface Product {
  id: string;
  name: string;
  price: number;
}

function assertNever(x: never): never {
  throw new Error(`Unhandled case: ${JSON.stringify(x)}`);
}

function renderProductState(state: AsyncState<Product>): string {
  switch (state.status) {
    case 'idle':
      return 'Press the button to load a product.';
    case 'loading':
      return 'Loading…';
    case 'success':
      // state.data is Product
      return `${state.data.name} — $${state.data.price.toFixed(2)}`;
    case 'error':
      // state.message is string, state.code is number | undefined
      return state.code
        ? `Error ${state.code}: ${state.message}`
        : `Error: ${state.message}`;
    default:
      return assertNever(state);
  }
}

// Form validation using a result discriminated union
type FieldResult =
  | { valid: true;  value: string }
  | { valid: false; error: string };

function validateEmail(raw: string): FieldResult {
  const trimmed = raw.trim();
  if (!trimmed) {
    return { valid: false, error: 'Email is required' };
  }
  if (!/^[^s@]+@[^s@]+.[^s@]+$/.test(trimmed)) {
    return { valid: false, error: 'Enter a valid email address' };
  }
  return { valid: true, value: trimmed.toLowerCase() };
}

function submitForm(rawEmail: string) {
  const result = validateEmail(rawEmail);

  if (!result.valid) {
    // result is { valid: false; error: string }
    console.error('Validation failed:', result.error);
    return;
  }

  // result is { valid: true; value: string }
  console.log('Submitting with email:', result.value);
}
renderProductState({ status: 'idle' })
// => "Press the button to load a product."

renderProductState({ status: 'loading' })
// => "Loading…"

renderProductState({ status: 'success', data: { id: '1', name: 'Keyboard', price: 129 } })
// => "Keyboard — $129.00"

renderProductState({ status: 'error', message: 'Not found', code: 404 })
// => "Error 404: Not found"

submitForm('')
// => Validation failed: Email is required

submitForm('not-an-email')
// => Validation failed: Enter a valid email address

submitForm('user@example.com')
// => Submitting with email: user@example.com
Common Narrowing Mistakes

Mistake

Problem

Fix

Using typeof to check for null

typeof null === "object" is true in JS

Check value !== null explicitly first

Skipping the else branch

Type stays wide after the if block exits

Use early return or else to fully narrow

Using Boolean() to filter arrays

TypeScript keeps the original wide type

Use a type predicate: (x): x is T => ...

Non-exhaustive switch on a union

New variants silently fall through

Add assertNever(x) in the default case

Narrowing a let variable then reassigning

TypeScript widens the type after assignment

Use const, or re-check the type after the assignment

Narrowing Technique Summary

Technique

Best For

Example Check

typeof

Primitives: string, number, boolean, undefined

typeof x === "string"

instanceof

Class instances and built-ins like Date and Error

x instanceof Date

in operator

Objects with distinct property shapes

"data" in response

Truthiness

Removing null and undefined from optionals

if (value) { ... }

Equality

Literal types and shared union overlap

if (x === "north") { ... }

Discriminated union

Tagged object unions — the most scalable pattern

switch (shape.kind) { ... }

Type predicate

Custom runtime validation functions

(x): x is User => isUserShape(x)

never + assertNever

Exhaustiveness checking at compile time

default: return assertNever(x)

Key Takeaways
  1. Narrowing lets TypeScript track type refinements through your control flow — use it instead of type assertions.

  2. typeof and instanceof cover most primitive and class-based narrowing needs.

  3. Discriminated unions (tagged unions) are the most scalable pattern for complex object shapes.

  4. Always add assertNever in default switch branches to catch unhandled union members at compile time.

  5. Type predicates let you encode custom runtime checks so TypeScript narrows at the call site.

  6. Prefer early returns (guard clauses) to keep narrowed types flat and readable.

  7. Truthiness narrows away null and undefined, but use == null for the combined check.

  8. Control flow analysis means TypeScript re-evaluates the type of a variable after every assignment — this is a feature, not a bug.

Tip
When you find yourself writing as SomeType to silence a TypeScript error, ask whether a narrowing check would be safer. Type assertions bypass the type system; narrowing works with it.