Repository navigation
fix: don't synthesize a cause on Errors that never had one - #354
Conversation
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.
| }); | ||
|
|
||
| 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'); | ||
| }); |
There was a problem hiding this 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.
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.There was a problem hiding this comment.
This is SuperJSON, not plain JSON! It can do it ;D
There was a problem hiding this comment.
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.
Description
A round trip through SuperJSON adds an own
causeproperty to anyErrorthat didn't have one.The serializer only emits a
causewhen the source error actually has the property (transformer.ts):…but the deserializer passes the options object unconditionally:
Per the spec, the
Errorconstructor installs an owncauseproperty whenever the options object has acausekey (InstallErrorCause), and{ cause: undefined }does have that key. So an error serialized without a cause is revived with an owncause: undefinedproperty it never had:Because the revived error now owns a
cause, re-serializing it emits one too, sostringifyis not idempotent across a round trip. This matters wherever errors are round-tripped (e.g. tRPC), and it changesObject.keys(err)/ spread behaviour.The fix mirrors the serializer's
'cause' in vguard so the options object is only passed when a cause was actually serialized — preserving real causes (including an explicitcause: 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 owncause: undefinedproperty;Object.hasOwn(roundTripped, 'cause')wastrue.After
cause(Object.hasOwnstaysfalse).Added two cases under
describe('Error without a cause', …)insrc/index.test.ts(the first fails onmain, 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
Errorthrough SuperJSON synthesized a spurious owncause: undefinedproperty that was never on the original error. The root cause was that the deserializer unconditionally passed{ cause: v.cause }to theErrorconstructor, and per specInstallErrorCauseinstalls the property whenever the options object contains acausekey — even if the value isundefined.src/transformer.ts: adds a'cause' in vguard to the deserializer, exactly mirroring the existing guard in the serializer, so the options object is only passed when the serialized payload actually contains acausekey.src/index.test.ts: adds two regression tests — one verifying that a cause-less error gains no owncauseafter 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: undefinednot 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
'cause' in vguard to the Error deserializer, mirroring the existing guard in the serializer to prevent spurious own-cause synthesis on round-trip.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"%%{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"Prompt To Fix All With AI
Reviews (1): Last reviewed commit: "fix: don't synthesize a cause on Errors ..." | Re-trigger Greptile