“What is the difference between ?? and ||?” is a staple beginner JavaScript interview question, and it often comes wrapped in a bug: “A user set their volume to 0, saved, and the app reset it to 50. Find the problem.” The answer is almost always a || used for a default value. Interviewers like this question because it checks whether you know JavaScript’s falsy values precisely, and whether you think about valid-but-falsy data such as zero, empty strings and false.
A strong answer also covers the related default mechanisms, which do not all follow the same rule: default parameters, destructuring defaults and the logical assignment operators.
Before you start
You should know what truthy and falsy mean: in a boolean context, false, 0, -0, 0n, '', null, undefined and NaN are falsy, and everything else, including '0', [] and {}, is truthy. You should also know that || and && return one of their operands, not necessarily a boolean.
The short answer
a || b returns b whenever a is falsy, so legitimate values like 0, '' and false get replaced. a ?? b returns b only when a is null or undefined, so it is the right operator for “use this default when the value is missing.” Default parameters and destructuring defaults are narrower still: they apply only to undefined, not null. Use || only when every falsy value really should be treated as “no value.”
How it works
Both operators short-circuit: the right side is evaluated only if needed. The difference is the test applied to the left side.
const values = [0, '', false, NaN, null, undefined, 'set'];
for (const v of values) {
console.log(String(v).padEnd(9), '||', String(v || 'default').padEnd(7), '??', v ?? 'default');
}
// 0 || default ?? 0
// || default ??
// false || default ?? false
// NaN || default ?? NaN
// null || default ?? default
// undefined || default ?? default
// set || set ?? setThe empty-string row shows ?? returning the empty string, which prints as nothing. || replaced five values that ?? kept.
Default parameters and destructuring defaults use a third rule: only undefined triggers the default. Passing null explicitly means “I am providing a value, and it is null.”
function greet(name = 'guest') {
return `hi ${name}`;
}
console.log(greet()); // hi guest
console.log(greet(undefined)); // hi guest
console.log(greet(null)); // hi null
const { limit = 10 } = { limit: null };
console.log(limit); // nullThat null case surprises many developers working with JSON APIs, where missing fields often arrive as null. If you need null to fall back too, apply ?? inside the function.
Step-by-step walkthrough
Step 1: Decide which values count as missing
Before choosing an operator, ask what “no value” means for this field. For a volume level, 0 is a real setting. For a display name, perhaps an empty string should fall back to “Anonymous”. For a boolean preference, false is a deliberate choice. Usually only null and undefined mean missing, which points to ??.
Step 2: Apply ?? to settings
function loadSettings(saved) {
return {
volume: saved.volume ?? 50,
nickname: saved.nickname ?? 'Player',
notifications: saved.notifications ?? true,
};
}
console.log(loadSettings({ volume: 0, nickname: '', notifications: false }));
// { volume: 0, nickname: '', notifications: false }
console.log(loadSettings({}));
// { volume: 50, nickname: 'Player', notifications: true }If you also want to reject an empty nickname, express that rule separately and visibly, for example saved.nickname?.trim() || 'Player', so readers can see it is intentional.
Step 3: Use the logical assignment operators
a ??= b assigns b only when a is nullish; a ||= b assigns when a is falsy; a &&= b assigns when a is truthy. They short-circuit too: when no assignment is needed, the right side never runs and no write happens.
const options = { retries: 0, timeout: null };
options.retries ??= 3;
options.timeout ??= 5000;
options.label ||= 'untitled';
console.log(options); // { retries: 0, timeout: 5000, label: 'untitled' }
let calls = 0;
const expensive = () => { calls++; return 'computed'; };
const cache = { value: 'cached' };
cache.value ??= expensive();
console.log(calls); // 0Step 4: Mind precedence and mixing
The language forbids combining ?? directly with || or && because the intended grouping is ambiguous. You must add parentheses. Also note that ?? has lower precedence than comparison operators, so arithmetic or comparisons bind first.
let err;
try {
eval('null || undefined ?? "x"');
} catch (e) {
err = e.constructor.name;
}
console.log(err); // SyntaxError
console.log((null || undefined) ?? 'x'); // x
const count = null;
console.log((count ?? 0) + 1); // 1
console.log(count ?? 0 + 1); // 1
const n = 5;
console.log(n ?? 0 + 1); // 5The last two lines show why parentheses matter: count ?? 0 + 1 parses as count ?? (0 + 1). It happens to print 1 for null, but for 5 it prints 5, not 6.
Worked scenario
An e-commerce cart let users pick a quantity and a discount percentage. Support tickets said that free promotional items (price 0) showed as full price and a 0 percent discount became 10 percent.
function lineTotalBroken(item) {
const price = item.price || item.listPrice;
const discount = item.discount || 10;
return price * (1 - discount / 100);
}
console.log(lineTotalBroken({ price: 0, listPrice: 30, discount: 0 })); // 27Both defaults used ||, so 0 was treated as missing. The fix uses ??, which keeps real zeros and only falls back when the field is absent.
function lineTotal(item) {
const price = item.price ?? item.listPrice;
const discount = item.discount ?? 10;
return price * (1 - discount / 100);
}
console.log(lineTotal({ price: 0, listPrice: 30, discount: 0 })); // 0
console.log(lineTotal({ listPrice: 30 })); // 27
console.log(lineTotal({ price: null, listPrice: 30, discount: null })); // 27The last call shows why ?? beats a default parameter or destructuring default here: the API sent null for missing fields, which = 10 would not have caught.
Common mistake
- “
??checks for falsy values.” It only checks fornullandundefined. - “Default parameters handle null.” They handle only
undefined. - “
??and||are interchangeable for strings.” Not when an empty string is valid input, such as a cleared search box. - Treating
NaNas missing with??.NaN ?? 0isNaN. If invalid numbers should fall back, check withNumber.isNaNexplicitly. - Mixing
??with||without parentheses. It is a syntax error, not a precedence puzzle. - Assuming
??is a type check. It does not validate.'abc' ?? 5is'abc', even if you expected a number.
Verify the behavior
These assertions document the three different default rules side by side.
import assert from 'node:assert/strict';
assert.equal(0 || 5, 5);
assert.equal(0 ?? 5, 0);
assert.equal('' ?? 'x', '');
assert.equal(null ?? 'x', 'x');
const withParam = (v = 'd') => v;
assert.equal(withParam(undefined), 'd');
assert.equal(withParam(null), null);
const { a = 'd' } = { a: null };
assert.equal(a, null);
let x = 0;
x ||= 9;
assert.equal(x, 9);
let y = 0;
y ??= 9;
assert.equal(y, 0);
console.log('defaults behave as described');
// defaults behave as describedRun it with node defaults.mjs.
Follow-up questions
How does ?? relate to optional chaining? They pair naturally: user?.profile?.city ?? 'Unknown'. Optional chaining yields undefined when a link is missing, and ?? supplies the fallback.
Is ?? the same as x !== null && x !== undefined ? x : y? Yes, except that ?? evaluates x only once, which matters when x is a function call or getter.
What about document.all? It is a legacy browser object that is falsy and loosely equal to null, but document.all ?? 'x' returns document.all, because ?? checks for actual null or undefined.
When is || still right? When every falsy value genuinely means missing, such as showing a placeholder for any empty or absent label.
Interview exercise
What does each line print, and which ones would you rewrite in production code?
const cfg = { port: 0, host: '', debug: false, retries: null };
console.log(cfg.port || 3000);
console.log(cfg.host ?? 'localhost');
console.log(cfg.debug ?? true);
console.log(cfg.retries ?? 3);
console.log(cfg.missing?.value ?? 'none');
// 3000
//
// false
// 3
// noneAnswer and reasoning
cfg.port || 3000 prints 3000 because 0 is falsy. Port 0 can be meaningful (it asks the OS for any free port), so this should become ??. cfg.host ?? 'localhost' prints an empty line because '' is not nullish; whether that is a bug depends on the rule. An empty host is almost certainly a mistake, so a deliberate cfg.host || 'localhost' or validation that rejects empty strings is clearer. cfg.debug ?? true prints false, correctly respecting the explicit choice; || would have forced it to true. cfg.retries ?? 3 prints 3 because null is nullish. The optional chain yields undefined for the missing object, and ?? supplies 'none'. The reasoning interviewers want is not memorising the table but naming, per field, which values mean “missing.”
Continue learning
Try more JavaScript interview questions and quiz yourself with the JavaScript MCQs. Related notes: optional chaining with nullish coalescing and destructuring defaults. Official reference: Nullish coalescing operator on MDN.