Null Coalescing Operator in TypeScript

October 2, 202611 min read
Requirements:
ObjectsFunctions| Unions

The null coalescing operator is two question marks, and it does exactly one thing: it returns the right-hand side when the left-hand side is null or undefined, and the left-hand side in every other case.

declare const incomingPort: string | undefined
 
const resolvedPort = incomingPort ?? '3000'
// resolvedPort: string

That is the whole feature. The reason it still deserves an article is everything around it — what the compiler infers from it, the one case where it is a syntax error, and the four or five places people reach for it when it is not actually the right tool.

One naming note before we go further. The specification calls this the nullish coalescing operator, because "nullish" is the word for "null or undefined". Most people say null coalescing operator, and both names point at the same ??. The distinction matters only when you are reading the TypeScript release notes, which use "nullish" throughout.

What the null coalescing operator checks

?? checks for exactly two values. Not falsiness — identity against null and undefined.

const zeroRetries = 0
 
const retriesWithOr = zeroRetries || 5 // 5 ❌ a real 0 was thrown away
const retriesWithNullish = zeroRetries ?? 5 // 0 ✅

This is the entire reason ?? was added. || tests for falsiness, and JavaScript's falsy list includes several values that are perfectly legitimate data:

const falsyButValid: unknown[] = [0, -0, '', false, NaN]
// every one of these survives ?? and is replaced by ||

A config object makes the damage obvious.

interface RateLimitConfig {
  maxRequests?: number
  burst?: number
  enabled?: boolean
  prefix?: string
}
 
declare const userConfig: RateLimitConfig
 
const brokenLimit = userConfig.maxRequests || 100
// a user who wrote maxRequests: 0 silently gets 100
 
const brokenEnabled = userConfig.enabled || true
// this expression can never be false — it is always true
 
const correctLimit = userConfig.maxRequests ?? 100
const correctEnabled = userConfig.enabled ?? true

brokenEnabled is the one that bites hardest. A boolean flag defaulted with || cannot be turned off, because the only value a user would write to turn it off is the one || discards.

The long way round

Before ??, writing this correctly meant spelling out both halves of "nullish" by hand.

declare const incomingTimeout: number | null | undefined
 
const verboseTimeout =
  incomingTimeout !== null && incomingTimeout !== undefined ? incomingTimeout : 30
 
const terseTimeout = incomingTimeout ?? 30

Both produce number. The second one is the version you will still understand in six months.

It helps to keep the four defaulting tools straight, because they trigger on different things:

ToolFires onLeaves alone
a ?? bnull, undefined0, '', false, NaN
a || bevery falsy valuetruthy values only
a?.bnull, undefined on the receivereverything else
function f(a = b)undefined onlynull and all falsy

The row that surprises people is the last one. A default parameter does not fire on null.

What TypeScript infers from ??

The compiler strips null and undefined from the left operand's type and unions what is left with the right operand's type. That means ?? is a narrowing operation, not just a runtime convenience.

declare const maybeLabel: string | null | undefined
 
const definiteLabel = maybeLabel ?? 'untitled'
// definiteLabel: string — null and undefined are gone

The right-hand side does not have to match the left.

declare const maybeCount: number | undefined
 
const countOrMessage = maybeCount ?? 'not counted'
// countOrMessage: number | string

This is why ?? quietly fixes a whole family of compiler errors. If you have hit TS18047: 'x' is possibly 'null' or TS18048: 'x' is possibly 'undefined', supplying a fallback with ?? is usually the shortest honest fix — it gives the value a type the rest of your code can actually use, instead of asserting the problem away with !.

It narrows the result, not the source

The narrowing applies to the expression, not to the thing on the left. The original property is exactly as optional afterwards as it was before.

interface SearchParams {
  query?: string
}
 
declare const searchParams: SearchParams
 
const safeQuery = searchParams.query ?? ''
// safeQuery: string
 
// const directLength = searchParams.query.length
// ❌ TS18048: 'searchParams.query' is possibly 'undefined'
 
const queryLength = searchParams.query?.length ?? 0

If you need the narrowed value more than once, store it in a const and use that. Repeating ?? '' at every call site is a sign the fallback belongs one level higher up.

The right side is not always reachable

Under strictNullChecks, if the left operand can never be nullish, the ?? is dead code. The compiler will not stop you, so this is on you to notice.

const alwaysPresent: string = 'typescript'
 
const pointlessFallback = alwaysPresent ?? 'fallback'
// pointlessFallback: string — the right side can never run

Seeing a ?? on a non-optional value usually means the type is lying, or the ?? is a leftover from before someone tightened the type.

Logical nullish assignment: ??=

??= assigns the right-hand side only when the target is currently null or undefined. It is the compound form of ??, in the same way += is the compound form of +.

interface CacheOptions {
  ttlSeconds?: number
  namespace?: string
}
 
function applyCacheDefaults(options: CacheOptions): CacheOptions {
  options.ttlSeconds ??= 60
  options.namespace ??= 'default'
  return options
}

The important detail is that ??= short-circuits the assignment, not just the value. If the property is already set, no write happens at all. That matters for setters, proxies, and anything watching for mutations:

const writeLog: string[] = []
 
const tracked = {
  _theme: 'dark' as string | undefined,
  get theme() {
    return this._theme
  },
  set theme(next: string | undefined) {
    writeLog.push(`set to ${next}`)
    this._theme = next
  },
}
 
tracked.theme ??= 'light'
// writeLog is still empty — the setter never ran

Memoising a lazily computed field is where ??= earns its place.

class ReportBuilder {
  private cachedTitle?: string
 
  getTitle(): string {
    this.cachedTitle ??= this.computeTitle()
    return this.cachedTitle
  }
 
  private computeTitle(): string {
    return 'Quarterly Report'
  }
}

The parenthesis rule

Mixing ?? with || or && in the same expression without parentheses is a syntax error, not a style warning. This is deliberate — the precedence would be ambiguous to readers, so the language refuses to guess.

declare const firstChoice: string | null
declare const secondChoice: string | null
declare const thirdChoice: string
 
// const ambiguous = firstChoice ?? secondChoice || thirdChoice
// ❌ TS5076: '??' and '||' operations cannot be mixed without parentheses
 
const explicitMix = (firstChoice ?? secondChoice) || thirdChoice // ✅
const otherMix = firstChoice ?? (secondChoice || thirdChoice) // ✅

Those two lines do genuinely different things, which is the point. Chaining ?? with itself needs no parentheses, because there is nothing to disambiguate:

declare const fromFlag: string | undefined
declare const fromEnv: string | undefined
declare const fromFile: string | undefined
 
const configuredHost = fromFlag ?? fromEnv ?? fromFile ?? 'localhost'
// configuredHost: string

That chain is the single most useful shape ?? has. It reads as a priority list, evaluates left to right, and stops at the first non-nullish value.

Pairing ?? with optional chaining

?. and ?? are two halves of the same idea and were shipped in the same TypeScript release. ?. gets you out of a nested lookup safely by producing undefined; ?? turns that undefined into a real value.

interface UserProfile {
  settings?: {
    theme?: string
    fontSize?: number
  }
}
 
declare const currentUser: UserProfile | null
 
const activeTheme = currentUser?.settings?.theme ?? 'light'
// activeTheme: string

Without the ??, activeTheme would be string | undefined and every consumer would have to handle the gap again. Without the ?., the lookup throws when currentUser is null — which is the runtime version of TS2531: Object is possibly 'null'.

The optional-property side of this pairing has more rules than fit here; the TypeScript optional page covers ? on properties, parameters and tuple elements in full.

It short-circuits

If the left operand is not nullish, the right operand is never evaluated. That makes ?? safe in front of expensive or side-effecting fallbacks.

function loadDefaultTemplate(): string {
  console.log('only runs when the cache missed')
  return 'default-template'
}
 
declare const cachedTemplate: string | null
 
const chosenTemplate = cachedTemplate ?? loadDefaultTemplate()

Nothing is logged when cachedTemplate holds a string.

Where ?? is not the answer

Default parameters already handle undefined. A default parameter fires on undefined and only on undefined — null passes straight through, which is exactly when you still need ??.

function greetWithDefault(personName: string = 'friend'): string {
  return `Hello, ${personName}`
}
 
function greetWithNullish(personArg: string | null): string {
  return `Hello, ${personArg ?? 'friend'}`
}
 
greetWithDefault(undefined) // 'Hello, friend'
greetWithNullish(null) // 'Hello, friend'

An empty string is not nullish. If a blank form field should fall back to a placeholder, ?? will not do it, because '' is a real string.

declare const formInput: string | undefined
 
const keepsBlank = formInput ?? 'placeholder' // '' stays ''
const replacesBlank = formInput?.trim() || 'placeholder' // '' becomes 'placeholder'

This is the one spot where || is the correct operator. Reach for it deliberately, not out of habit.

It does not validate. ?? guards against absence, not against wrong types. A value of NaN, an empty array, or an object missing half its fields sails through untouched.

It flattens null and undefined into one bucket. Usually that is the point. But some APIs give the two values separate meanings, and ?? cannot tell them apart.

interface PatchPayload {
  avatarUrl?: string | null
}
 
declare const patch: PatchPayload
 
// These two payloads mean different things:
//   {}                      → leave the avatar as it is
//   { avatarUrl: null }     → clear the avatar
const flattenedAvatar = patch.avatarUrl ?? 'keep-existing' // ❌ both collapse to the same branch
 
const avatarAction = !('avatarUrl' in patch)
  ? 'leave-alone'
  : patch.avatarUrl === null
    ? 'clear'
    : 'set'

PATCH endpoints, GraphQL nullable fields, and database rows where NULL means "no value" rather than "no answer" all land here. When the distinction matters, test for it explicitly.

Finding the || you should have written as ??

In an existing codebase the interesting question is not how to use ??, it is which of your hundreds of existing || defaults are quietly wrong. You do not have to audit them by hand — @typescript-eslint ships a rule for exactly this.

{
  "rules": {
    "@typescript-eslint/prefer-nullish-coalescing": "error"
  }
}

The rule is type-aware, which is what makes it worth turning on. It only flags a || whose left-hand side can actually be null or undefined, so a boolean guard like isEnabled || isAdmin is left alone while config.retries || 3 is reported. It also has a --fix mode, so most of the migration is mechanical.

declare const legacyRetries: number | undefined
 
const legacyDefault = legacyRetries || 3 // flagged
const fixedDefault = legacyRetries ?? 3 // the autofix

Run it once with --fix, then read the diff. The handful of changes that look wrong are the places where someone genuinely meant "falsy", and those are worth a comment explaining why.

Compiler support

?? and ?. landed in TypeScript 3.7 and were standardised in ES2020; ??= arrived in TypeScript 4.0 alongside ES2021. If your target is es2020 or newer, the operators are emitted as-is. Below that, TypeScript downlevels them into a conditional:

// target: es6 — what `incomingPort ?? '3000'` compiles to
const resolvedPort = incomingPort !== null && incomingPort !== void 0 ? incomingPort : '3000'

The void 0 is TypeScript being careful: in old JavaScript, undefined was writable, so comparing against void 0 is the only way to be certain. The emitted check tests both null and undefined in one go, which is the runtime definition of "nullish".

Wrapping up

The null coalescing operator replaces a value only when that value is null or undefined. That precision is the entire point — || was close enough until your data contained a legitimate 0, '' or false, and then it quietly corrupted your defaults.

Three things worth remembering:

Share this article

Become a TypeScript Pro

Track your progress through 200+ hands-on challenges. Free, sign in with GitHub.

Or start solving right away: explore all TypeScript challenges

Practice with Challenges

Put your null coalescing operator in typescript knowledge to the test with these related challenges.

#59Get Optional
Hard
#90Optional Keys
Hard
#28143OptionalUndefined
Hard
#4Pick
Easy

Related Concepts

Concepts that build on or relate to null coalescing operator in typescript.

TypeScript OptionalUnion TypesTypeScript satisfies OperatorThe JavaScript Spread Operator in TypeScriptInterfaces