Skip to content

feat(zod): respect an explicit type in schema metadata#1693

Draft
macalinao wants to merge 2 commits into
middleapi:mainfrom
macalinao:feat/zod-respect-meta-type
Draft

feat(zod): respect an explicit type in schema metadata#1693
macalinao wants to merge 2 commits into
middleapi:mainfrom
macalinao:feat/zod-respect-meta-type

Conversation

@macalinao

@macalinao macalinao commented Jul 20, 2026

Copy link
Copy Markdown

Summary

Make ZodToJsonSchemaConverter honor an explicit scalar JSON Schema type set on a schema's .meta().

Zod copies every .meta() field on top of its structural conversion but never reconciles them. So a schema like a Temporal validator, which is a z.union([...]) under the hood with .meta({ type: 'string', format, pattern }), converts to:

{ "anyOf": [{ "type": "string" }, ...], "type": "string", "format": "date-time", "pattern": "" }

— the intended string schema polluted with a redundant, contradictory anyOf (the non-string branches can never satisfy type: 'string').

This PR: when metadata pins a scalar type, treat it as authoritative and drop the leftover structural composition keywords (anyOf/oneOf/allOf). The union above now converts to the clean:

{ "type": "string", "format": "date-time", "pattern": "" }

Why this scope

I checked zod's own toJSONSchema behavior first (per the zod JSON Schema docs: "All metadata fields get copied into the resulting JSON Schema", and override runs after the metadata merge). Empirically:

schema zod output needs fix?
z.number().meta({ type: 'string' }) { type: 'string' } no — zod already overwrites the scalar type
z.object({ a }).meta({ type: 'object', title }) { type: 'object', properties, required, title } no — properties preserved
z.union([string, number]).meta({ type: 'string' }) { anyOf: [...], type: 'string' } yes — contradictory leftover anyOf

The only case zod leaves inconsistent is a composition wrapper left next to a pinned type, so the fix strips only those keywords — it does not wholesale-replace the schema with the metadata (which would destroy properties).

Restricted to scalar types on purpose. For object/array, the composition branches carry real structure the metadata does not restate — e.g. z.union([{a}, {b}]).meta({ type: 'object' }) is a consistent { anyOf: [...], type: 'object' }, not contradictory noise. Stripping there would silently discard the branch shapes, so those types are excluded and keep their anyOf.

Context

This replaces the need for an external converter interceptor. Downstream libraries (e.g. temporal-zod, which currently ships a structural interceptor workaround) can now express a validator's JSON Schema directly through .meta() — no converter configuration required. All Temporal validators are ISO strings/durations, i.e. scalar type: 'string', so they are fully covered.

Test plan

  • pnpm vitest run packages/zod/src/converter.test.ts — added cases: union pinning a scalar type, nested-in-object, object schema restating type (unchanged), and a union of objects pinned to type: 'object' that keeps its anyOf.
  • pnpm --filter @orpc/zod run type:check
  • pnpm exec eslint packages/zod/src/converter.ts packages/zod/src/converter.test.ts

🤖 Generated with Claude Code

Zod copies every `.meta()` field on top of its structural conversion but
never reconciles them, so a schema such as
`z.union([...]).meta({ type: 'string', format, pattern })` converts to
`{ anyOf: [...], type: 'string', format, pattern }` — the intended string
schema polluted with a redundant, contradictory `anyOf`.

When metadata pins a concrete JSON Schema `type`, treat it as authoritative
and drop the leftover structural composition keywords (`anyOf`/`oneOf`/
`allOf`). Scalar and object shapes are unaffected: zod already overwrites a
structural `type` on merge, and object schemas keep their `properties`.

This lets libraries (e.g. temporal-zod) express a validator's JSON Schema
directly via `.meta()` instead of shipping an external converter interceptor.
@vercel

vercel Bot commented Jul 20, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
orpc Ready Ready Preview, Comment Jul 20, 2026 2:47am

Only strip the leftover `anyOf`/`oneOf`/`allOf` when the pinned `type` is a
primitive scalar (string/number/integer/boolean/null). For `object`/`array`
the composition branches carry real structure the metadata does not restate,
so dropping them would silently discard information rather than remove
contradictory noise.
@dinwwwh

dinwwwh commented Jul 20, 2026

Copy link
Copy Markdown
Member

I think we should request that Scalar or Zod support this use case instead of implementing it in oRPC. Relying on a custom meta key could conflict with future built-in Zod metadata

Personally, I think this belongs in Scalar.

@dinwwwh

dinwwwh commented Jul 20, 2026

Copy link
Copy Markdown
Member

alternative #1695

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants