Optional Chaining vs Nullish Coalescing in JavaScript
Photo by Unsplash on Unsplash
Table of Contents
Short Answer
Mixing `??` with `||` or `&&` in one expression without parentheses is a SyntaxError, and the engine catches it before your code ever runs.
What Optional Chaining Actually Does
When you write `user?.profile?.city`, the engine checks whether `user` is `null` or `undefined`. If it is, evaluation stops immediately and the whole expression produces `undefined`. It doesn't attempt `profile`, and it doesn't throw the cannot read properties error that used to bring pages down.
The syntax covers three shapes and most people only know the first. Property access is `obj?.prop`, dynamic access is `obj?.[key]`, and function calls are `obj.method?.()`. That last one is genuinely useful for optional callbacks, because it checks the function exists before calling it rather than checking separately.
Worth being precise about the trigger: it only reacts to `null` and `undefined`. An empty object, an empty string or the number zero are all perfectly valid values and evaluation carries straight on through them.
I use it most heavily on API responses, where a field being absent is normal rather than exceptional. Anyone doing the same will recognise the shape from our guide to reading JSON from an API, since deeply optional payloads are exactly where this earns its keep.
The Zero and Empty String Trap
For years the standard way to write a default was `const count = input || 10`. That reads correctly and is wrong, because `||` triggers on every falsy value. Pass a genuine `0` and you get `10`. Pass an empty string for a name and you get the placeholder back instead of the empty string the user actually chose.
`??` fixes it by narrowing the trigger to `null` and `undefined` only. `const count = input ?? 10` gives you `10` when nothing was supplied and keeps the `0` when a zero was. That's almost always what the code meant.
The values that separate them are worth memorising because there are only six in the gap: `false`, `0`, `-0`, `0n`, `''` and `NaN`. Those six are falsy but not nullish, so they behave differently under the two operators and identically under everything else.
Settings and form inputs are where this bites hardest. A user turning something off sends `false`, a user setting a volume to silent sends `0`, and a user clearing a field sends `''`. All three are real choices, and `||` overwrites all three with your default. I've fixed this exact bug in three separate codebases and the symptom was the same every time: a setting that refuses to stay off.
Why Mixing Them Throws a SyntaxError
That looks hostile until you consider the alternative. `??` and `||` have genuinely different semantics, and any precedence the committee picked would be the wrong guess roughly half the time. Rather than let people write ambiguous code that silently does one of two plausible things, the language refuses the expression.
Parentheses resolve it, and they force you to state which grouping you meant. `(a ?? b) || c` and `a ?? (b || c)` are both legal and they do different things, which is precisely the point. The error is the language making you decide.
Same rule applies to `&&`. There's no combination of `??` with a logical operator that works bare, and there's no runtime cost to any of this because it's caught at parse time. A CI run catches it, a linter catches it, and it never reaches a user.
The MDN nullish coalescing reference documents the restriction if you want the exact spec wording.
The Assignment Forms
`??=` assigns only when the current value is `null` or `undefined`, while `||=` assigns whenever the current value is falsy. So `config.retries ??= 3` leaves an explicit `0` alone, and `config.retries ||= 3` quietly replaces it. Same trap as before, same fix.
These are also short-circuiting, which matters more than it sounds. If the assignment doesn't happen, the right hand side is never evaluated at all. Putting an expensive function call there costs nothing on the passes where the value was already set.
Config objects and options merging are the natural home for `??=`. You take whatever the caller passed, fill the gaps, and never clobber a deliberate zero or empty string. Doing the same job with a spread and a defaults object works too, but it treats absent and falsy identically unless you're careful.
There's a `&&=` as well, which assigns only when the current value is truthy. It comes up rarely and mostly for clearing derived state.
Where Optional Chaining Hides Real Bugs
Sprinkling `?.` through a chain to stop an error means the error stops appearing, not that the problem stopped happening. `user?.profile?.settings?.theme` returning `undefined` tells you nothing about which link in that chain was missing, and if `user` should never be null, you've just hidden a bug in your data layer behind a reasonable looking expression.
The distinction worth holding is between optional and unexpected. A field that's genuinely optional in your schema deserves `?.`. A field that should always be present deserves a check that fails loudly, because silent `undefined` propagates and surfaces somewhere far away from the cause.
There's a small performance angle too, though it rarely matters. Each `?.` is a check, and a chain of six inside a tight loop does six checks per iteration. Not a reason to avoid it, just a reason not to reach for it reflexively on values you already validated.
My own rule is that `?.` belongs at the boundary where untrusted data enters, and plain dot access belongs everywhere after that, because by then the shape should already be known. If you're inspecting a payload to work out which fields are genuinely optional, our JSON formatter makes the structure readable in a couple of seconds.
Rules That Hold Up
Reach for `??` by default when writing a fallback, and treat `||` as the special case you pick deliberately when you genuinely want every falsy value replaced. That inverts the old habit, and inverting it is the whole improvement.
Use `?.` where absence is expected and a plain check where it isn't. If you can't say which of those applies to a given field, that uncertainty is worth resolving before you write either one.
Add parentheses the moment an expression mixes operators, even where the parser would allow it. The next person reading it shouldn't have to recall a precedence table, and you'll be that person soon enough.
Keep the six value gap in mind whenever you touch a settings path. `false`, `0`, `-0`, `0n`, `''` and `NaN` are where these operators disagree, and settings code is where those values are most likely to be real. Modern JavaScript has been steadily replacing footguns like this, and the Temporal API is the current example of the same cleanup happening to dates.
Frequently Asked Questions
What is the difference between ?? and || in JavaScript?
The || operator falls back on any falsy value, including false, 0, -0, 0n, empty string and NaN. The ?? operator falls back only on null and undefined, so a real zero or empty string is preserved.
Why do I get a SyntaxError mixing ?? with ||?
JavaScript refuses the combination without parentheses because the two operators have different semantics and any default precedence would be wrong half the time. Wrap one side in parentheses to state which grouping you meant.
Does optional chaining work with function calls?
Yes. obj.method?.() calls the function only if it exists and returns undefined otherwise, which is useful for optional callbacks. It also works for dynamic access with obj?.[key].
What does ??= do?
It assigns only when the current value is null or undefined, unlike ||= which assigns on any falsy value. It's the safe choice for config defaults where an explicit 0 or false should survive.
Is it bad to use too much optional chaining?
It can be. Chaining ?. everywhere hides which link was actually missing and can mask a real data bug. Use it where absence is expected, and check loudly where a field should always exist. Our [JSON formatter](/tools/json-formatter) helps work out which is which.
When were these operators added to JavaScript?
Both optional chaining and nullish coalescing arrived in ES2020, and the logical assignment forms including ??= followed in ES2021. All are supported across current browsers and runtimes, so no transpilation or polyfill is needed for either one in a modern project. If you're still running a build step that compiles them away, it's worth checking whether your browser target list is older than your actual traffic, because the compiled output is longer and slower than the native operators it replaces.
Try ToolsFuel
23+ free online tools for developers, designers, and everyone. No signup required.
Browse All Tools