edgegoat Guides

Negative zero in JavaScript: why -0 is different from 0

IEEE 754 numbers carry a sign bit even for zero. JavaScript hides it in most places and exposes it in a few, and that inconsistency is exactly what turns into a bug.

Where -0 comes from

  • Rounding small negatives: Math.round(-0.4), Math.trunc(-0.5), Math.ceil(-0.5).
  • Arithmetic: -1 % 1, -0.5 * 0, 0 / -5.
  • Parsing: Number("-0") and JSON.parse("-0").

Where it shows up

  • 1 / -0 is -Infinity and Math.atan2(0, -0) is pi.
  • Object.is(-0, 0) is false, so React state, memoization and Object.is based equality treat them as different.
  • String(-0) and JSON.stringify(-0) print "0", but Intl.NumberFormat and toLocaleString() print "-0".

The bug: "-0" on screen

A change indicator rounds a tiny negative delta to zero, and Intl.NumberFormat keeps the sign. Users see -0.

example.js
function formatDelta(n) {
  return (n > 0 ? '+' : '') + new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 }).format(n);
}

formatDelta(-0.04); // "-0"

The edgegoat test that catches it

example.test.js
import { expect, test } from 'vitest';
import { numbers } from 'edgegoat';

test.each(numbers({ tags: ['zero', 'sign'] }))('$label', ({ value }) => {
  expect(formatDelta(value)).not.toBe('-0');
});
Fails: negative zero and -0.0001. The fix is signDisplay: 'exceptZero', which prints +0.5, -0.5 and a plain 0.

FAQ

Floating point numbers store a sign bit separately from the magnitude, so zero can be positive or negative. === treats them as equal, but division, Object.is, Math.atan2 and Intl formatting can tell them apart.

Use Object.is(x, -0), or x === 0 && 1 / x === -Infinity in older code.

JSON.parse("-0") returns -0, but JSON.stringify(-0) returns "0", so a round trip loses the sign.

Add zero (x + 0) or write x === 0 ? 0 : x. For display, Intl.NumberFormat with signDisplay: 'exceptZero' or 'negative' avoids printing it.

More guides