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 | numberparameter becomes juststringonce you prove it withtypeof - 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
kindorstatusliteral 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
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:
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:
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:
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:
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:
function greet(name: string | null) {
if (name) {
console.log(name.toUpperCase()); // name: string
}
}Type predicates for reusable guards:
function isString(val: unknown): val is string {
return typeof val === "string";
}
if (isString(input)) input.toUpperCase(); // input: stringWhen 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/undefinedchecks → prefer=== nullover truthiness for precision
Common mistakes
Using == null instead of === null:
// 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:
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:
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:
const v = value; // v: string
setTimeout(() => console.log(v.toUpperCase()), 1000);instanceof on primitives:
// 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:
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:
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:
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
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
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
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 readyA concise answer to help you respond confidently on this topic during an interview.