Intermediate1 questionFull Guide

TypeScript Type Guards & Narrowing — Complete Interview Guide

Understand TypeScript's type narrowing system — typeof, instanceof, in operator, discriminated unions, custom type predicates, and the never type for exhaustive checks. Learn to write safe code that TypeScript can reason about statically.

The Mental Model

TypeScript's type narrowing is like a detective refining a list of suspects. You start with a wide type (string | number | null) — any of those could be true. Each check you write (if typeof x === 'string') eliminates some suspects, and inside that branch TypeScript narrows its understanding of the type to only what's still possible. By the time you reach a specific branch, TypeScript knows exactly what type is present and unlocks the appropriate properties and methods. The narrowing is purely static — it's TypeScript reading your conditions and updating its type model, with zero runtime cost.

The Explanation

Built-in Narrowing Constructs

TypeScript understands several JavaScript constructs and uses them to narrow types automatically:

function formatValue(value: string | number | null | undefined): string {
  // typeof narrowing
  if (typeof value === "string") {
    return value.toUpperCase(); // value: string here
  }

  // Nullish check narrowing
  if (value == null) {
    return "N/A"; // value: null | undefined here
  }

  // At this point, TypeScript knows value must be number
  return value.toFixed(2); // value: number here
}

// instanceof narrowing
function handleError(err: unknown): string {
  if (err instanceof Error) {
    return err.message; // err: Error
  }
  if (err instanceof TypeError) {
    return err.stack ?? "No stack"; // err: TypeError (subtype of Error)
  }
  return String(err);
}

The in Operator

The in operator narrows by checking for the presence of a property — particularly useful for discriminating between object shapes:

interface Circle  { kind: "circle";  radius: number; }
interface Square  { kind: "square";  side: number; }
interface Triangle { kind: "triangle"; base: number; height: number; }

type Shape = Circle | Square | Triangle;

// Using 'in' to check for unique properties
function describeShape(shape: Shape): string {
  if ("radius" in shape) {
    return `Circle with radius ${shape.radius}`; // shape: Circle
  }
  if ("side" in shape) {
    return `Square with side ${shape.side}`; // shape: Square
  }
  return `Triangle ${shape.base} × ${shape.height}`; // shape: Triangle
}

Discriminated Unions — The Gold Standard Pattern

A discriminated union gives every member a shared literal property (the discriminant). TypeScript then narrows based on that property's value — the most ergonomic and exhaustive narrowing pattern:

type ApiResult<T> =
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error";   message: string; code: number };

function renderUser(result: ApiResult<User>) {
  switch (result.status) {
    case "loading":
      return <Spinner />;
    case "success":
      return <UserCard user={result.data} />; // result.data: User
    case "error":
      return <Error msg={result.message} />;  // result.message: string
  }
}

Custom Type Predicates

When built-in constructs are not enough, you can teach TypeScript about narrowing by writing a function that returns a type predicate — a return type of the form arg is Type:

// Without a type predicate — TypeScript cannot use the result to narrow
function isString(value: unknown): boolean {
  return typeof value === "string";
}
// After calling isString(x), TypeScript still thinks x is unknown

// With a type predicate — the caller's type is narrowed on true
function isString(value: unknown): value is string {
  return typeof value === "string";
}
const x: unknown = "hello";
if (isString(x)) {
  console.log(x.toUpperCase()); // x is narrowed to string — OK
}

// Real-world example — filtering an array to remove null/undefined
function isDefined<T>(value: T | null | undefined): value is T {
  return value !== null && value !== undefined;
}
const maybeUsers: (User | null)[] = getUsers();
const users: User[] = maybeUsers.filter(isDefined); // Clean array of User

The never Type for Exhaustive Checks

After narrowing all known members of a union, the remaining type should be never. You can exploit this to write exhaustive checks that fail at compile time when a new union member is added without updating the handler:

type Direction = "north" | "south" | "east" | "west";

function move(dir: Direction): string {
  switch (dir) {
    case "north": return "Moving north";
    case "south": return "Moving south";
    case "east":  return "Moving east";
    case "west":  return "Moving west";
    default:
      // If we reach here, dir is 'never' — all cases handled
      const exhaustiveCheck: never = dir;
      throw new Error(`Unhandled direction: ${exhaustiveCheck}`);
  }
}

// Now if you add "up" to Direction without updating the switch:
// TypeScript errors: Type '"up"' is not assignable to type 'never'
// This is a compile-time safety net — no tests required

Assertion Functions

Assertion functions (TypeScript 3.7+) throw on falsy values and narrow the type afterward — useful for writing asserts that double as type narrowing:

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

const raw: unknown = getConfigValue();
assertIsString(raw);
// After this line, TypeScript treats raw as string
console.log(raw.toUpperCase()); // OK

Common Misconceptions

⚠️

Many developers think type guards add runtime overhead — they are ordinary JavaScript (typeof, instanceof, property checks). TypeScript only reads them during compilation to update its type model. The runtime cost is identical to any conditional check you'd write in plain JavaScript.

⚠️

Many developers think you need type predicates for every custom check — built-in constructs (typeof, instanceof, in, truthiness, equality) narrow automatically. Type predicates are only needed when TypeScript cannot infer the narrowing from your function's return value.

⚠️

Many developers use as Type casts instead of narrowing — a cast silences the error but doesn't actually verify the type at runtime. A type guard actually checks the type and narrows it. In production code, prefer narrowing over casting.

⚠️

Many developers think discriminated unions require complex setup — any shared literal property works as the discriminant. The pattern is as simple as adding a kind or type or status string literal to each member.

⚠️

Many developers think the never exhaustive check is just defensive coding — it's a compile-time guarantee. Add a new union member without updating the switch and TypeScript immediately errors at the default branch. No test is needed to catch the regression.

⚠️

Many developers think filtering with Array.filter removes null types automatically — filter's return type is still (T | null)[] in TypeScript. You need a type predicate isDefined<T>(x: T | null | undefined): x is T to get TypeScript to infer the filtered array as T[].

Where You'll See This in Real Code

API error handling: switch on result.status with a discriminated union (loading | success | error) is the standard pattern in React data fetching — each case narrows to the exact shape with exactly the right properties available.

Filtering arrays of nullable values: isDefined type predicate used with .filter() is ubiquitous in codebases that fetch data from APIs that can return null for missing items — it removes nulls while keeping TypeScript happy.

Redux reducers: every switch case on action.type is a discriminated union — TypeScript narrows action to the specific action type in each case, giving you typed payload without any casting.

Plugin systems: checking for the presence of specific methods using the in operator allows a plugin host to safely call optional plugin capabilities without runtime errors.

Form validation: assertion functions (asserts value is string) are used in validation utilities that throw on invalid input — callers don't need additional if-checks after calling the validator.

Event handling: instanceof narrowing on Event objects (e instanceof MouseEvent, e instanceof KeyboardEvent) unlocks the specific properties (e.clientX, e.key) that TypeScript knows are only available on the narrowed type.

Interview Cheat Sheet

  • typeof: narrows primitives — 'string' | 'number' | 'boolean' | 'object' | 'undefined' | 'function'
  • instanceof: narrows class instances — (err instanceof Error) narrows to Error
  • in operator: narrows by property presence — ('radius' in shape) narrows to shapes with radius
  • Discriminated union: shared literal property (kind, status, type) used in switch/if to narrow the whole union
  • Type predicate: function isX(v: unknown): v is X — teaches TypeScript how to narrow from function return
  • Truthiness narrowing: if (value) removes null, undefined, 0, '', NaN, false from the type
  • Equality narrowing: if (x === 'north') narrows x to 'north' (literal type)
  • never exhaustive check: in a switch default, assign to never to get a compile error on unhandled union members
  • Assertion function: asserts val is Type — throws if false, narrows after the call
  • Array.filter with predicate: .filter(isDefined) returns T[] when isDefined is T | null | undefined: x is T
💡

How to Answer in an Interview

  • 1.Distinguish narrowing from casting immediately: 'Type guards narrow by actually checking the type at runtime and letting TypeScript update its model. Type assertions (as Type) are a compile-time override — they silence errors without adding any check. In production code, narrowing is always safer.'
  • 2.The discriminated union pattern is the answer to many design questions. When asked how to handle different API response shapes or Redux actions, lead with: 'I model this as a discriminated union with a shared literal discriminant, then switch on it — TypeScript narrows each case automatically and I never need to cast.'
  • 3.The never exhaustive check is a senior-level technique that always impresses: 'I add a default case that assigns to never. If I later add a union member without updating the switch, TypeScript errors at compile time — it's a zero-cost regression guard.'
  • 4.Know the isDefined type predicate — it's used in almost every production codebase: 'Array.filter doesn't automatically narrow nullables. I write isDefined<T>(x: T | null | undefined): x is T — now .filter(isDefined) returns T[] and TypeScript is happy.'

Practice Questions

1 question
#01

What are type guards and how do you create custom ones?

EasyType System PRO💡 Narrowing the type in a branch — typeof, instanceof, in operator, or a user-defined predicate function

Related Topics

TypeScript Types vs Interfaces — Complete Interview Guide
Intermediate·8–12 Qs
TypeScript Union & Intersection Types — Complete Interview Guide
Intermediate·6–10 Qs
TypeScript Generics — Complete Interview Guide
Intermediate·8–12 Qs
TypeScript Conditional Types — Complete Interview Guide
Advanced·6–10 Qs
🎯

Can you answer these under pressure?

Reading answers is not the same as knowing them. Practice saying them out loud with AI feedback — that's what builds real interview confidence.

Practice Free →Try Output Quiz