A JavaScript service resolves options with `const retries = cfg.retries || 3` in dozens of places, and users report that explicit `0` and `false` settings are ignored. How do you audit and fix this, and where is `??` not the right replacement?
answer
- absent and falsy were being conflated
- the operator choice depends on the option's type
- empty text is often genuinely missing
- one guard does not catch unparseable numbers
- resolve defaults once, at the boundary
basics
~20 sTreat it as a semantics audit, not a find-and-replace: at each site decide which values mean absent. Numeric and boolean options should use ?? so 0 and false survive; string and array options where empty genuinely means missing need an explicit check, not ??.
solid answer
~50 sThe bug is that `||` conflates "not provided" with "provided and falsy", so a deliberate `0`, `false` or `""` is replaced by the default. I would find the sites mechanically — grep for `|| ` next to literal defaults, focusing on options, config and function parameter resolution — but fix them per site rather than in bulk, because the correct rule depends on the option's type. Numeric and boolean options almost always want `??`: `cfg.retries ?? 3` and `cfg.verbose ?? true` preserve explicit values. String options are the trap: for a user-entered name, an empty string usually *does* mean missing, so a blind `??` migration reintroduces empty labels, and you want an explicit emptiness test instead. I would also pin the behaviour with tests that pass `0`, `false` and `""` explicitly, since those are precisely the cases the original code never exercised, and then move default resolution to one normalisation step at the boundary so downstream code stops re-deriving defaults at all.
code
javascript · 13 linesconst cfg = { retries: 0, verbose: false, label: ' ', timeout: 'abc' };
console.log(cfg.retries || 3); // 3 — bug: explicit 0 ignored
console.log(cfg.retries ?? 3); // 0 — fixed
console.log(cfg.verbose ?? true);// false — fixed
// ?? is the WRONG fix for these two:
console.log(cfg.label ?? 'untitled'); // ' ' — blank label survives
console.log(cfg.label.trim() || 'untitled'); // 'untitled'
const n = Number(cfg.timeout);
console.log(n ?? 30); // NaN — ?? does not catch NaN
console.log(Number.isFinite(n) ? n : 30); // 30go deeper
Be able to identify the cause — || replaces every falsy value, not just missing ones — and apply ?? to numeric and boolean options so an explicit 0 or false is respected.
Explain the falsy-versus-nullish distinction per option type, and show that empty strings and NaN are cases where ?? is not the correct replacement and an explicit check is needed.
Demonstrate the audit discipline: locate the sites mechanically, decide semantics per site, add tests that pass 0, false and empty string, and collapse scattered defaults into a single normalisation step at the boundary.
Own the contract question: decide and document whether absence is an omitted key, an explicit null, or a sentinel, and make defaults resolve once at a documented boundary so no two parts of the system can disagree about what a missing option means.
## What actually went wrong `a || b` returns `b` for every falsy `a`: `false`, `0`, `-0`, `0n`, `""`, `NaN`, `null` and `undefined`. The idiom was written to mean "if the caller supplied nothing, use the default", but it actually means "if the caller supplied nothing *or something falsy*, use the default". For options whose valid range includes a falsy value, those two readings diverge, and the failure is silent — no exception, no log, just a setting that refuses to take effect. The symptom pattern is characteristic: `retries: 0` behaves like `retries: 3`, `verbose: false` can never be turned off, `timeout: 0` (meaning "no timeout") becomes a real timeout, and an empty `prefix: ''` reverts to a default prefix. ## Step 1 — find the sites These live in predictable places: option-object destructuring, `function f(opts) { const x = opts.x || DEFAULT; }`, environment-variable parsing, and merge helpers such as `{ ...defaults, ...provided }` followed by per-field `||`. A search for `|| ` immediately followed by a numeric, boolean or string literal, plus a search on the option names in your public API surface, finds nearly all of them. Type information helps if you have it: any option declared as a number or boolean is a candidate by construction. ## Step 2 — classify each site, do not bulk-replace For each site ask one question: *is any falsy value a legitimate setting here?* | Option shape | Falsy value that is legitimate | Correct operator | |---|---|---| | numeric count, size, timeout | `0` | `??` | | boolean flag | `false` | `??` | | free-text label, name | usually none — `""` means missing | explicit check | | identifier / key from user input | `""` means missing | explicit check | | parsed number that may fail | `NaN` is neither | explicit check | The first two are the clear wins: `cfg.retries ?? 3` and `cfg.verbose ?? true`. ## Step 3 — where `??` is the wrong answer Two cases bite people who migrate mechanically. **Empty strings.** For a display name typed into a form, `""` means the user entered nothing, and `name ?? 'anonymous'` will happily render an empty label. Here the original `||` was correct, or better, an explicit rule: ```js const display = name?.trim() ? name.trim() : 'anonymous'; ``` **NaN.** `??` does not rescue `NaN`, so `Number(input) ?? 30` never fires the default for unparseable input. Numeric parsing needs its own guard: ```js const n = Number(raw); const timeout = Number.isFinite(n) ? n : 30; ``` A third, quieter case: environment variables are always strings, so `process.env.DEBUG ?? true` is `"false"` — a truthy string — when the operator was never the problem in the first place. Env parsing needs an explicit decode step, not a fallback operator. ## Step 4 — lock the behaviour down with tests The reason this class of bug survives for years is that nobody writes the test that passes `0`. Add cases that supply each falsy-but-legitimate value explicitly and assert it survives to the point of use. These tests are cheap and they are the only thing that stops the `||` idiom from creeping back in during a later refactor. ## Step 5 — remove the repetition Dozens of call sites re-deriving the same defaults is the deeper defect; the operator is only how it manifests. Normalise once at the boundary — where the config object is loaded or the public function is entered — into a fully populated internal object, and let everything downstream read plain properties: ```js function normalise(cfg = {}) { return { retries: cfg.retries ?? 3, verbose: cfg.verbose ?? true, label: cfg.label?.trim() || 'untitled' }; } ``` Now there is exactly one place where the absent-versus-falsy decision is expressed, one place to test, and no chance of two call sites disagreeing about what the default is. Note the deliberate mix of operators inside it — that is the point: each field states its own rule, and the choice is visible in one screen instead of scattered across the codebase. ## Step 6 — decide what "absent" means in your API The cleanest long-term fix is upstream of all of this: define whether a missing option is represented by an omitted key, an explicit `null`, or a sentinel, and document it. If callers can send `null` to mean "use the default", `??` is exactly right. If `null` means "explicitly nothing", then `??` is wrong and you need `=== undefined`. Answering that once removes the ambiguity that makes every individual call site a judgment call. ## What to say in an interview Diagnose (falsy versus absent), audit mechanically but fix semantically, name the two migration traps (empty strings and `NaN`), add tests for the falsy values that were never exercised, and finish with the structural fix: resolve defaults once at the boundary rather than at every use.
- How would you find these sites in a large codebase without types?Search for `||` immediately followed by a numeric, boolean or string literal, and for the option names in the public API surface; option destructuring and env parsing are the dense areas. Then reduce recurrence structurally: one normalisation function per entry point, so new call sites read resolved properties instead of re-deriving defaults.
- A caller passes `null` to mean "use the default" — does `??` still fit?Yes, because `??` treats `null` and `undefined` identically. If instead `null` means "explicitly no value" and only an omitted key means default, `??` is wrong and you need `cfg.x === undefined ? DEFAULT : cfg.x`, or a presence check with `in` or `Object.hasOwn`. Decide and document which convention the API uses.
- Why add tests specifically for 0, false and empty string?Because those are exactly the inputs the buggy code never exercised — the original tests passed truthy values, so the defect was invisible. Explicit falsy-value cases assert the absent-versus-falsy semantics you just chose, and they fail loudly if someone later reintroduces `||` while refactoring.
- Is `Object.assign({}, defaults, provided)` a safer way to apply defaults?It avoids the falsy problem, since it copies whatever key is present rather than testing values — but only for keys that are present. An explicit `undefined` in `provided` overwrites the default with `undefined`, and the same is true of object spread, so you still need to strip undefined values or fall back per field.
saying these in an interview costs you the question
- Proposes a global find-and-replace of || with ??
- Assumes ?? also rescues NaN and unparseable numbers
- Ignores that empty strings often genuinely mean missing
- Fixes call sites without adding falsy-value tests
- Leaves defaults duplicated across dozens of call sites