fix(client): preserve subclass prototype chain in cloneORPCError - #1799
fix(client): preserve subclass prototype chain in cloneORPCError#1799Wadiou wants to merge 1 commit into
Conversation
`cloneORPCError` previously hardcoded `new ORPCError(error.code, ...)`, which stripped custom subclass prototypes from cloned error instances. During error reconciliation, this caused `error instanceof CustomSubclass` checks to fail downstream. Update `cloneORPCError` to use `Object.create(Object.getPrototypeOf(error))` to preserve the prototype chain, while explicitly copying `.message`, `.stack`, and `.cause` to ensure non-enumerable Error properties and cause chains are retained cleanly.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
cloneORPCErrorclones viaObject.create(Object.getPrototypeOf(error))+Object.assign– preserves the subclass prototype chain instead of always producing a baseORPCError, withmessage/stack/cause(non-enumerable onErrorinstances) copied explicitly.- Signature generalized to
<T extends AnyORPCError>(error: T): T– the returned type now reflects the actual (possibly subclassed) error. - New unit test asserting
instanceof CustomSubclassError,instanceof ORPCError, and custom field/message preservation.
The change is sound: code, data, name, and subclass fields are own enumerable properties so Object.assign captures them; the explicitly-copied message/stack/cause cover the non-enumerable Error props. Since the clone keeps the original object's prototype, all three instanceof paths hold — including the cross-context WeakSet walk via getConstructors — and because the prototype is inherited from the source instance itself, it's actually more robust than the prior new ORPCError(...) approach. All three callers (packages/contract/src/error-utils.ts, packages/server/src/procedure-client.ts:277, packages/json-schema/src/smart-coercion-link-plugin.ts:77) only mutate data/defined/inferable after cloning, which this implementation preserves. The new test genuinely fails against the old code, so it pins the regression. Verified locally: all 23 client + 19 contract error-utils tests pass.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
More templates
@orpc/ai-sdk
@orpc/arktype
@orpc/bun
@orpc/client
@orpc/cloudflare
@orpc/contract
@orpc/experimental-effect
@orpc/evlog
@orpc/hibernation
@orpc/json-schema
@orpc/nest
@orpc/next
@orpc/openapi
@orpc/opentelemetry
@orpc/pinia-colada
@orpc/pino
@orpc/publisher
@orpc/ratelimit
@orpc/server
@orpc/shared
@orpc/swr
@orpc/tanstack-query
@orpc/trpc
@orpc/valibot
@orpc/zod
commit: |
dinwwwh
left a comment
There was a problem hiding this comment.
Thanks for the fix! The goal is legitimate and the approach is close. I verified locally: all 23 tests pass, repo-wide tsc is clean, and instanceof Error / instanceof ORPCError / instanceof CustomSubclassError all hold.
There is one regression to address before merging.
The clone is no longer a real Error
Object.create skips the Error constructor, so the clone lacks the [[ErrorData]] internal slot, and the manually assigned message / stack / cause become own enumerable properties (on real errors they are non-enumerable). Confirmed with the PR applied:
util.types.isNativeError(cloned)→false,Object.prototype.toString.call(cloned)→[object Object]Object.keys(original)→[name, defined, inferable, code, data], butObject.keys(cloned)additionally containsmessage,stack,cause
Practical impact:
- Cloning happens automatically on the server path (
reconcileORPCError,procedure-client.ts), so every reconciled error users catch has this shape. User code doing{ ...error }or iterating keys now picks up the stack trace andcause, which it did not before. oRPC's own wire serialization goes throughtoJSON()and is unaffected. structuredClone/postMessageof a cloned error no longer round-trips as anError, which matters for message-port/worker transports.
Suggested fix
Construct a real error first, then swap the prototype. This keeps the subclass prototype chain, native-error semantics, and copies every own property (including custom subclass fields and non-enumerable stack) with its exact descriptor:
export function cloneORPCError<T extends AnyORPCError>(error: T): T {
const cloned = new ORPCError(error.code, {
message: error.message,
data: error.data,
cause: error.cause,
})
Object.setPrototypeOf(cloned, Object.getPrototypeOf(error))
Object.defineProperties(cloned, Object.getOwnPropertyDescriptors(error))
return cloned as T
}This also makes the explicit message / stack / cause / defined / inferable assignments unnecessary.
Minor
- Subclass private fields (
#field) still throw on the clone since the subclass constructor never runs. This is inherent to any constructor-skipping clone (the suggestion above included); a short JSDoc note would help since(error: T): Tpromises full fidelity. - The explicit
defined/inferablereassignments are redundant withObject.assignin the current version. - Test suggestion: assert
Object.keys(cloned)matchesObject.keys(original), and thatstackandcauseare preserved. That would have caught the enumerability issue.
`cloneORPCError` rebuilt errors with `new ORPCError(...)`, which stripped custom subclass prototypes, so `error instanceof CustomError` failed after error reconciliation. The clone is now constructed as a real `ORPCError` whose prototype is swapped to the original's, so subclass instances stay `instanceof` their class while the clone remains a native `Error`. Supersedes #1799, thanks @Wadiou for the report and the initial approach. ## Fixes - Cloned errors are `instanceof` their subclass, and custom properties are copied with their exact descriptors. - Unlike the `Object.create` approach in #1799, the clone keeps native `Error` semantics: `message` / `stack` / `cause` stay non-enumerable, so `{ ...error }` and key iteration do not expose stack traces, and `structuredClone` / `postMessage` still round-trip. - Signature widened to `<T extends AnyORPCError>(error: T): T`, backward compatible. ## Testing - New tests cover subclass prototype preservation and clone shape / native-error semantics; the shape test fails under the #1799 approach. - All client, contract, server, and json-schema tests pass (1157), `tsc` clean. --------- Co-authored-by: Wadoud <wadiouyt@gmail.com>

fix(client): preserve subclass prototype chain in
cloneORPCErrorProblem
cloneORPCErrorinstantiatesnew ORPCError(error.code, ...)directly, stripping custom subclass prototypes (e.g.class CustomError extends ORPCError). This causeserror instanceof CustomErrorchecks to fail downstream during procedure error reconciliation.Solution
Object.create(Object.getPrototypeOf(error))to preserve the subclass prototype chain..message,.stack, and.causeto preserve non-enumerable error properties.cloneORPCErrorsignature to<T extends AnyORPCError>(error: T): T.Tests
packages/client/src/error-utils.test.tsverifying subclass prototype preservation and custom property copying. All 23 tests pass.