TypeScript Type Narrowing Cheat Sheet
Every narrowing technique in one reference -- typeof, instanceof, in, type predicates, discriminated unions, exhaustive checking, and assertion functions.
typeof Narrowing
TypeScript narrows the type after a `typeof` check to the matching primitive.
function format(value: string | number): string {
if (typeof value === 'string') {
return value.toUpperCase(); // => value is string
}
return value.toFixed(2); // => value is number
}
format('hello'); // => 'HELLO'
format(3.14159); // => '3.14'// typeof returns one of these strings:
// 'string' | 'number' | 'bigint' | 'boolean'
// | 'symbol' | 'undefined' | 'object' | 'function'
function handle(x: unknown) {
if (typeof x === 'function') {
x(); // => x is Function
}
if (typeof x === 'object') {
// x is object | null (typeof null === 'object')
}
}`typeof null` returns `"object"` -- always check for null separately when narrowing objects.
Truthiness Narrowing
Truthy/falsy checks narrow out `null`, `undefined`, `0`, `""`, and `false`.
function greet(name: string | null | undefined): string {
if (name) {
return `Hello, ${name}`; // => name is string
}
return 'Hello, stranger';
}function display(value: string | number | null) {
if (value) {
console.log(value); // => string | number
}
// But "" and 0 are falsy -- they fall through to here
// This might not be what you want
}
// Safer: check for null/undefined explicitly
function displaySafe(value: string | number | null) {
if (value !== null) {
console.log(value); // => string | number (includes "" and 0)
}
}Truthiness narrowing eliminates all falsy values, not just null/undefined. Use explicit null checks when 0 or "" are valid inputs.
in Operator
The `in` operator narrows based on whether a property exists on the value.
interface Dog { bark(): void; breed: string }
interface Cat { meow(): void; indoor: boolean }
function handlePet(pet: Dog | Cat) {
if ('bark' in pet) {
pet.bark(); // => pet is Dog
pet.breed; // => type: string
} else {
pet.meow(); // => pet is Cat
pet.indoor; // => type: boolean
}
}function processResponse(data: unknown) {
if (
typeof data === 'object' &&
data !== null &&
'error' in data
) {
// data is narrowed to: object & Record<'error', unknown>
console.log((data as { error: string }).error);
}
}The `in` operator requires the left side to be a string literal and the right side to be an object type.
instanceof
Narrows to a class instance by checking the prototype chain.
class ApiError {
constructor(public status: number, public message: string) {}
}
class NetworkError {
constructor(public url: string) {}
}
function handleError(err: ApiError | NetworkError) {
if (err instanceof ApiError) {
console.log(err.status); // => err is ApiError
} else {
console.log(err.url); // => err is NetworkError
}
}function process(value: Date | string) {
if (value instanceof Date) {
value.getFullYear(); // => value is Date
} else {
value.toUpperCase(); // => value is string
}
}instanceof only works with classes and constructors, not interfaces or type aliases (those are erased at runtime).
Type Guard Functions (x is T)
A function with return type `x is T` tells TypeScript to narrow the parameter when the function returns true.
interface User {
id: number;
name: string;
email: string;
}
function isUser(value: unknown): value is User {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
'name' in value &&
'email' in value
);
}
function process(data: unknown) {
if (isUser(data)) {
console.log(data.name); // => data is User
console.log(data.email); // => type: string
}
}type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'square'; side: number };
function isCircle(shape: Shape): shape is Extract<Shape, { kind: 'circle' }> {
return shape.kind === 'circle';
}
const shapes: Shape[] = [
{ kind: 'circle', radius: 5 },
{ kind: 'square', side: 3 },
{ kind: 'circle', radius: 10 },
];
const circles = shapes.filter(isCircle);
// circles => type: { kind: 'circle'; radius: number }[]Array.filter with a type guard narrows the array element type. Without the predicate, filter returns Shape[].
Discriminated Unions
A shared literal property (the discriminant) lets TypeScript narrow union members automatically.
type ApiResult<T> =
| { status: 'success'; data: T }
| { status: 'error'; error: string }
| { status: 'loading' };
function render(result: ApiResult<string[]>) {
switch (result.status) {
case 'success':
console.log(result.data.join(', ')); // => data is string[]
break;
case 'error':
console.log(result.error); // => error is string
break;
case 'loading':
console.log('Loading...');
break;
}
}type Action =
| { type: 'fetch'; url: string }
| { type: 'cache'; key: string; ttl: number }
| { type: 'transform'; fn: (data: string) => string };
function execute(action: Action) {
if (action.type === 'cache') {
console.log(action.key, action.ttl); // => both accessible
}
}Exhaustive Checking with never
Assign to `never` in the default branch to get a compile error when you forget a case.
type Status = 'pending' | 'active' | 'archived';
function getLabel(status: Status): string {
switch (status) {
case 'pending': return 'Waiting';
case 'active': return 'In Progress';
case 'archived': return 'Done';
default: {
const _exhaustive: never = status;
return _exhaustive; // => Error if a case is missed
}
}
}function assertNever(value: never): never {
throw new Error(`Unexpected value: ${value}`);
}
type Shape = { kind: 'circle'; r: number } | { kind: 'square'; s: number };
function area(shape: Shape): number {
switch (shape.kind) {
case 'circle': return Math.PI * shape.r ** 2;
case 'square': return shape.s ** 2;
default: return assertNever(shape);
}
}If you add a new union member later, the default branch errors at compile time -- forcing you to handle the new case.
Assertion Functions (asserts x is T)
Assertion functions narrow the type for the rest of the scope if they return without throwing.
function assertDefined<T>(
value: T | null | undefined,
name: string
): asserts value is T {
if (value === null || value === undefined) {
throw new Error(`${name} must be defined`);
}
}
function process(config: { apiKey?: string }) {
assertDefined(config.apiKey, 'apiKey');
// After assertion, apiKey is string (not string | undefined)
console.log(config.apiKey.toUpperCase());
}function assertIsString(value: unknown): asserts value is string {
if (typeof value !== 'string') {
throw new TypeError(`Expected string, got ${typeof value}`);
}
}
function handle(input: unknown) {
assertIsString(input);
// input is now string for the rest of this scope
console.log(input.toUpperCase());
}Assertion functions narrow the type after the call site -- unlike type guards which narrow inside a conditional block.
Narrowing Gotchas
Situations where narrowing doesn't work the way you'd expect.
function example(value: string | null) {
if (value !== null) {
// value is string here
setTimeout(() => {
// value is string | null again -- TypeScript can't guarantee
// it wasn't reassigned between now and when the callback runs
// value.toUpperCase(); // => Error
}, 100);
}
}
// Fix: capture in a const
function exampleFixed(value: string | null) {
if (value !== null) {
const captured = value; // => type: string (const, won't change)
setTimeout(() => {
captured.toUpperCase(); // => OK
}, 100);
}
}function process(value: string | number) {
if (typeof value === 'string') {
value.toUpperCase(); // => OK, value is string
value = 42; // Reassignment
// value.toUpperCase(); // => Error: value is now number
value.toFixed(2); // => OK, value is number
}
}TypeScript tracks assignments. Any reassignment resets the narrowed type to whatever the new value's type is.
interface Config {
debug?: boolean;
verbose?: boolean;
}
function setup(config: Config) {
if (config.debug) {
console.log('Debug on'); // => config.debug is true
doSomething(config);
// After a function call, config.debug might have changed
// config.debug is boolean | undefined again
// Fix: destructure first
const { debug } = config;
if (debug) {
doSomething(config);
console.log(debug); // => still true (const binding)
}
}
}
function doSomething(config: Config) { /* ... */ }