fix(json-schema): safer value coercion and smarter union matching#1729
Conversation
Coerce only unambiguous, lossless values: plain numeric strings within the safe integer range, strict ISO 8601 dates (now with UTC offsets and expanded years), and integer strings for bigints. Rank union branches so unknown keys no longer disqualify a discriminated union branch, stop $ref cycles, keep __proto__ as an own property, and skip invalid patternProperties regexes instead of throwing. Harden the shared get helper so paths cannot reach prototype members, and index routers/contracts directly at call sites that no longer need it. Rewrite coercer tests around real-world payloads with full coverage, including invalid schema edge cases.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
More templates
@orpc/ai-sdk
@orpc/arktype
@orpc/bun
@orpc/client
@orpc/cloudflare
@orpc/contract
@orpc/experimental-effect
@orpc/evlog
@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: |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
ℹ️ One rough edge worth flagging — everything else looks solid.
Reviewed changes — tightens the JSON Schema coercer with safe-lossless conversion rules, 3-level satisfaction for union matching, $ref cycle detection, and prototype-pollution hardening. Migrates internal get() callers off prototype traversal and rewrites tests around real-world converter output shapes.
- Safer number/bigint/date coercion — strict regex patterns reject scientific notation, hex, leading zeros, and ambiguous date formats; safe-integer boundary checks prevent digit loss on large numbers.
- Smarter union matching with 3-level satisfaction —
LOOSELY_SATISFIEDlets branches match when some keys are unrecognized; untouched-value preference avoids unnecessary conversions; discriminator branches tolerate extra keys. $refcycle detection —appliedRefsset tracks already-resolved schemas to prevent infinite recursion on self-referencing or circular$refchains.- Prototype-pollution hardening —
__proto__andconstructorare treated as own properties in object coercion;$refresolution usesObject.hasOwnto avoid prototype traversal;get()in@orpc/sharednow skips inherited properties. - Graceful degradation — invalid
patternPropertiesregexes are skipped instead of throwing; unresolvable$refs, unknown types, and empty unions/enums pass through unchanged. - Documentation updated —
smart-coercion.mdreflects the new conversion rules; pointer references inserver-side-client.mduse direct bracket access.
ℹ️ get() in @orpc/shared is a technically breaking change
The Object.hasOwn guard now skips values inherited from prototypes. For external consumers passing plain objects this is a pure security improvement, but anyone using get() on class instances with inherited properties will see those properties suddenly resolve to undefined.
All internal call sites have been migrated to direct bracket access or audited as plain-object-only. The PR body mentions this change underneath the get bullet point but does not carry a BREAKING CHANGE: footer.
Technical details
# `get()` prototype-dependency breaking change
## Affected sites
- `packages/shared/src/object.ts:62` — `!Object.hasOwn(current, key)` added to the loop guard
## Required outcome
- If this is a deliberate breaking change, add `BREAKING CHANGE: get() no longer traverses prototype properties` to the commit/PR body so dependents know to audit their usage.
- If this is not intended as a breaking change, the function could be documented as "own-properties-only" with a deprecation for prototype-dependent uses.
## Suggested approach (optional)
The simplest path is to note it in the changelog. The change is well-motivated (security) and the affected surface is small (the only `get()` callers that walked non-plain-object paths were the router-utils and nest implement files, now migrated).
## Open questions for the human (optional)
- Should `@orpc/shared` bump a major version for this? The previous behavior let `get({}, ['toString'])` return `Object.prototype.toString`, which is arguably a bug.DeepSeek Pro (free via Pullfrog for OSS) | 𝕏
There was a problem hiding this comment.
Pull request overview
This PR hardens JSON Schema–driven “smart coercion” across oRPC by making conversions more conservative (aiming for unambiguous/lossless results), improving union-branch selection, and adding several robustness protections against malformed schemas and hostile payloads (e.g. $ref cycles, prototype-related edge cases). It also updates a shared get() helper to ignore prototype members and adjusts a few call sites/docs accordingly.
Changes:
- Update
@orpc/shared/getto returnundefinedfor prototype members and add tests for prototype-key paths. - Refine
JsonSchemaCoercerunion matching and schema/payload hardening (cycle detection, safer object/property handling, invalidpatternPropertiestolerance) with an expanded test suite. - Replace some internal uses of
get(obj, [key])with direct indexing in router/contract utilities and update docs to match new coercion rules.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/tanstack-query/src/router-utils.ts | Switches nested-client access from get() to direct indexing in recursive utils. |
| packages/swr/src/router-utils.ts | Removes get() import and uses direct indexing for nested-client traversal. |
| packages/shared/src/object.ts | Makes get() treat prototype members as missing via Object.hasOwn. |
| packages/shared/src/object.test.ts | Adds coverage asserting prototype keys (__proto__, constructor, etc.) return undefined. |
| packages/pinia-colada/src/router-utils.ts | Switches nested-client access from get() to direct indexing in recursive utils. |
| packages/nest/src/implement.ts | Replaces get(contract,[key]) with contract[key] in decorator recursion. |
| packages/json-schema/src/coercer.ts | Introduces satisfaction scoring for unions, adds $ref cycle protection, tightens coercion helpers, and hardens object handling. |
| packages/json-schema/src/coercer.test.ts | Rewrites/expands tests around real converter output and edge cases. |
| apps/content/learn-and-contribute/mini-orpc/server-side-client.md | Updates guide snippet to use direct indexing instead of get(). |
| apps/content/docs/plugins/smart-coercion.md | Updates smart coercion docs to reflect new safety/union rules and links to current sources. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Summary
Makes the smart coercion plugins safer and more predictable. Values only convert when the result is unambiguous and lossless, unions pick the branch that actually fits the payload, and malformed schemas or hostile payloads can no longer break a request.
What changed
'12345678901234567890'keeps its digits.-07:00) and expanded years. Runtime dependent formats such as'12-25-2020'stay untouched.''no longer becomes0n, and hex or padded strings stay untouched.$refcycles no longer overflow the stack,$refcannot resolve into prototype members,__proto__payload keys stay own properties, and an invalidpatternPropertiesregex is skipped instead of throwing.getin@orpc/sharedtreats prototype members as missing. Call sites that only index routers or contracts now do so directly.ZodToJsonSchemaConverteremits them) with 100% coverage, including invalid schema edge cases.