Type Narrowing Without the Boilerplate

TypeScript knows more about your types inside a conditional block than outside it. Type guards are the mechanism that makes this work โ€” they teach the compiler that after a certain check, a value can only be one specific type. The built-in ones (typeof, instanceof, in) cover most cases. Custom type predicates and discriminated unions handle the rest.

๐Ÿค” Sound familiar?
  • You write as SomeType to silence the compiler instead of narrowing properly
  • You have runtime checks that TypeScript doesn't respect because you didn't use a type predicate
  • Your discriminated unions fall through to neverin switch statements and you're not sure why
  • You're repeating the same if (typeof x === 'string') checks everywhere instead of extracting a guard

Discriminated unions + exhaustive switch statements eliminate entire categories of runtime errors that type assertions only hide.

Built-in Narrowing

typeof

function format(value: string | number | boolean): string {
  if (typeof value === 'string') {
    return value.toUpperCase(); // TypeScript knows: value is string here
  }
  if (typeof value === 'number') {
    return value.toFixed(2); // value is number here
  }
  return String(value); // value is boolean here
}

// typeof only narrows to JS primitives:
// 'string' | 'number' | 'bigint' | 'boolean' | 'symbol' | 'undefined' | 'function' | 'object'
// Note: typeof null === 'object' โ€” always check null separately

instanceof

class NetworkError extends Error {
  constructor(public status: 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(error: unknown): string {
  if (error instanceof NetworkError) {
    return `Network ${error.status}: ${error.message}`;
  }
  if (error instanceof ValidationError) {
    return `Validation on '${error.field}': ${error.message}`;
  }
  if (error instanceof Error) {
    return error.message;
  }
  return 'Unknown error';
}

in operator

interface Cat { meow(): void; }
interface Dog { bark(): void; }

function makeSound(animal: Cat | Dog): void {
  if ('meow' in animal) {
    animal.meow(); // animal is Cat
  } else {
    animal.bark(); // animal is Dog
  }
}

// Useful for optional properties too
interface Config {
  timeout?: number;
  endpoint: string;
}

function logConfig(config: Config) {
  if ('timeout' in config && config.timeout !== undefined) {
    console.log('Timeout:', config.timeout); // number, not number | undefined
  }
}

Discriminated Unions: The Most Powerful Pattern

A discriminated union is a union type where every member has a common literal property (the discriminant). TypeScript uses this to narrow in switch statements with total exhaustiveness checking.


flowchart TD
    result["Result: Success | Failure | Pending"]
    result -->|"status === 'success'"| s["data: T is available"]
    result -->|"status === 'failure'"| f["error: string is available"]
    result -->|"status === 'pending'"| p["Neither โ€” still loading"]

    style s fill:#16a34a,color:#fff,stroke:#15803d
    style f fill:#dc2626,color:#fff,stroke:#b91c1c
    style p fill:#0078D4,color:#fff,stroke:#005a9e
type Result<T> =
  | { status: 'success'; data: T }
  | { status: 'failure'; error: string; code: number }
  | { status: 'pending' };

function processResult<T>(result: Result<T>): string {
  switch (result.status) {
    case 'success':
      return JSON.stringify(result.data); // data is T โ€” no assertion needed
    case 'failure':
      return `Error ${result.code}: ${result.error}`; // error and code available
    case 'pending':
      return 'Loading...';
    default:
      // Exhaustiveness check โ€” TypeScript catches missing cases
      const _never: never = result;
      throw new Error(`Unhandled status: ${String(_never)}`);
  }
}

Custom Type Predicates

When built-in narrowing isn't enough, write a function that returns value is Type โ€” a type predicate. TypeScript trusts your runtime check and narrows accordingly.

// Type predicate: tells TS "if this returns true, value is User"
function isUser(value: unknown): value is User {
  return (
    typeof value === 'object' &&
    value !== null &&
    'id' in value &&
    typeof (value as any).id === 'string' &&
    'name' in value &&
    typeof (value as any).name === 'string'
  );
}

// Usage
const data: unknown = await fetchData();

if (isUser(data)) {
  console.log(data.name); // โœ… TypeScript knows data is User
} else {
  console.log('Not a user');
}

// Filter arrays to narrow types
const maybeUsers: unknown[] = getItems();
const users: User[] = maybeUsers.filter(isUser); // filter narrows the array type

Assertion Functions (TypeScript 3.7+)

Assertion functions throw on failure โ€” they're useful in constructors and functions where you want to narrow the type for the rest of the current scope.

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

const maybeString: unknown = getConfig('name');
assertIsString(maybeString); // throws if not string
// From this point on, maybeString is narrowed to string
console.log(maybeString.toUpperCase()); // โœ… no assertion needed

The unknown Pattern for Catch Blocks

TypeScript 4.0+ types catch clause variables as unknown. Proper type guards here prevent runtime errors in error handling code.

try {
  await dangerousOperation();
} catch (error) {
  // error is unknown โ€” you must narrow before using
  if (error instanceof Error) {
    console.error(error.message); // โœ…
  } else if (typeof error === 'string') {
    console.error(error); // โœ…
  } else {
    console.error('Unknown error:', JSON.stringify(error));
  }
}

// Reusable helper
function getErrorMessage(error: unknown): string {
  if (error instanceof Error) return error.message;
  if (typeof error === 'string') return error;
  return 'An unexpected error occurred';
}

Pitfalls

Type assertions vs type guards

// โŒ Type assertion โ€” silences the error but doesn't check at runtime
const user = data as User; // will crash if data isn't actually a User

// โœ… Type guard โ€” validates at runtime AND narrows the type
if (isUser(data)) {
  const user = data; // User โ€” safe
}

Forgetting the exhaustiveness check

Always include the default: const _never: never = ... pattern in switch statements over discriminated unions. Without it, adding a new union member compiles fine but silently hits the missing case at runtime.