Skip to main content

Type narrowing in TypeScript

Type narrowing is how TypeScript refines a variable from a broad union type down to a specific subtype by analyzing the runtime checks you already write.

Theory

TL;DR

  • Think of it like a bouncer checking IDs: a string | number parameter becomes just string once you prove it with typeof
  • TypeScript tracks control flow through if/else, switch, and early returns, shrinking types per branch
  • Six main techniques: typeof, instanceof, in, equality checks, truthiness checks, and type predicates (value is Type)
  • Discriminated unions (a shared kind or status literal field) are the cleanest pattern for complex state
  • All narrowing is compile-time only. Zero runtime cost, the checks run as plain JavaScript

Quick example

typescript
function logValue(input: string | number) { // input: string | number here if (typeof input === "string") { // TypeScript narrows: input is string input.toUpperCase(); // OK } else { // input: number input.toFixed(2); // OK } }

TypeScript sees typeof input === "string" and intersects the union with string in that branch. The else branch gets whatever is left: number. Hover over input in your IDE inside each branch and the type changes.

How the compiler narrows

TypeScript's compiler runs control flow analysis on the AST. For every branch (if, else, switch case, early return), it recalculates what type a variable can be at that point. The original type never changes. TypeScript just knows more about it inside that scope.

No runtime cost. Everything is erased when the code compiles. V8 sees plain JavaScript conditionals, nothing special.

Six narrowing techniques

typeof for primitive unions:

typescript
function format(val: string | number | null) { if (val === null) return "N/A"; if (typeof val === "string") return val.trim(); return val.toLocaleString(); // val: number here }

instanceof for class instances:

typescript
function handleEvent(event: MouseEvent | KeyboardEvent) { if (event instanceof MouseEvent) { console.log(event.clientX); // event: MouseEvent } else { console.log(event.key); // event: KeyboardEvent } }

in operator for interface shapes:

typescript
interface Circle { radius: number; } interface Square { size: number; } function getArea(shape: Circle | Square) { if ("radius" in shape) { return Math.PI * shape.radius ** 2; // shape: Circle } return shape.size ** 2; // shape: Square }

Equality narrowing for discriminated unions:

typescript
type Result = | { status: "ok"; data: string } | { status: "err"; message: string }; function handle(result: Result) { if (result.status === "ok") { console.log(result.data); // result: { status: "ok"; data: string } } else { console.log(result.message); } }

Truthiness narrowing removes null/undefined:

typescript
function greet(name: string | null) { if (name) { console.log(name.toUpperCase()); // name: string } }

Type predicates for reusable guards:

typescript
function isString(val: unknown): val is string { return typeof val === "string"; } if (isString(input)) input.toUpperCase(); // input: string

When to use which technique

  • Primitive union (string | number) → typeof
  • Class instances → instanceof
  • Interface or object shapes → "prop" in obj
  • State machines, API responses, Redux actions → discriminated unions with equality check
  • Complex validation, unknown API data → type predicates
  • null/undefined checks → prefer === null over truthiness for precision

Common mistakes

Using == null instead of === null:

typescript
// Wrong - TypeScript does NOT narrow here function bad(value: string | null) { if (value == null) return; value.length; // Error: value is still string | null } // Correct function good(value: string | null) { if (value === null) return; value.length; // value: string }

The loose == null catches both null and undefined, so TypeScript can't confidently narrow string | null to string. Use strict ===.

Accessing a property without in first:

typescript
interface A { x: number; } interface B { y: string; } // Wrong - TypeScript errors: x doesn't exist on B function wrong(p: A | B) { if (p.x) { ... } } // Correct function correct(p: A | B) { if ("x" in p) { /* p: A */ } }

Assuming callbacks preserve narrowing:

typescript
function process(value: string | null) { if (value !== null) { setTimeout(() => { // TypeScript may flag this: value could have changed console.log(value.toUpperCase()); }, 1000); } value = null; // changed after the check }

Capture the narrowed value in a const before the callback:

typescript
const v = value; // v: string setTimeout(() => console.log(v.toUpperCase()), 1000);

instanceof on primitives:

typescript
// Wrong - primitives are not class instances if (value instanceof String) { ... } // Correct if (typeof value === "string") { ... }

Forgetting to check optional fields after narrowing the outer union:

typescript
type Action = { type: "LOAD"; data?: string } | { type: "UPDATE" }; function handle(action: Action) { if (action.type === "LOAD") { // data is still string | undefined here! // action.data.trim(); // Error if (action.data) { action.data.trim(); // OK: string } } }

TypeScript narrows the union but does not auto-narrow nested optionals. Each level needs its own check.

Real-world usage

  • React: discriminated props (if (props.kind === "primary") in Chakra UI-style polymorphic components)
  • Redux Toolkit: action narrowing in reducers via switch (action.type)
  • tRPC/Express middleware: if ("userId" in req.body) for request body variants
  • Zod: if (result.success) { const data = result.data; } after .safeParse()
  • API responses: type ApiResponse<T> = { success: true; data: T } | { success: false; error: string }

Follow-up questions

Q: What is the difference between type narrowing and a type assertion (as string)?
A: Narrowing proves the type through control flow. TypeScript checks your logic and only narrows when it can verify correctness. An assertion is a promise to the compiler with no proof behind it. If the promise is wrong, you get a runtime error.

Q: Write a type guard function for an API user object.
A:

typescript
function isUser(obj: unknown): obj is { name: string; email: string } { return ( typeof obj === "object" && obj !== null && "name" in obj && "email" in obj ); }

Q: Why might TypeScript fail to narrow a union of objects when the shared property is typed as string instead of a literal?
A: If the discriminant is string rather than "loading" | "success" | "error", TypeScript can't tell the variants apart. Use literal types or as const on the values to get precise matching.

Q: Does narrowing work across await?
A: Yes, if the variable is not reassigned between the check and the await. TypeScript tracks it through async control flow as long as nothing mutates it in between.

Q: How do you make a switch-based reducer exhaustive using never?
A:

typescript
default: const _check: never = action; return _check;

If you add a new action type and forget to handle it, TypeScript errors on the never assignment. The default case becomes a compile-time safety net.

Examples

Basic: Primitive union with early return

typescript
function format(val: string | number | null) { if (val === null) return "N/A"; if (typeof val === "string") { return val.trim(); // val: string } return val.toLocaleString(); // val: number } format(" hello "); // "hello" format(1234567); // "1,234,567" format(null); // "N/A"

Three types, three branches, each clearly narrowed. The early return for null is the most common pattern in production code. I use it as a default in every utility function that accepts optional values, because it keeps the happy path flat and readable.

Intermediate: React component with discriminated props

typescript
interface ButtonProps { kind: "button"; label: string; onClick: () => void; } interface LinkProps { kind: "link"; label: string; href: string; } type Props = ButtonProps | LinkProps; function InteractiveElement(props: Props) { if (props.kind === "button") { // props: ButtonProps - onClick exists here return <button onClick={props.onClick}>{props.label}</button>; } // props: LinkProps - href exists here return <a href={props.href}>{props.label}</a>; }

The kind field is the discriminant. TypeScript narrows to each exact interface inside the matching branch. Trying to access props.href in the first branch is a compile error. This pattern appears in nearly every component library that supports polymorphic rendering.

Advanced: Redux-style reducer with nested narrowing

typescript
type Action = | { type: "LOAD"; status: "loading" | "success" | "error"; data?: string } | { type: "UPDATE"; value: number }; interface AppState { content: string; status: string; count: number; } function reducer(state: AppState, action: Action): AppState { if (action.type === "LOAD") { // action: { type: "LOAD"; status: ...; data?: string } if (action.status === "success" && action.data) { // Without '&& action.data', data is still string | undefined. // TypeScript won't let you assign it without the explicit check. return { ...state, content: action.data }; } return { ...state, status: action.status }; } // action: { type: "UPDATE"; value: number } return { ...state, count: action.value }; }

The tricky part: data is optional (data?: string). Even after narrowing action.type === "LOAD" and action.status === "success", you still need && action.data to push the type from string | undefined to string. TypeScript does not auto-narrow nested optionals. That is the edge case that catches most mid-level developers in code review.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?