Skip to content

Commit 37d8836

Browse files
dinwwwhWadiou
andauthored
fix(client): preserve subclass prototype chain in cloneORPCError (#1801)
`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>
1 parent 722f8df commit 37d8836

2 files changed

Lines changed: 42 additions & 5 deletions

File tree

packages/client/src/error-utils.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,4 +234,34 @@ describe('cloneORPCError', () => {
234234
expect(original.data).toBe(1)
235235
expect(cloned.data).toBe(2)
236236
})
237+
238+
it('preserves custom subclass prototype chain', () => {
239+
class CustomSubclassError extends ORPCError<'BAD_REQUEST', { customField: string }> {
240+
customProp = 'test'
241+
}
242+
243+
const original = new CustomSubclassError('BAD_REQUEST', {
244+
message: 'Custom error message',
245+
data: { customField: 'value' },
246+
cause: new Error('why'),
247+
})
248+
249+
const cloned = cloneORPCError(original)
250+
251+
expect(cloned).toBeInstanceOf(CustomSubclassError)
252+
expect(cloned).toBeInstanceOf(ORPCError)
253+
expect(cloned.customProp).toBe('test')
254+
expect(cloned.message).toBe('Custom error message')
255+
expect(cloned.stack).toBe(original.stack)
256+
expect(cloned.cause).toBe(original.cause)
257+
})
258+
259+
it('clone keeps native error semantics and property shape', () => {
260+
const original = new ORPCError('BAD_REQUEST', { message: 'msg', data: 1, cause: 'why' })
261+
const cloned = cloneORPCError(original)
262+
263+
expect(Object.prototype.toString.call(cloned)).toBe('[object Error]')
264+
expect(Object.keys(cloned)).toEqual(Object.keys(original))
265+
expect({ ...cloned }).toEqual({ ...original })
266+
})
237267
})

packages/client/src/error-utils.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,17 +54,24 @@ export function createORPCErrorFromJson<TCode extends ORPCErrorCode, TData>(
5454
return error
5555
}
5656

57-
export function cloneORPCError<T extends ORPCErrorCode, TData>(error: ORPCError<T, TData>): ORPCError<T, TData> {
57+
/**
58+
* Clones an `ORPCError` while preserving its prototype chain, so instances of
59+
* `ORPCError` subclasses remain `instanceof` their class.
60+
*
61+
* Limitation: subclass constructors are not re-run, so private fields
62+
* (`#field`) are not carried over and subclass members that read them
63+
* will throw on the clone.
64+
*/
65+
export function cloneORPCError<T extends AnyORPCError>(error: T): T {
5866
const cloned = new ORPCError(error.code, {
59-
...error,
6067
message: error.message,
6168
data: error.data,
6269
cause: error.cause,
6370
})
6471

72+
Object.setPrototypeOf(cloned, Object.getPrototypeOf(error))
73+
Object.defineProperties(cloned, Object.getOwnPropertyDescriptors(error))
6574
cloned.stack = error.stack
66-
;(cloned.defined as Writable<typeof cloned.defined>) = error.defined
67-
;(cloned.inferable as Writable<typeof cloned.inferable>) = error.inferable
6875

69-
return cloned
76+
return cloned as T
7077
}

0 commit comments

Comments
 (0)