breakhound StarGitHub stars

Guide

Negative zero in JavaScript: the bugs it causes and how to catch them

JavaScript has two zeros, and the difference between them is invisible to ===, String() and JSON. Here is how -0 breaks number formatting, how the pdf.js bug happened, and how to find it without writing a single test.

By Alexandre KohlerUpdated 3 min read

JavaScript has two zeros

IEEE 754 floating point numbers carry a sign bit, so 0 and -0 are different values that compare as equal. -0 === 0 is true, and String(-0), template literals and JSON.stringify(-0) all produce "0". The sign is only observable through a few doors: Object.is(-0, 0) is false, 1 / -0 is -Infinity, and Math.sign(-0) returns -0.

Negative zero is not exotic. It appears whenever a negative number is rounded or truncated toward zero: Math.trunc(-0.5), Math.round(-0.4), Math.ceil(-0.2) and -1 * 0 all return -0. Any library that does arithmetic on user supplied numbers will meet it eventually.

Where -0 actually bites

Because -0 prints as "0", the bug is rarely the zero itself. It is the sign information that disappears before the zero is created. Three places cause most real reports:

  • Math.trunc and friends. Splitting a number into whole and fractional parts with Math.trunc turns every value in the open interval (-1, 0) into -0 for the whole part. Once you stringify that part, the minus sign is gone.
  • toFixed. (-0.4).toFixed(0) returns "-0", but (-0).toFixed(0) returns "0". Two inputs that look equivalent produce different strings, and code that post-processes the result (stripping "-0", parsing it back) behaves inconsistently.
  • Equality checks. Object.is treats the two zeros as different, while === does not. Test assertions such as expect(x).toBe(0) fail on -0 because toBe uses Object.is.

The naive code that loses the sign

A fixed point formatter is a classic example. It splits the number, formats the fraction, and glues the parts back together. Every positive input and every number at or below -1 works, which is why this passes code review and hand written tests.

format.ts
export function formatFixed(n: number): string {
  const whole = Math.trunc(n);
  const frac = Math.round(Math.abs(n - whole) * 100);
  return `${whole}.${String(frac).padStart(2, '0')}`;
}

formatFixed(-1.5); // '-1.50'
formatFixed(-0.5); // '0.50'  (the minus sign is gone)

For -0.5, Math.trunc returns -0, the template literal prints it as 0, and a negative balance, temperature or delta is displayed as a positive one. The output is well formed and no exception is thrown, so nothing in your logs will point at it.

A real case: pdf.js pull request 22017

This is not hypothetical. Fix mozilla/pdf.js#22017 corrected a code path where negative values above -1 lost their sign, the same shape of bug as the formatter above. Bugs like this survive for years because the failing inputs sit in a narrow band of the number line that people rarely type into a test. A generator that deliberately tries values between -1 and 0 finds them in seconds.

The fix

Decide the sign explicitly, from the original input, before you do anything that can round it away. Work with the absolute value and put the sign back at the end:

format.ts
export function formatFixed(n: number): string {
  const sign = n < 0 ? '-' : '';
  const abs = Math.abs(n);
  const whole = Math.trunc(abs);
  const frac = Math.round((abs - whole) * 100);
  return `${sign}${whole}.${String(frac).padStart(2, '0')}`;
}

When you really need to distinguish the zeros, use Object.is(x, -0), not x === -0. When you want to normalize them away, x + 0 turns -0 into 0. Decide which behavior your API promises and write it down.

Rule of thumb: never derive the sign of a result from a value that has already been truncated or rounded. Read it from the input.

Catching it automatically with breakhound

The negative-zero-sign oracle looks for functions where a value between -1 and 0 loses its minus sign in the output. The input generator already includes -0 and other edge cases from the edgegoat package, so you do not write anything. See how breakhound finds bugs automatically for the overall approach.

Terminal
npx breakhound ./my-lib --rules negative-zero-sign --plain
Terminal output
MEDIUM (1)
  [negative-zero-sign] formatFixed drops the minus sign of numbers between -1 and 0 (confidence medium)
    formatFixed(-0.5)  -> '0.50'
    formatFixed(0.5)  -> '0.50'
    formatFixed(-1.5)  -> '-1.50'

Every finding comes with a minimal reproduction script and a ready to paste Vitest test, so the regression test for the fix already exists:

format.test.ts
import { expect, test } from 'vitest';
import { formatFixed } from 'buggy-lib';

test("formatFixed keeps the sign of -0.5", () => {
  expect(formatFixed(-0.5)).not.toBe(formatFixed(0.5));
});

To keep it from coming back, run the same command on every pull request. The GitHub Actions guide shows the workflow, and roundtrip testing covers a related class of silent data loss. Go back to the home page for the full list of oracles.

FAQ

Is -0 === 0 true in JavaScript?

Yes. Strict and loose equality both treat the two zeros as equal. Use Object.is(x, -0) when you must tell them apart.

Why does Math.trunc(-0.5) return -0?

Truncation keeps the sign of the input, and the result has magnitude zero, so it is negative zero. Printing it gives "0", which is how the minus sign gets lost.

Does toFixed keep the sign of small negative numbers?

It depends on the value. (-0.4).toFixed(0) returns "-0", while (-0).toFixed(0) returns "0". Do not assume the two behave the same.

How do I convert -0 to 0?

Add zero: -0 + 0 is 0. Adding opposite signed zeros gives positive zero under round to nearest.

Can breakhound find negative zero bugs in code without tests?

Yes. It reads TypeScript types (.d.ts) to decide what to generate for each parameter, then runs the negative-zero-sign oracle. Point it at a package or a local path with npx breakhound ./my-lib.