TypeScript Type Guards & Narrowing Explained
Narrowing is TypeScript's ability to refine a broader type to a more specific one based on runtime checks. It's what makes union types practical.
typeof Narrowing
function format(value: string | number | boolean): string {
if (typeof value === 'string') {
return value.toUpperCase() // value: string ✓
}
if (typeof value === 'number') {
return value.toFixed(2) // value: number ✓
}
return String(value) // value: boolean ✓
}
typeof works for: 'string', 'number', 'boolean', 'bigint', 'symbol', 'undefined', 'function', 'object'.
instanceof Narrowing
function handleEvent(event: MouseEvent | KeyboardEvent) {
if (event instanceof MouseEvent) {
console.log(event.clientX, event.clientY) // MouseEvent properties ✓
} else {
console.log(event.key) // KeyboardEvent properties ✓
}
}
// Works with custom classes too: class ApiError extends Error { constructor(public statusCode: number, message: string) { super(message) } } class NetworkError extends Error { constructor(public retryable: boolean, message: string) { super(message) } }
function handleError(err: ApiError | NetworkError) { if (err instanceof ApiError) { console.log(err.statusCode) // ✓ } else { console.log(err.retryable) // ✓ } }
in Operator Narrowing
type Cat = { meow(): void; purr(): void }
type Dog = { bark(): void; fetch(): void }
function speak(animal: Cat | Dog) { if ('meow' in animal) { animal.meow() // animal: Cat ✓ } else { animal.bark() // animal: Dog ✓ } }
Discriminated Unions (Best Practice)
The most robust narrowing pattern — add a literal discriminant field:
type Success<T> = { status: 'success'; data: T }
type Failure = { status: 'failure'; error: string }
type Loading = { status: 'loading' }
type AsyncResult<T> = Success<T> | Failure | Loading
function render<T>(result: AsyncResult<T>) { switch (result.status) { case 'loading': return <Spinner /> // result: Loading ✓ case 'success': return <Data value={result.data} /> // result: Success<T> ✓ case 'failure': return <Error msg={result.error} /> // result: Failure ✓ } }
The discriminant (status) must be a literal type, not just string.
Custom Type Predicates
// Standard approach — TypeScript doesn't learn the type after this:
function isUser(value: unknown) {
return typeof value === 'object' && value !== null && 'id' in value
}
// Type predicate — TypeScript narrows in the true branch: function isUser(value: unknown): value is User { return ( typeof value === 'object' && value !== null && 'id' in value && typeof (value as any).id === 'number' ) }
const data: unknown = fetchFromAPI() if (isUser(data)) { console.log(data.id) // data: User ✓ console.log(data.name) // ✓ }
Assertion Functions (Throw or Narrow)
function assertDefined<T>(val: T | null | undefined, msg: string): asserts val is T {
if (val == null) throw new Error(msg)
}
const user = getUser(id) // User | null assertDefined(user, 'User must exist') console.log(user.name) // user: User ✓ (TypeScript knows it's non-null after assertion)
Truthiness Narrowing
function process(input: string | null | undefined | 0 | false) {
if (input) {
// input: string (all falsy values are excluded)
console.log(input.toUpperCase())
}
}
// Careful: '' (empty string) is also falsy — may not be what you want
// Use input != null for null/undefined only
Exhaustiveness Checking with never
type Shape = { kind: 'circle'; radius: number } | { kind: 'square'; side: number }
function area(shape: Shape): number { switch (shape.kind) { case 'circle': return Math.PI shape.radius * 2 case 'square': return shape.side ** 2 default: const _exhaustive: never = shape // ❌ Error if a new Shape is added without handling it throw new Error('Unhandled shape: ' + _exhaustive) } }
Practice TypeScript narrowing questions at [JSPrep Pro](/auth).