Skip to content

fix: don't synthesize a cause on Errors that never had one - #354

Merged
Skn0tt merged 1 commit into
ravionhq:mainfrom
spokodev:fix/error-roundtrip-spurious-cause
Jun 18, 2026
Merged

Skn0tt merged 1 commit into
ravionhq:mainfrom
spokodev:fix/error-roundtrip-spurious-cause

Conversation

@spokodev

@spokodev spokodev commented Jun 17, 2026 •

Copy link
Copy Markdown
Contributor

Description

A round trip through SuperJSON adds an own cause property to any Error that didn't have one.

The serializer only emits a cause when the source error actually has the property (transformer.ts):

if ('cause' in v) {
  baseError.cause = v.cause;
}

…but the deserializer passes the options object unconditionally:

const e = new Error(v.message, { cause: v.cause });

Per the spec, the Error constructor installs an own cause property whenever the options object has a cause key (InstallErrorCause), and { cause: undefined } does have that key. So an error serialized without a cause is revived with an own cause: undefined property it never had:

const e = new Error('boom');
Object.hasOwn(e, 'cause'); // false

const r = SuperJSON.deserialize(SuperJSON.serialize(e));
Object.hasOwn(r, 'cause'); // true  ← synthesized
r.cause;                   // undefined

Because the revived error now owns a cause, re-serializing it emits one too, so stringify is not idempotent across a round trip. This matters wherever errors are round-tripped (e.g. tRPC), and it changes Object.keys(err) / spread behaviour.

The fix mirrors the serializer's 'cause' in v guard so the options object is only passed when a cause was actually serialized — preserving real causes (including an explicit cause: undefined) and leaving cause-less errors untouched.

Closes the round-trip asymmetry; no public API change.

Test plan

Before

  • new Error('x') round-tripped to an error with an own cause: undefined property; Object.hasOwn(roundTripped, 'cause') was true.

After

  • A cause-less error round-trips without gaining a cause (Object.hasOwn stays false).
  • An error with an explicit cause still round-trips with the cause intact.

Added two cases under describe('Error without a cause', …) in src/index.test.ts (the first fails on main, both pass after the fix). Full suite green (npm test, 85 passing).

Greptile Summary

This PR fixes an idempotency bug where round-tripping a cause-less Error through SuperJSON synthesized a spurious own cause: undefined property that was never on the original error. The root cause was that the deserializer unconditionally passed { cause: v.cause } to the Error constructor, and per spec InstallErrorCause installs the property whenever the options object contains a cause key — even if the value is undefined.

  • src/transformer.ts: adds a 'cause' in v guard to the deserializer, exactly mirroring the existing guard in the serializer, so the options object is only passed when the serialized payload actually contains a cause key.
  • src/index.test.ts: adds two regression tests — one verifying that a cause-less error gains no own cause after a round-trip, and one verifying that an error with an explicit cause still round-trips correctly.

Confidence Score: 5/5

Safe to merge; the change is minimal, well-targeted, and fully covered by new tests.

The fix is a one-liner guard that mirrors an identical guard already present in the serializer. Both changed files are small, the logic is straightforward, and the new tests confirm the corrected and previously-correct behaviors. The only open question is around cause: undefined not being round-trippable through JSON, but that is a pre-existing, inherent JSON limitation rather than anything introduced here.

No files require special attention; both changes are self-contained and easy to verify.

Important Files Changed

Filename Overview
src/transformer.ts Added 'cause' in v guard to the Error deserializer, mirroring the existing guard in the serializer to prevent spurious own-cause synthesis on round-trip.
src/index.test.ts Adds two regression tests covering the no-cause and explicit-cause round-trip cases; the first test fails on main and passes after the fix.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant App
    participant Serializer as transformer.ts (serialize)
    participant JSON
    participant Deserializer as transformer.ts (deserialize)

    App->>Serializer: Error("msg") [no cause]
    Serializer->>Serializer: 'cause' in v? → false, omit cause
    Serializer->>JSON: "{ name, message } (no cause key)"
    JSON-->>Deserializer: "{ name, message }"
    Deserializer->>Deserializer: 'cause' in v? → false
    Deserializer-->>App: new Error(v.message) ✅ no own cause

    App->>Serializer: "Error("msg", { cause: "x" })"
    Serializer->>Serializer: 'cause' in v? → true, emit cause
    Serializer->>JSON: "{ name, message, cause: "x" }"
    JSON-->>Deserializer: "{ name, message, cause: "x" }"
    Deserializer->>Deserializer: 'cause' in v? → true
    Deserializer-->>App: "new Error(v.message, { cause: "x" }) ✅ cause preserved"
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 Serializer as transformer.ts (serialize)
    participant JSON
    participant Deserializer as transformer.ts (deserialize)

    App->>Serializer: Error("msg") [no cause]
    Serializer->>Serializer: 'cause' in v? → false, omit cause
    Serializer->>JSON: "{ name, message } (no cause key)"
    JSON-->>Deserializer: "{ name, message }"
    Deserializer->>Deserializer: 'cause' in v? → false
    Deserializer-->>App: new Error(v.message) ✅ no own cause

    App->>Serializer: "Error("msg", { cause: "x" })"
    Serializer->>Serializer: 'cause' in v? → true, emit cause
    Serializer->>JSON: "{ name, message, cause: "x" }"
    JSON-->>Deserializer: "{ name, message, cause: "x" }"
    Deserializer->>Deserializer: 'cause' in v? → true
    Deserializer-->>App: "new Error(v.message, { cause: "x" }) ✅ cause preserved"
Loading
Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
src/index.test.ts:1046-1056
**Missing test for `cause: undefined` round-trip**

The PR description states the fix "preserves real causes (including an explicit `cause: undefined`)," but this case isn't tested — and it actually cannot survive a round-trip. `JSON.stringify` silently drops object properties whose value is `undefined`, so the `cause` key is lost in the serialized payload. The deserializer sees no `cause` key and produces an error without an own `cause`. A test case for `new Error('msg', { cause: undefined })` would document this limitation and prevent future confusion about whether the behavior is intentional.

Reviews (1): Last reviewed commit: "fix: don't synthesize a cause on Errors ..." | Re-trigger Greptile

Greptile also left 1 inline comment on this PR.

The Error deserializer always called `new Error(message, { cause: v.cause })`.
Per the spec, the Error constructor installs an own `cause` property whenever
the options object *has* a `cause` key — and `{ cause: undefined }` does — so an
Error that was serialized without a cause came back with an own
`cause: undefined` property it never had. That flips
`Object.hasOwn(err, 'cause')` from false to true on every round trip and breaks
`stringify`/`parse` idempotence (the re-serialized payload now carries the
synthesized cause).

The serializer already guards with `'cause' in v`; mirror it on the
deserializer so the options object is only passed when a cause was present.
@spokodev
spokodev requested a review from Skn0tt as a code owner June 17, 2026 12:07
Comment thread src/index.test.ts
Comment on lines +1046 to +1056
});

it('preserves an explicit cause', () => {
const original = new Error('outer', { cause: 'inner reason' });
expect(Object.hasOwn(original, 'cause')).toBe(true);

const roundTripped: any = SuperJSON.parse(SuperJSON.stringify(original));

expect(Object.hasOwn(roundTripped, 'cause')).toBe(true);
expect(roundTripped.cause).toBe('inner reason');
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Missing test for cause: undefined round-trip

The PR description states the fix "preserves real causes (including an explicit cause: undefined)," but this case isn't tested — and it actually cannot survive a round-trip. JSON.stringify silently drops object properties whose value is undefined, so the cause key is lost in the serialized payload. The deserializer sees no cause key and produces an error without an own cause. A test case for new Error('msg', { cause: undefined }) would document this limitation and prevent future confusion about whether the behavior is intentional.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/index.test.ts
Line: 1046-1056

Comment:
**Missing test for `cause: undefined` round-trip**

The PR description states the fix "preserves real causes (including an explicit `cause: undefined`)," but this case isn't tested — and it actually cannot survive a round-trip. `JSON.stringify` silently drops object properties whose value is `undefined`, so the `cause` key is lost in the serialized payload. The deserializer sees no `cause` key and produces an error without an own `cause`. A test case for `new Error('msg', { cause: undefined })` would document this limitation and prevent future confusion about whether the behavior is intentional.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is SuperJSON, not plain JSON! It can do it ;D

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, my mistake! SuperJSON handles undefined via its annotations layer, so { cause: undefined } does survive a round-trip intact. The concern about JSON.stringify silently dropping undefined doesn't apply here.

That said, a test for new Error('msg', { cause: undefined }) would still be valuable — not to document a limitation, but to verify that the fix correctly preserves an explicit cause: undefined as an own property after round-tripping through SuperJSON.

@Skn0tt
Skn0tt merged commit faf164b into ravionhq:main Jun 18, 2026
4 checks passed
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.

2 participants