跳到正文
DEV Community· Gabriel Anhaia·· 3 小時前AI 評分60

Zod 4 的 .refine() 為何不會傳到模型:JSON Schema 會保留哪些規則

Your Zod .refine() Never Reaches the Model: What Survives the Trip to JSON Schema

AI 導讀

在 OpenAI Node SDK 和 Vercel AI SDK 的結構化輸出流程中,Zod 4 的 .refine() 規則會在轉成 JSON Schema 時被略過,模型因此看不到這些限制,但解析回應時仍會執行驗證。

正文

Pass a Zod 4 schema to a structured-output helper and every .refine() in it is dropped before the model sees it, with no warning. The OpenAI Node SDK and the Vercel AI SDK both call z.toJSONSchema() on Zod 4 schemas, and that JSON is what the model is constrained by.

You can see why in Zod's JSON Schema converter at v4.6.7, the release from 12 October 2026. One line in json-schema-processors.ts decides which of your validation rules get written into the schema: for (const ch of list) contributors[ch._zod.def.check]?.(agg, ch._zod.def);. The contributors map has 13 keys: greater_than, less_than, multiple_of, min_length, string_format and friends. Every .refine() you write is stored as a check named custom (api.ts), and custom is not one of the keys. The optional call finds nothing, and the rule is skipped.

Your refinement still runs later, when the response is parsed. The model just never heard about it.

A realistic schema with four kinds of rule

Here is a refund extraction schema of the kind you would hand to a model that reads support emails. It mixes rules JSON Schema can express with rules it can't.

import { z } from "zod";

export const Refund = z
  .object({
    orderId: z.string().regex(/^ORD-\d{6}$/),
    amount: z.number().positive(),
    currency: z.enum(["EUR", "USD"]),
    orderDate: z.iso.date(),
    refundDate: z.iso.date(),
    note: z.string().refine((s) => !/https?:\/\//.test(s), {
      message: "note must not contain links",
    }),
    sku: z.string().transform((s) => s.trim().toUpperCase()),
  })
  .refine((r) => r.refundDate >= r.orderDate, {
    message: "refundDate is before orderDate",
    path: ["refundDate"],
  });

Four fields use rules JSON Schema has a keyword for: regex, positive, z.enum and the ISO date format. The rest are the problem: a .refine() on note, a cross-field .refine() on the whole object, and a .transform() on sku.

The JSON the model actually gets

Below is the shape z.toJSONSchema(Refund, { io: "input" }) produces, which is the mode the Vercel AI SDK uses. I haven't run this exact schema myself. Each property follows the converter's test snapshots in to-json-schema.test.ts and its processor source for the same kinds of Zod call, and I trimmed the long ISO-date regex and the $schema line. Print it on your own version with console.log(JSON.stringify(z.toJSONSchema(Refund, { io: "input" }), null, 2)).

{
  "type": "object",
  "properties": {
    "orderId": { "type": "string", "pattern": "^ORD-\\d{6}$" },
    "amount": { "type": "number", "exclusiveMinimum": 0 },
    "currency": { "type": "string", "enum": ["EUR", "USD"] },
    "orderDate": {
      "type": "string",
      "format": "date",
      "pattern": "<long ISO date regex, trimmed>"
    },
    "refundDate": {
      "type": "string",
      "format": "date",
      "pattern": "<long ISO date regex, trimmed>"
    },
    "note": { "type": "string" },
    "sku": { "type": "string" }
  },
  "required": [
    "orderId", "amount", "currency", "orderDate",
    "refundDate", "note", "sku"
  ]
}

Line it up against the Zod source:

Field Zod rule What the model sees
orderId .regex(...) pattern
amount .positive() exclusiveMinimum: 0
currency z.enum enum
orderDate, refundDate z.iso.date() format: "date" plus a pattern
note .refine(no links) nothing, just "type": "string"
sku .transform(...) input mode: a plain string; output mode: an error
the object .refine(refund after order) nothing

The test file backs up the note row. It unions z.string().refine((s) => s.length > 2) with z.string().refine((s) => s.length < 9), and the snapshot collapses both branches to a bare "type": "string". They can only dedupe if neither refinement made it into the output. The object-level refinement goes the same way, because .superRefine() and object .refine() also land as custom checks.

One more detail from the Zod JSON Schema docs: in input mode, additionalProperties is not set on objects. The AI SDK adds it back with its own addAdditionalPropertiesToJsonSchema pass. In the default output mode, Zod writes additionalProperties: false itself.

A flat illustration of a customs checkpoint where some parcels pass through a gate and others are held back on a table
Representable checks cross into JSON Schema. Refinements stay behind.

Transforms either throw or tell the model the wrong type

Transforms behave differently from refinements, and the behaviour depends on the io option. A .transform() on a string is a pipe: string in, transform out. The pipe processor picks a side with ctx.io === "input" ? (inIsTransform ? def.out : def.in) : def.out (source).

In input mode, it takes the string side, so the model sees { "type": "string" } for sku and your uppercasing happens after. That is harmless for this field. It is less harmless for something like z.string().transform(Number), where the model is told only "string", so nothing stops it from writing "twelve" or "12 EUR", and Number() hands your code NaN without a validation error.

In output mode, the default, the converter reaches the transform itself. The transform processor calls handleUnrepresentable with "Transforms cannot be represented in JSON Schema", and the docs say Zod throws on unrepresentable types unless you pass unrepresentable: "any". The OpenAI helper calls z4.toJSONSchema(schema, { target: 'draft-7', ... }) without changing io or unrepresentable, so a .transform() inside a schema you pass to zodTextFormat should fail when you build the format, before any request goes out. That is the better failure of the two: loud and early.

The provider can strip more

Getting a keyword into JSON Schema doesn't guarantee the model is held to it. Each API supports a subset.

Anthropic's structured outputs docs list numerical constraints such as minimum, maximum and multipleOf, and string constraints (minLength, maxLength), as unsupported. Their SDK helpers remove those, append each one to the field's description as text, and then validate the response against your original schema. .positive() emits exclusiveMinimum, which the docs don't name but which is a numerical constraint of the same kind. So with zodOutputFormat(), your amount > 0 rule is likely to reach Claude as text in a description rather than as a decoding constraint. Check the transformed schema on your SDK version to confirm.

OpenAI's supported subset includes pattern, format, numeric bounds and anyOf, and lists allOf, not and if/then/else as unsupported. It also requires every field to be in required, with optional fields written as a union with null. Unions are their own trap: Zod's union processor emits oneOf for discriminated unions (the source comment says so), and the OpenAI helper's override callback rewrites that oneOf into anyOf before sending. If you call z.toJSONSchema() yourself and send the result to an API directly, you inherit none of those fixes.

The fix: describe the rule, validate after, retry once

There is no way to make a refinement representable, so the work moves to two places: telling the model the rule in words, and catching the response when it breaks the rule anyway.

Start with .describe(): every rule the model has to follow goes there. Zod copies metadata into the generated JSON Schema ("All metadata fields get copied into the resulting JSON Schema", docs), so the description travels with the field. Put .describe() last in the chain. The metadata docs show .meta() returning undefined on a schema created by .refine() from a described one, because metadata belongs to one schema instance and every method returns a new one.

Then move transforms out of the schema you send. Keep the wire schema plain and reshape the data after it parses.

export const Refund = z
  .object({
    orderId: z
      .string()
      .regex(/^ORD-\d{6}$/)
      .describe("Order id, e.g. ORD-123456"),
    amount: z.number().positive().describe("Greater than 0"),
    currency: z.enum(["EUR", "USD"]),
    orderDate: z.iso.date(),
    refundDate: z.iso
      .date()
      .describe("ISO date, on or after orderDate"),
    note: z
      .string()
      .refine((s) => !/https?:\/\//.test(s), {
        message: "note must not contain links",
      })
      .describe("Plain text. No URLs."),
    sku: z.string().describe("SKU exactly as written"),
  })
  .refine((r) => r.refundDate >= r.orderDate, {
    message: "refundDate is before orderDate",
    path: ["refundDate"],
  });

The refinements are still there. They are now the second line of defence instead of the only one.

A flat illustration of a loop where a document passes a checkpoint, gets a correction note attached, and goes around once more
Validate the response, send the Zod error back, allow one more pass.

Last, validate the response yourself and send the Zod error back for one retry. The OpenAI SDK's responses.parse does call your schema's parse (parseZodObject), but it throws on failure. Calling responses.create and running safeParse yourself gives you the error object to feed back. The model ID below is one of the current ones on OpenAI's models page; swap in yours.

import OpenAI from "openai";
import { zodTextFormat } from "openai/helpers/zod";
import { z } from "zod";

const openai = new OpenAI();

type Msg = { role: "user" | "assistant"; content: string };

export async function extract<T extends z.ZodType>(
  schema: T,
  name: string,
  prompt: string,
): Promise<z.infer<T>> {
  const input: Msg[] = [{ role: "user", content: prompt }];
  for (let attempt = 0; attempt < 2; attempt++) {
    const res = await openai.responses.create({
      model: "gpt-6-luna",
      input,
      text: { format: zodTextFormat(schema, name) },
    });
    const raw = res.output_text;
    const result = schema.safeParse(JSON.parse(raw));
    if (result.success) return result.data;
    input.push(
      { role: "assistant", content: raw },
      {
        role: "user",
        content:
          "That JSON failed validation:\n" +
          z.prettifyError(result.error) +
          "\nReturn the corrected JSON only.",
      },
    );
  }
  throw new Error(`${name}: failed validation twice`);
}

Then reshape after parsing:

const refund = await extract(Refund, "refund", emailText);
const sku = refund.sku.trim().toUpperCase();

z.prettifyError() turns each issue into a ✖ line with the message, followed by an → at <path> line when the issue has a path. That is close to the sentence a human reviewer would write. The path you set in .refine() is what puts the field name in that message, so set it on every object-level rule.

Stop at one retry: if the model fails the same rule twice with the error in front of it, a third attempt is mostly cost. Throw, log the raw output, and look at whether the rule belongs in the prompt or in your code. JSON.parse can also throw on a cut-off or refused response; in production, check the response status before you parse.


If this was useful

The gap between "the schema I wrote" and "the schema the model saw" is one of the first things that bites when you move from a demo to a feature people rely on. AI That Answers is the book in the series that covers this ground in TypeScript: prompts, structured output, validating what comes back, and keeping token cost in view while you do it.

AI That Answers — AI in TypeScript

The whole AI in TypeScript series, from your first LLM call to agents in production: xgabriel.com/ai-in-typescript

來源:DEV Community · dev.to