Truthiness & Equality Narrowing
TypeScript performs control flow analysis — it tracks which types are possible at every point in your code. Two of the most common narrowing techniques are:
- Truthiness narrowing: using
if (value)to filter out falsy values likenull,undefined,0,"" - Equality narrowing: using
===or!==to narrow to a specific literal or type
These patterns are fundamental to writing correct TypeScript without reaching for type assertions.
Falsy Values in JavaScript (and TypeScript)
Before diving into narrowing, it helps to know exactly what JavaScript considers falsy. TypeScript's analysis is built on top of this runtime behavior.
Value | Type | Falsy? |
|---|---|---|
false | boolean | Yes |
0, -0, 0n | number / bigint | Yes |
"" (empty string) | string | Yes |
null | null | Yes |
undefined | undefined | Yes |
NaN | number | Yes |
Everything else | any type | No (truthy) |
Basic Truthiness Narrowing
When you put a value in an if condition, TypeScript removes the falsy types (null,
undefined, false, 0, "") from the type in the true branch.
function printLength(str: string | null | undefined) {
if (str) {
// TypeScript knows: str is string here (not null or undefined)
// It also knows str is not "" (empty string)
console.log(str.length)
} else {
console.log('No string provided')
}
}
printLength('hello') // 5
printLength(null) // No string provided
printLength('') // No string provided — "" is falsy!null and "", use an explicit null check:if (str !== null && str !== undefined) or if (str != null).Narrowing with Boolean()
Calling Boolean(value) is equivalent to a truthiness check for narrowing purposes,
but it's often used with .filter() to remove falsy items from arrays.
const maybeNumbers: (number | null | undefined)[] = [1, null, 2, undefined, 3] // Without Boolean — TypeScript keeps null/undefined in the type const withNulls = maybeNumbers.filter(n => n !== null && n !== undefined) // type: (number | null | undefined)[] — TypeScript isn't sure yet // With type predicate (correct way) const cleanNumbers = maybeNumbers.filter((n): n is number => n !== null && n !== undefined) // type: number[] // Boolean cast approach const alsoClean = maybeNumbers.filter(Boolean) as number[] // Boolean works at runtime but TypeScript needs a hint
(item): item is Type => item !== null && item !== undefined as a type predicate in .filter() for the cleanest type-safe approach. TypeScript 5.5+ can infer this automatically in some cases.Equality Narrowing with ===
When you compare a value with === to a specific literal, TypeScript narrows the type
to that exact literal — or eliminates it in the !== branch.
type Direction = 'north' | 'south' | 'east' | 'west'
function describe(dir: Direction) {
if (dir === 'north') {
// TypeScript knows: dir is 'north'
console.log('Heading north!')
} else {
// TypeScript knows: dir is 'south' | 'east' | 'west'
console.log(`Going ${dir}`)
}
}
// Works with non-literal types too
function processStatus(code: number | string) {
if (code === 200) {
// code is 200 (number literal)
console.log('OK')
} else if (code === '200') {
// code is '200' (string literal)
console.log('String OK')
}
}Narrowing null and undefined with ===
The === null and === undefined checks are very precise — they only match
that exact value. This is different from the loose == null check (double equals).
function process(value: string | null | undefined) {
if (value === null) {
console.log('explicitly null')
return
}
if (value === undefined) {
console.log('explicitly undefined')
return
}
// Here TypeScript knows: value is string
console.log(value.toUpperCase())
}
// Loose equality == catches BOTH null AND undefined
function processLoose(value: string | null | undefined) {
if (value == null) {
// value is null | undefined — both caught by == null
console.log('null or undefined')
return
}
// value is string here
console.log(value.toUpperCase())
}value == null (double equals) is a well-known idiom that catches bothnull and undefined. TypeScript understands this pattern and narrows correctly in both branches.Narrowing Unions with switch Statements
switch statements on a discriminant field are the most readable way to handle unions,
and TypeScript narrows precisely in each case.
type Status = 'loading' | 'success' | 'error'
interface State {
status: Status
data?: string
errorMessage?: string
}
function render(state: State): string {
switch (state.status) {
case 'loading':
return 'Loading...'
case 'success':
// status is 'success' here
return `Data: ${state.data ?? 'empty'}`
case 'error':
// status is 'error' here
return `Error: ${state.errorMessage ?? 'unknown'}`
}
}Equality Narrowing Across Variables
When you compare two variables with ===, TypeScript narrows the types of both
to their intersection — the types that both could simultaneously be.
function example(a: string | number, b: string | boolean) {
if (a === b) {
// a and b must both be string (the only shared type)
console.log(a.toUpperCase()) // safe: a is string
console.log(b.toUpperCase()) // safe: b is string
}
}Practical Pattern: Optional Callback
interface Config {
timeout?: number
onSuccess?: (data: unknown) => void
onError?: (err: Error) => void
}
function fetchData(url: string, config: Config) {
const timeout = config.timeout ?? 5000
fetch(url)
.then(res => res.json())
.then(data => {
if (config.onSuccess) {
// Narrowed: onSuccess is (data: unknown) => void (not undefined)
config.onSuccess(data)
}
})
.catch((err: Error) => {
if (config.onError) {
// Narrowed: onError is (err: Error) => void
config.onError(err)
}
})
}Narrowing with typeof and Truthiness Together
function normalize(value: string | number | null | undefined): string {
if (value == null) {
return '' // handles both null and undefined
}
if (typeof value === 'number') {
return value.toFixed(2) // value is number
}
// value is string here
return value.trim()
}
console.log(normalize(null)) // ""
console.log(normalize(undefined)) // ""
console.log(normalize(3.14159)) // "3.14"
console.log(normalize(' hello ')) // "hello""" "" "3.14" "hello"
The never Type at the End
If you narrow all possible types in a union, what remains has type never.
This is TypeScript's way of saying "this code is unreachable."
function process(val: string | number | boolean) {
if (typeof val === 'string') {
return val.length
} else if (typeof val === 'number') {
return val * 2
} else if (typeof val === 'boolean') {
return val ? 1 : 0
} else {
// val is never here — all cases handled
const unreachable: never = val
throw new Error(`Unexpected: ${unreachable}`)
}
}typeof,instanceof, and in — forms the backbone of safe TypeScript code. Master these and you'll rarely need unsafe type assertions.