TypeScriptTruthiness & Equality Narrowing

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 like null, 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.

TS
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!
Warning
The truthy check also removes empty strings. If you need to distinguish betweennull 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.

TS
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
Tip
Use (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.

TS
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).

TS
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())
}
Note
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.

TS
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.

TS
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

TS
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

TS
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."

TS
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}`)
  }
}
Success
Exhaustive narrowing using truthiness and equality operators — combined with typeof,instanceof, and in — forms the backbone of safe TypeScript code. Master these and you'll rarely need unsafe type assertions.