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.
// 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
}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".
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 nulltypeof 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.
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[]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.
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.
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}`);
}
}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.
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.
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.
// 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;
}
}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.
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);
}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.
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!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.
// 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.
// 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.comCommon 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
Narrowing lets TypeScript track type refinements through your control flow — use it instead of type assertions.
typeof and instanceof cover most primitive and class-based narrowing needs.
Discriminated unions (tagged unions) are the most scalable pattern for complex object shapes.
Always add assertNever in default switch branches to catch unhandled union members at compile time.
Type predicates let you encode custom runtime checks so TypeScript narrows at the call site.
Prefer early returns (guard clauses) to keep narrowed types flat and readable.
Truthiness narrows away null and undefined, but use == null for the combined check.
Control flow analysis means TypeScript re-evaluates the type of a variable after every assignment — this is a feature, not a bug.
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.