← All posts

Your Zod `.refine()` never reaches the model: what survives JSON Schema

OpenAI Node SDK and Vercel AI SDK run Zod 4 through z.toJSONSchema(); every `.refine()` drops silently. What the model sees — and how to fix it.

Your Zod `.refine()` never reaches the model: what survives JSON Schema
Contents

In brief

Pass a Zod 4 schema into a structured-output helper and every .refine() is dropped before the model sees it — with no warning.

The OpenAI Node SDK and the Vercel AI SDK both call z.toJSONSchema(). In Zod 4.6.7 the converter’s contributors map has 13 keys; .refine() is stored as a check named custom, which is not in that map.

The refinement still runs when you parse the response. The model was never constrained by it.

What happened

Gabriel Anhaia walks through the gap between the schema you wrote and the schema the model actually sees.

Think of airport security: your suitcase holds a passport and a handwritten note — “no URLs in note.” The scanner only keeps items that match a list of thirteen categories. The note is removed without an announcement. You find out at baggage claim, when someone opens the bag and checks your packing list.

One line in json-schema-processors.ts decides which checks become JSON Schema: for each check it looks up a handler in contributors. Keys include greater_than, less_than, multiple_of, min_length, string_format, and neighbours. In api.ts, every .refine() is a custom check — the optional lookup finds nothing, and the rule is skipped.

He builds a refund-extraction schema for support emails. Four fields map cleanly: .regex(), .positive(), z.enum, z.iso.date(). The rest are the problem: a field .refine() on note (no links), an object .refine() (refund date not before order date), and a .transform() on sku.

With { io: "input" } — the mode the Vercel AI SDK uses — the model sees pattern, exclusiveMinimum, enum, and format: "date". note becomes a bare string. The object rule vanishes. In input mode sku is also just a string; in output mode the converter may throw — “Transforms cannot be represented in JSON Schema.”

Zod’s own tests back this up: two z.string().refine(...) branches in a union collapse to "type": "string". Object .refine() and .superRefine() take the same path — again custom.

Providers can strip more. A keyword in JSON Schema is not a guarantee at decode time. Anthropic’s helpers often move numerical bounds and string length limits into field descriptions instead of hard constraints; .positive() emits exclusiveMinimum, same class. OpenAI accepts a wider set (pattern, format, bounds, anyOf) but not allOf, not, or if/then/else; its helper also rewrites oneOf to anyOf.

Why it matters

In a demo the schema looks green: types line up, the helper stays quiet. In production the model happily puts a URL in note or swaps the dates — those rules never left for JSON Schema.

Failure shows up only on parse. If you throw and never feed the error text back, the user sees a crash instead of corrected JSON.

The gap between “the schema I wrote” and “the schema the model saw” is one of the first traps when a prototype becomes a feature people depend on. Refinements and transforms live in Zod; structured output lives in a JSON Schema subset plus whatever the provider did not strip.

In practice

The author puts two lines of defence in place and pulls transforms out of the wire schema.

  1. Duplicate every rule the model must follow in .describe() — Zod copies metadata into the generated JSON Schema. Put .describe() last in the chain: after .refine(), metadata falls off because each method returns a new instance.
  2. Keep .refine() as the second line: they still run on parse.
  3. Remove .transform() from the schema you pass to the helper. Keep the wire types plain; trim and uppercase sku after a successful parse.
  4. Do not rely only on responses.parse, which throws on failure. Call responses.create, run safeParse on the text, and on failure send one retry with z.prettifyError(...) asking for corrected JSON only.
  5. Set path on object-level .refine() calls so the field name shows up in the message the model sees.
  6. Stop after one retry. Two failures with the error in view are a signal to log the raw output and decide whether the rule belongs in the prompt or in your code.
  7. Check response status before JSON.parse: cut-offs and refusals otherwise look like parse errors.

Takeaway

In Zod 4, .refine() does not travel into JSON Schema: the converter map has no custom key. The model sees regex, positive, enum, and ISO dates; field and object refinements do not. Transforms either lie about the type in input mode or throw in output mode.

The fix is plain: spell the rule with .describe(), keep refinements as a second line, move transforms out, validate with safeParse, and feed z.prettifyError back once.

The bag cleared security. Check the list yourself — the scanner did not announce what it removed.

Comments

Loading comments…