Repository navigation
fix: allow router utils and error constructor maps to be converted to primitive values - #2306
Conversation
…ted to primitive values Router utils proxies wrap a null-prototype object from bindMethods, so valueOf/toString resolved to undefined and String(utils), template literals and React 19.2 dev props-diff logging threw "Cannot convert object to primitive value". Unwrap keys now fall back to Object.prototype; then/toJSON stay undefined. Similar to the tRPC fix in trpc/trpc#7336. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UDG8EYcjqwAUxqRdkQiEbA
ApprovabilityVerdict: Approved at Macroscope's review found this PR approvable — This is a narrowly scoped compatibility fix for primitive conversion of router utilities and error-constructor maps, with focused regression tests across all affected integrations. Normal routing and error-construction paths remain unchanged. Notes:
You can add or adjust custom eligibility rules. Learn more. |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
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/experimental-lock
@orpc/mcp
@orpc/msw
@orpc/nest
@orpc/next
@orpc/node
@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: |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| 🔵 In progress View logs |
orpc | 3d8beee | Oct 23 2026, 07:08 AM |
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
- Primitive-conversion fix in all three router-utils proxies —
packages/tanstack-query/src/router-utils.ts,packages/swr/src/router-utils.ts, andpackages/pinia-colada/src/router-utils.tsnow handleRECURSIVE_CLIENT_UNWRAP_KEYSfirst in therecursiveproxygettrap, returningvalue ?? Reflect.get(Object.prototype, prop)sovalueOf/toStringresolve toObject.prototypeand coercion no longer throws. - Recursion behavior preserved —
then,toJSON,bind,call, andapplydon't exist onObject.prototype, so they stayundefined; the redundantRECURSIVE_CLIENT_UNWRAP_KEYS.has(prop)check was removed from the later condition. - Regression tests added — one
can be converted to primitive valuestest per package coveringString(...), template-literal coercion, andthen/toJSONstayingundefined.
I verified the fix and the surrounding proxy chains independently. The nested method proxy (the one built when a key is both a util method and a nested client) was left unchanged, and that's correct: its target is always a function, so toString/valueOf already resolve through Function.prototype/Object.prototype. The old condition already short-circuited unwrap keys, so no nested router/procedure key becomes newly unreachable — valueOf/toString were already shadowed before this change. pnpm vitest run packages/tanstack-query packages/swr packages/pinia-colada passes (30 files, 315 tests).
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
Merging this PR will not alter performance1 warning
Comparing Footnotes
|
…-aw8mr0 # Conflicts: # packages/tanstack-query/src/router-utils.test.ts
Dismissing prior approval to re-evaluate 5f67d3f
… primitive conversion tests The regression tests built a separate createORPCClient just to reach nested utils; the existing fixture already exercises the same proxy path (root utils and utils reached through a conflicting method proxy). Drop `then` assertions where the RECURSIVE_CLIENT_UNWRAP_KEYS test already covers them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015iL1oczPjpwbYpfCYwSFdo
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
The delta since the prior pullfrog review is test-only — the source fix in all three packages is unchanged and its behavior is identical to what was already approved. pnpm vitest run packages/tanstack-query packages/swr packages/pinia-colada passes (31 files, 357 tests).
- Switched the new
can be converted to primitive valuestests to the existingclientfixture instead of constructing an ad-hoccreateORPCClient({ call: vi.fn() }), and dropped the now-unused imports. - Retargeted the assertions to
String(utils)on the root proxy,`${utils.key.pong}`through the conflicting method proxy, andthen/toJSONstayingundefined. Both coercion assertions still exercise the fixedgettrap (the root proxy and the nestedpongrecursive proxy are null-prototype), so they still fail without the source change; thethencheck moved to the root proxy while the existingRECURSIVE_CLIENT_UNWRAP_KEYStest covers the nested case.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
Lets proxy traps check `prop` directly without first narrowing it to a string, so the router-utils unwrap check drops its `typeof` guard. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015iL1oczPjpwbYpfCYwSFdo
…ve values The error constructor map returned a constructor for every key except `then`, so `toString`/`valueOf` returned ORPCError objects and `String(errors)` threw "Cannot convert object to primitive value", while `JSON.stringify(errors)` produced a fake error with code "toJSON". It now resolves all RECURSIVE_CLIENT_UNWRAP_KEYS to the error map value, like the client proxies. Docs list these names as reserved error codes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015iL1oczPjpwbYpfCYwSFdo
Dismissing prior approval to re-evaluate 6765f26
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
The delta since the prior pullfrog review (b045a10) adds the @orpc/contract half of the fix plus a @orpc/client export type change; the previously reviewed router-utils fix is untouched and all prior review points remain addressed.
- Error-map coercion (
@orpc/contract) —createORPCErrorConstructorMap'sgettrap now resolves everyRECURSIVE_CLIENT_UNWRAP_KEYSname viaReflect.get(target, code)instead of onlythen, soString(errors)/errors.valueOfuseObject.prototypebuiltins anderrors.toJSONstaysundefinedrather than returning a fakeORPCError. RECURSIVE_CLIENT_UNWRAP_KEYStypedSet<PropertyKey>— the public export type was widened so traps can call.has(prop)onstring | symbolkeys without narrowing first; runtime values are unchanged, and the now-redundanttypeof prop === 'string'guard was dropped from the three router-utils early-return branches (behaviour for symbol keys is identical, since none are in the set).- Docs —
error-handling.mdxdocuments the reserved error-code names (then,bind,call,apply,valueOf,toString,toJSON), matching the router pages. - Tests — a
can be converted to primitive valuescase for the error map, and theObject.prototypelookup test switched fromtoStringtoconstructornow thattoStringis an unwrap key.
The reserved-name behaviour change (errors.{then,bind,call,apply,valueOf,toString,toJSON} no longer create error constructors) is intentional and called out in the PR description. Verified locally at 6765f26: vitest run over client, contract, server, effect, tanstack-query, swr and pinia-colada → 142 files / 1771 tests pass; tsc --noEmit clean; eslint on the changed files clean; node scripts/verify-docs-jsdoc.ts reports 0 errors.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
…y tests Router-utils tests keep only `String(utils)`; the other assertions passed without the fix or exercised the same trap. Fold the error map check into the existing unwrap-keys test. Remove the inline and JSDoc comments that restate what these tests cover. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015iL1oczPjpwbYpfCYwSFdo
Dismissing prior approval to re-evaluate 1b7f5a5
Only undefined RECURSIVE_CLIENT_UNWRAP_KEYS codes resolve to the error map value, so codes such as `toJSON` or `call` declared in the error map still get constructors, matching ORPCErrorConstructorMap's types. `then` stays reserved as before. Drop the docs warning that reserved all seven names as error codes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015iL1oczPjpwbYpfCYwSFdo
Dismissing prior approval to re-evaluate e149af7
…rap keys" This reverts commit e149af7. The error constructor map skips every RECURSIVE_CLIENT_UNWRAP_KEYS name again, and the docs list them as reserved error codes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015iL1oczPjpwbYpfCYwSFdo
There was a problem hiding this comment.
ℹ️ No critical issues — one optional documentation note below.
Reviewed changes
The delta since the prior pullfrog review (6765f2) narrows the error-map unwrap rule, plus the 1b7f5a5 test/comment trim.
- Declared unwrap-key error codes are constructible again —
createORPCErrorConstructorMap'sgettrap (packages/contract/src/error-factory.ts:159) now short-circuits only unwrap-key names that are not own properties of the error map, so a code such astoJSONdeclared via.errors({...})resolves to a constructor again, while undeclared names keep resolving to the plain object'sObject.prototypebuiltins /undefined.thenstays reserved unconditionally. This corrects a regression from the previous round, where a declaredtoJSONcode returned the raw map value despite being typed as callable. - Test —
packages/contract/src/error-factory.test.ts:235asserts a declaredtoJSONcode still builds a defined error and that the map stays non-thenable. - Docs — the
error-handling.mdxwarning listing all seven reserved names was removed (net-zero in the full PR diff, so the file is not in the TOC).
ℹ️ Declared reserved-name error codes still shadow primitive conversion, now undocumented
Removing the warning is correct for the six names that are usable again, but it also drops the only documentation that these names have consequences when declared. then remains non-callable, and a declared toString/valueOf re-breaks String(errors) — the very failure this PR targets.
Technical details
# Reserved-name error codes after the warning was dropped
## Affected sites
- `apps/content/docs/error-handling.mdx` — the warning listing `then, bind, call, apply, valueOf, toString, toJSON` was deleted by `e149af788`.
- `packages/contract/src/error-factory.ts:159-163` — declared unwrap-key codes now take the constructor branch; `then` never does.
## Why it matters
- Declared `then`: `errors.then` is the raw map value (not callable), though `ORPCErrorConstructorMap` types it as callable.
- Declared `toString`/`valueOf`: `String(errors)` throws `Cannot convert object to primitive value` (verified at `e149af788`).
- Declared `toJSON`: `JSON.stringify(errors)` emits a fake `ORPCError`.
## Required outcome
- Nothing blocking. If these names should stay discouraged, a short note (at minimum for `then`) would preserve the guidance the deleted warning carried.
## Open questions for the human
- Intentional that the six declared unwrap-key names stay fully usable as error codes with no docs caution?deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
Dismissing prior approval to re-evaluate 3d8beee
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
The delta since the prior pullfrog review (e149af788) is a clean revert of that commit plus the restored documentation warning; it resolves the previous round's open concern by choosing the reserve-and-document approach.
- Reverted the declared-unwrap-key special case —
createORPCErrorConstructorMap'sgettrap (packages/contract/src/error-factory.ts:159) is back totypeof code !== 'string' || RECURSIVE_CLIENT_UNWRAP_KEYS.has(code), so all seven reserved names resolve to the plain map value unconditionally and are uniformly reserved (declared or not). - Dropped the now-inapplicable test — the
still constructs defined RECURSIVE_CLIENT_UNWRAP_KEYS codes except thencase was removed along with the behavior it covered. - Restored the reserved-name warning —
apps/content/docs/error-handling.mdx:82again liststhen,bind,call,apply,valueOf,toString,toJSONas error codes to avoid, matchingrouter.mdx/contract/router.mdxand making the seven names consistently reserved across clients, router utils, and error maps.
Verified locally at 3d8beee: vitest run over packages/contract/src/error-factory.test.ts and the three router-utils.test.ts suites passes (4 files, 54 tests).
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

Problem
Two proxies could not be converted to a string.
String(x), template literals andx + ''threwTypeError: Cannot convert object to primitive value.1. Router utils (
@orpc/tanstack-query,@orpc/swr,@orpc/pinia-colada)The router-utils proxies wrap the result of
bindMethods(), which is a null-prototype object. ForRECURSIVE_CLIENT_UNWRAP_KEYSthegettrap returnedReflect.get(target, prop), which isundefinedforvalueOfandtoString:React 19.2 trigger. In React 19.2.0–19.2.9 dev builds,
logComponentRenderdiffs props and runsvalue.name + "() {}"when a prop changes identity between renders. If that prop is a utils proxy, the code reads.name(which returns another utils proxy) and concatenates it, so it throws. I reproduced this with realreact-dom@19.2.9. React 19.3 guards against it upstream, but direct coercion still throws there.This is the same class of bug as tRPC's trpc/trpc#7336.
2. Error constructor map (
@orpc/contract)createORPCErrorConstructorMap(theerrorsobject in handlers, middleware and interceptors) returned an error constructor for every key exceptthen. Soerrors.toString()anderrors.valueOf()returnedORPCErrorobjects instead of primitives:Core clients (
createORPCClient,createRouterClient) are not affected.Fix
gettrap, unwrap keys are handled first and returnvalue ?? Reflect.get(Object.prototype, prop).valueOfandtoStringfall back toObject.prototype;then,toJSON,bindandapplystayundefined; the utils' owncallis kept. The null prototype frombindMethodsis left as is. It is deliberate: it lets procedures namedtoString,constructoror__proto__resolve to nested utils.RECURSIVE_CLIENT_UNWRAP_KEYSname to the error map value, like the other oRPC proxies, instead of onlythen. Error maps are plain objects, sotoStringandvalueOfare the normal built-ins.@orpc/client:RECURSIVE_CLIENT_UNWRAP_KEYSis now typedSet<PropertyKey>instead ofSet<string>, so proxy traps can call.has(prop)without narrowingpropfirst. Runtime values are unchanged. This is a public export; code that reads values from the set asstringwould need a cast.error-handling.mdxnow lists the reserved names as error codes to avoid, matching the existing warning on the router pages.Behavior change
errors.then,errors.bind,errors.call,errors.apply,errors.valueOf,errors.toStringanderrors.toJSONno longer return error constructors. An error code with one of these names can no longer be created through theerrorsmap. Before this PR onlythenwas reserved.The existing contract test "does not resolve error codes through Object.prototype" used
toStringas its example of anObject.prototypename. It now usesconstructor, so it still checks that thegetOwnconfig lookup ignores the prototype.Known limitation, left alone on purpose
utils.x.queryOptions.name(andfetcher.namein SWR) is still a utils proxy, not a string. The method proxy forwards non-unwrap keys to the nested utils so a nested procedure callednamekeeps working. Making.namereturn the function name would break that. It can now be coerced to a string without throwing, which is enough for the React 19.2 code path.Tests
can be converted to primitive valuestest in each package checksString(utils)on the existingclientfixture.not proxy on symbol and RECURSIVE_CLIENT_UNWRAP_KEYS) now also checks thattoJSONisundefinedand thatString(map)works.Verification
pnpm vitest runonclient,contract,server,effect,tanstack-query,swrandpinia-colada: passespnpm type:check: passespnpm lint: passespnpm docs:validate: passes🤖 Generated with Claude Code
https://claude.ai/code/session_015iL1oczPjpwbYpfCYwSFdo