Null Coalescing Operator in TypeScript
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: stringThat 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 ?? truebrokenEnabled 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 ?? 30Both 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:
| Tool | Fires on | Leaves alone |
|---|---|---|
a ?? b | null, undefined | 0, '', false, NaN |
a || b | every falsy value | truthy values only |
a?.b | null, undefined on the receiver | everything else |
function f(a = b) | undefined only | null 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 goneThe right-hand side does not have to match the left.
declare const maybeCount: number | undefined
const countOrMessage = maybeCount ?? 'not counted'
// countOrMessage: number | stringThis 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 ?? 0If 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 runSeeing 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 ranMemoising 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: stringThat 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: stringWithout 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 autofixRun 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:
- Default with
??, not||, unless you have specifically decided that falsy should count. - Chain it (
a ?? b ?? c) for priority lists, and parenthesise it the moment||or&&enters the expression. ??=is the assignment form, and it skips the write entirely when the target already has a value.
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