Skip to content

fix: preserve negative zero in Float32Array and Float64Array - #356

Open
mahirhir wants to merge 1 commit into
ravionhq:mainfrom
mahirhir:fix-typed-array-negative-zero
Open

mahirhir wants to merge 1 commit into
ravionhq:mainfrom
mahirhir:fix-typed-array-negative-zero

Conversation

@mahirhir

@mahirhir mahirhir commented Jun 29, 2026 •

Copy link
Copy Markdown

stringify/parse drops the sign of negative zero when it is stored in a Float32Array or Float64Array:

const sj = new SuperJSON();
const back = sj.parse(sj.stringify(new Float64Array([-0])));
Object.is(back[0], -0); // false, but it should be true

The typed-array transformer already encodes the special float values that a JSON round-trip cannot represent: NaN, Infinity and -Infinity are written out as strings (#292, #349). -0 is the one that was left out. JSON.stringify(-0) returns "0", so the sign is gone as soon as the serialized payload passes through JSON.

superjson preserves -0 everywhere else (the dedicated number rule from regression #83), and the object-only serialize/deserialize path kept it too because it passes the raw value through untouched. Only the string path lost it, and only for the two float-backed typed arrays, since integer and clamped arrays cannot hold -0.

This stores -0 as "-0" and reads it back, the same way NaN/Infinity are handled in that function. I added a regression test next to the existing #292 case.

Greptile Summary

This PR fixes a bug where -0 stored in Float32Array or Float64Array was silently dropped during JSON serialization, coming back as +0 after a stringify/parse round-trip. It extends the existing special-float-value encoding in typedArrayRule to treat -0 the same way NaN, Infinity, and -Infinity are already handled.

  • src/transformer.ts: Adds a -0 detection check (n === 0 && 1 / n === -Infinity) in the serializer and a corresponding '-0' → -0 conversion in the deserializer, following the exact pattern already used for the other IEEE 754 special values.
  • src/index.test.ts: Adds a regression test that verifies round-trip fidelity for -0 entries in both Float64Array and Float32Array, and also confirms positive 0 is not incorrectly converted.

Confidence Score: 5/5

Safe to merge — the change is minimal, isolated to the typed-array transformer, and follows the existing pattern exactly.

Both the serializer and deserializer changes are a single-line addition each, directly mirroring the already-proven NaN/Infinity handling. The negative-zero detection idiom (n === 0 && 1 / n === -Infinity) is correct and widely used in JS. The new test covers mixed arrays with both -0 and +0 to prevent false positives, and integer typed arrays are unaffected since they can never hold -0 in the first place.

No files require special attention.

Important Files Changed

Filename Overview
src/transformer.ts Adds negative-zero serialization ('-0' string) and deserialization in the typed-array transformer rule, consistent with the existing NaN/Infinity handling pattern. Logic is correct and the reciprocal-infinity idiom for detecting -0 is a well-established JavaScript technique.
src/index.test.ts New regression test covers Float64Array and Float32Array with mixed negative-zero, positive-zero, and normal values; uses Object.is for correct -0 identity checks and verifies positive zero is not falsely promoted to -0.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant App
    participant SuperJSON
    participant typedArrayRule
    participant JSON

    App->>SuperJSON: stringify(new Float64Array([-0, 0, 1]))
    SuperJSON->>typedArrayRule: serialize(Float64Array)
    typedArrayRule->>typedArrayRule: map each element
    Note over typedArrayRule: -0 → '-0' (new)<br/>0 → 0<br/>1 → 1
    typedArrayRule-->>SuperJSON: ['-0', 0, 1] + annotation
    SuperJSON->>JSON: JSON.stringify(['-0', 0, 1])
    JSON-->>App: "'{"json":{"a":["-0",0,1]},...}'"

    App->>SuperJSON: "parse('{"json":{"a":["-0",0,1]},...}')"
    SuperJSON->>JSON: JSON.parse(...)
    JSON-->>SuperJSON: "{a: ['-0', 0, 1]}"
    SuperJSON->>typedArrayRule: deserialize(['-0', 0, 1], 'Float64Array')
    typedArrayRule->>typedArrayRule: map each element
    Note over typedArrayRule: '-0' → -0 (new)<br/>0 → 0<br/>1 → 1
    typedArrayRule-->>SuperJSON: new Float64Array([-0, 0, 1])
    SuperJSON-->>App: Float64Array [-0, 0, 1]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant App
    participant SuperJSON
    participant typedArrayRule
    participant JSON

    App->>SuperJSON: stringify(new Float64Array([-0, 0, 1]))
    SuperJSON->>typedArrayRule: serialize(Float64Array)
    typedArrayRule->>typedArrayRule: map each element
    Note over typedArrayRule: -0 → '-0' (new)<br/>0 → 0<br/>1 → 1
    typedArrayRule-->>SuperJSON: ['-0', 0, 1] + annotation
    SuperJSON->>JSON: JSON.stringify(['-0', 0, 1])
    JSON-->>App: "'{"json":{"a":["-0",0,1]},...}'"

    App->>SuperJSON: "parse('{"json":{"a":["-0",0,1]},...}')"
    SuperJSON->>JSON: JSON.parse(...)
    JSON-->>SuperJSON: "{a: ['-0', 0, 1]}"
    SuperJSON->>typedArrayRule: deserialize(['-0', 0, 1], 'Float64Array')
    typedArrayRule->>typedArrayRule: map each element
    Note over typedArrayRule: '-0' → -0 (new)<br/>0 → 0<br/>1 → 1
    typedArrayRule-->>SuperJSON: new Float64Array([-0, 0, 1])
    SuperJSON-->>App: Float64Array [-0, 0, 1]
Loading

Reviews (1): Last reviewed commit: "fix: preserve negative zero in Float32Ar..." | Re-trigger Greptile

@mahirhir
mahirhir requested a review from Skn0tt as a code owner June 29, 2026 22:51
@mahirhir

Copy link
Copy Markdown
Author

No review on this since June. Here it is measured, and the measurement turned up something the PR title does not cover, so I would rather put both in front of you.

On main, the same file already makes this decision twice, and the two disagree by one case.

A plain number gets a dedicated transformer:

// src/transformer.ts:158
simpleTransformation<number, '-0', 'number'>(
  (v): v is number => v === 0 && 1 / v === -Infinity,
  'number',
  () => '-0',
  Number
),

The typed-array rule, ninety lines further down, handles the other three specials and not this one:

// src/transformer.ts:248
if (typeof n === 'number') {
  if (Number.isNaN(n)) return 'NaN';
  if (n === Infinity) return 'Infinity';
  if (n === -Infinity) return '-Infinity';
}
return n;

The predicate this PR adds is the same expression already used at line 159, so the change is the existing decision applied to the sibling path rather than a new idea.

On the published release, the gap is wider than that. Running superjson@2.2.6:

stringify(new Float64Array([NaN, Infinity, -0, 1.5]))
  {"json":[null,null,0,1.5],"meta":{"values":[["typed-array","Float64Array"]],"v":1}}
  parse  -> Float64Array [0, 0, 0, 1.5]

                    plain object   inside a Float64Array
  NaN               survives       lost
  Infinity          survives       lost
  -0                survives       lost
  1.5               survives       survives

The type annotation is preserved and a Float64Array does come back, which is what makes this quiet: the shape is right and three of the four values are not. The NaN and Infinity half of that is fixed on main already and has not been released, so 2.2.6 is worse than the code is. Only -0 is still open, which is what this PR addresses.

I checked my own control before believing any of the above. My first run reported that NaN was lost too, which I nearly filed as a second bug; the control asking whether the plain-object path preserves the same values is what separated "the release is behind main" from "my harness is broken".

The test I added asserts with Object.is, since -0 === 0 is true and an ordinary equality assertion passes whether or not the sign survives. It also pins 0 staying 0 in the same array, so the change cannot pass by turning every zero negative.

One thing I am not claiming:

$ gh pr checks 356 --repo ravionhq/superjson
no checks reported on the 'fix-typed-array-negative-zero' branch

Nothing runs on this branch, so the tables above are mine, produced locally against the published package rather than by your CI.

Happy to rebase, or to take the three-line change directly and close this. No attribution needed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant