skip to content

You are writing a memoization helper in JavaScript that skips recomputation when the new argument is "the same" as the previous one. Which sameness comparison should it use, and what breaks with each candidate?

level: seniorimportance: should knowfreq 33%

answer

  1. reference identity is not enough
  2. watch the value that never equals itself
  3. zero's sign rarely matters to caches
  4. SameValueZero is the usual want

basics

~20 s

Use SameValueZero: strict equality plus a NaN-matches-NaN case. Plain === makes a NaN argument look changed on every call, and Object.is additionally reports -0 as different from 0, which caches rarely want. None of them compares object contents.

solid answer

~50 s

There are three candidates and each fails differently. `===` is the obvious choice but never matches `NaN`, so a single `NaN` argument defeats the cache permanently and the work runs on every call — an easy hang or a hot loop in a numeric pipeline. `Object.is` fixes that, but it also separates `-0` from `0`, so a value that flips sign at zero — from `Math.round(-0.2)` or `-1 * 0` — is reported as changed when nothing meaningful did. SameValueZero, written as `a === b || (a !== a && b !== b)` or borrowed from a `Set`, matches `NaN` and treats the zeros alike, which is what a cache normally means by "same". Whichever you pick, all three compare objects by reference: a freshly built array or options object never matches, so callers must keep references stable or you must accept the cost of a deep comparison.

code

javascript · 14 lines
javascript
const sameValueZero = (a, b) => a === b || (a !== a && b !== b);

let lastInput, cached, runs = 0;
function double(n) {
  if (runs > 0 && sameValueZero(n, lastInput)) return cached;
  lastInput = n;
  runs += 1;
  cached = n * 2;
  return cached;
}

double(NaN);
double(NaN);
console.log(runs); // 1 — a plain === guard would recompute every call

go deeper

for a junior

Know that a cache compares the new value with the old one, and that === is the normal comparison but cannot recognise NaN as the same value it saw before.

for a middle

Explain each candidate's mechanics: strict equality misses NaN, Object.is separates -0 from 0, and SameValueZero — a === b || (a !== a && b !== b) — matches NaN while treating the zeros alike.

for a senior

Show the production consequence and the diagnosis: a permanently cold cache for one input value, stale results when a compared object is mutated in place, and the reference-stability contract you need from callers before a memo is worth anything.

for a principal

Own the policy: one documented sameness helper for the codebase rather than several ad-hoc ones, an explicit stance on structural comparison and its cost, and a clear rule about which layer owns reference stability.

## Why the choice matters A memo, a change detector and a dedup step all rest on one predicate: is the new value the same as the old one? JavaScript offers several answers to that and they disagree on exactly two values, `NaN` and `-0`. Those two look like trivia until they reach a cache, where the consequence is not a wrong boolean but a permanently cold cache or a spurious invalidation. ## Candidate 1: strict equality ```js if (next === prev) return cached; ``` Correct for every value except one. `NaN === NaN` is `false`, so once the input is `NaN` the guard never fires again: every call recomputes, every dependent recomputes, and the cache has silently become an overhead. `NaN` is not exotic in real inputs — it comes out of `Number(userText)` on bad text, out of `0/0` in a ratio, out of arithmetic on a missing sensor reading. The symptom is a hot path that gets slower for one particular record and nobody can reproduce it with clean data. ## Candidate 2: Object.is ```js if (Object.is(next, prev)) return cached; ``` This fixes `NaN` — `Object.is(NaN, NaN)` is `true` — but adopts the other divergence: `Object.is(-0, 0)` is `false`. Negative zero is produced by ordinary arithmetic (`-1 * 0`, `Math.round(-0.2)`, `0 * someNegative`) and is invisible when printed, since `String(-0)` is `"0"`. So the cache will report "changed" on a transition your logs cannot show you. That is a rarer failure than the `NaN` one, and usually a harmless extra recomputation, but it is a failure mode you should be able to name rather than discover. ## Candidate 3: SameValueZero The rule used by `Array.prototype.includes`, `Set` membership and `Map` keys matches `NaN` with `NaN` and treats `-0` and `0` as the same. It is what most caches actually mean, and it is three tokens to write: ```js const sameValueZero = (a, b) => a === b || (a !== a && b !== b); ``` The second clause is the only trick: `a !== a` is true for exactly one value, `NaN`. If you would rather not explain it in a review, `new Set([a]).has(b)` gets the identical semantics from the built-in collection, at the cost of an allocation. ## The failure none of them fixes: objects All three comparisons treat objects by reference. If the argument is an options object, an array or a callback built fresh at the call site, no reference-based comparison will ever report "same", and the memo is dead weight. There are only two honest ways out: 1. **Make the references stable.** Hoist the literal, cache it upstream, or key the memo on the primitive fields that actually vary. This keeps the comparison O(1) and keeps the meaning of "same" obvious. 2. **Compare structurally.** Write or import a deep comparison, and accept that its cost scales with the data and that *you* now have to decide how it treats `NaN`, `-0`, missing versus `undefined` keys, key order, `Map`/`Set` contents and cycles. Every deep-equality library answers those differently. A related trap is the mirror image: reference comparison reports "same" for an object that was mutated in place. If your pipeline mutates and you compare by reference, the cache serves stale results — the comparison is behaving correctly and the data flow is the bug. ## Guarding the first call ```js const NOTHING = Symbol('nothing'); let prev = NOTHING; let cached; function memoOne(fn) { return (arg) => { if (prev !== NOTHING && sameValueZero(arg, prev)) return cached; prev = arg; cached = fn(arg); return cached; }; } ``` Use a private sentinel rather than `undefined` for "no previous value", or a legitimate `undefined` argument will look like a cold cache forever. A unique symbol can never collide with a caller's value. ## How to answer Say which comparison you would pick and why, then name the failure mode of each alternative in one clause: `===` thrashes on `NaN`; `Object.is` invalidates on the sign of zero; SameValueZero is the pragmatic middle; and none of them sees inside objects, so reference stability is a contract with your callers, not an implementation detail. Then say that whichever you choose gets written down — a shared, documented comparison beats three subtly different ones scattered across layers.

  • What does a comparison like this do about object and array arguments?
    Nothing useful — all three candidates compare objects by reference, so an options object or array built fresh at the call site never matches and the memo never hits. Either make the references stable upstream, key the memo on the primitive fields that vary, or accept a deep comparison and its cost. Reference comparison also misses in-place mutation, serving stale results.
  • How do you write a SameValueZero comparison without pulling in a collection?
    `a === b || (a !== a && b !== b)`. The first clause covers everything strict equality already handles, including treating `-0` and `0` as the same, and the second is true only when both operands are `NaN`, the one value that is not equal to itself. If you prefer built-in semantics, `new Set([a]).has(b)` gives the identical answer at the cost of an allocation.
  • When would Object.is actually be the right choice for change detection?
    When the sign of zero is part of the meaning — a direction, a signed delta, or a value you format for a user where rendering `-0` would be visible or wrong. In those cases a transition from `0` to `-0` genuinely is a change worth recomputing. Everywhere else its zero strictness is noise that costs you cache hits.

saying these in an interview costs you the question

  • Uses === in a cache and ignores the NaN case
  • Assumes Object.is is always the safest choice
  • Expects reference comparison to notice in-place mutation
  • Reaches for deep equality on every comparison by default
  • Believes stringifying values is a reliable sameness test

context