Skip to content

feat: support for zod v4 schema validation #6421

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 9 commits into
base: v5
Choose a base branch
from
Open

Conversation

gr2m
Copy link
Collaborator

@gr2m gr2m commented May 22, 2025

Background

zod 4 has now been released: https://zod.dev/v4

We want to support both zod 3 and zod 4 schemas for validation.

Summary

  • update zod to latest (3.25.32 as of May 28)
  • Update implementations of generateObject(), streamObject(), generateText(), experimental_useObject() from @ai-sdk/react, and streamUI() from @ai-sdk/rsc to accept both zod v3 and zod v4 (mini) schemas

Verification

  • generateObject(): examples/ai-core/src/generate-object/openai.ts
  • streamObject(): examples/ai-core/src/stream-object/openai.ts
  • generateText(): examples/ai-core/src/generate-text/openai-output-object.ts
  • experimental_useObject(): examples/next-openai/app/use-obj/page.tsx
  • streamUI(): examples/next-openai/app/stream-ui/actions.tsx, update: comment

Tasks

  • Tests have been added / updated (for bug fixes / features)

  • Documentation has been added / updated (for bug fixes / features)

    All the docs use import { z } from 'zod'; which is zod v3. Should we update it to zod/v4 to align with the recommendation on https://zod.dev/?

  • A patch changeset for relevant packages has been added (for bug fixes / features - run pnpm changeset in the project root)

  • Formatting issues have been fixed (run pnpm prettier-fix in the project root)

Future Work

  • When using a zod 4 schema in examples/next-openai/app/api/use-object/schema.ts, then useObject({ api: '/api/use-object', schema: notificationSchema }) in examples/next-openai/app/use-object/page.tsx sets the type of { object } to any, and I couldn't figure out why that is. For now, I explicitly used a zod 3 schema for notificationSchema.

  • packages/svelte/src/structured-object.svelte.ts

  • packages/svelte/src/tests/structured-object-synchronization.svelte

  • Upgrade all internal uses of zod to z4 (~102 files), try using z4-mini.

Related Issues

#5682

SCHEMA extends z.Schema | Schema = z.Schema<JSONValue>,
? Array<
// @ts-expect-error - TODO: Type instantiation is excessively deep and possibly infinite
z3.infer<SCHEMA>
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

make sure SCHEMA is not an instance of zod 4

@gr2m

This comment was marked as resolved.

@gr2m

This comment was marked as resolved.

@OscarCornish
Copy link

Hey - Super keen to start using this so I've been poking around, figure I'll offer my 2 cents

You appear to have two causes for the recursive type error, the first

[K in keyof TOOL_SCHEMAS]: MappedTool<TOOL_SCHEMAS[K], CallToolResult> &

Is because when TOOL_SCHEMAS = 'automatic' | undefined, the second branch is continually taken, as when it recurses it recurses with TOOL_SCHEMAS = Record<string, { parameters: ToolParameters<unknown> }> which is invariant to Record<string, { parameters: ToolParameters<any> }>... and so the second branch is taken again, infinitely recursing

I think this is as simple as switching both tool schema records to use unknown?

I'm not so sure about the second one:

(identical to the other file).

I think it's because you are defining RESULT before SCHEMA, even though it depends on schema, which is triggering re-evaluation? in combination with the large union of any zod3 object & any zod4 object? I think your comment on the zod repo will yield much clearer answers.

Regardless, I have a fix. By pulling out some of the heavy lifting into helper types:

type InferSchema<S> =
  S extends z3.Schema ? z3.infer<S> :
  S extends z4.$ZodType ? z4.infer<S> :
  S extends Schema<infer T> ? T :
  never;

type InferFor<S, O extends 'object' | 'array'> =
  O extends 'array' ? Array<InferSchema<S>> : InferSchema<S>;

You can write the signature like:

...
export async function generateObject<
  SCHEMA extends z3.Schema | z4.$ZodType | Schema = z4.$ZodType<JSONValue>,
  Output extends 'object' | 'array' | 'enum' | 'no-schema' =
      InferSchema<SCHEMA> extends string ? 'enum' : 'object',
  RESULT = Output extends 'array' ? InferFor<SCHEMA, 'array'> : InferFor<SCHEMA, 'object'>
>(
  options: Omit<CallSettings, 'stopSequences'> &
 ...

So that the generics are in order


Hopefully this is both clear & helpful, apologies that the explanation of the second issue is a bit fuzzy

@gr2m gr2m force-pushed the v5-gr2m/5682-zod-4 branch from 62eadd4 to 24ad65f Compare May 28, 2025 20:43
@gr2m gr2m marked this pull request as ready for review May 28, 2025 20:59
@gr2m gr2m changed the title feat: support for zod v4 for schema validation feat: support for zod v4 schema validation May 28, 2025
@gr2m

This comment was marked as resolved.

: never,
SCHEMA extends z.Schema | Schema = z.Schema<JSONValue>,
Output extends
SCHEMA extends z3.Schema | z4.$ZodType | Schema = z4.$ZodType<JSONValue>,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you plan to continue supported Zod 3.24.x versions, note that all of these v4.* imports will resolve to any in your declaration files.

Screenshot 2025-05-28 at 18 16 46

This isn't necessarily a fatal flaw but you need to be cognizant of this and test your code against both Zod 3.24 and Zod 3.25.

@colinhacks
Copy link

colinhacks commented May 29, 2025

Left some reviews. It's extremely thoughtful to try to ensure compatibility with Zod 3.24.x, to put it nicely. Based on a quick scan, the current implementation will not work well for 3.24.x - I explain more in my reviews.

You're going to have a much easier time if you bump your Zod peer dep to 3.25. The timing with ai@5 works out great for this. There are zero breaking changes from 3.24 -> 3.25 for Zod 3 users. It's just a matter of running npm upgrade.

Arguably, a minor version bump in a peer dependency does not qualify as a breaking change at all since (assuming the peer dependency library didn't break anything between minors) it requires no code changes on the part of your users.

Note also I plan to publish zod@4 on npm at some point. At this point, the "zod" package root will start exporting Zod 4 instead of Zod 3. Zod 3 will then only be available at "zod/v3". This will require more code changes on your end and make it infeasible to support Zod 3.24.x. This may happen sooner rather than later.

PS Gregor - feel free to DM me about this stuff. I'm not on mastodon but I am on X/Bsky 👍

Copy link
Collaborator Author

@gr2m gr2m left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks a lot @colinhacks for reviewing the PR, much appreciated!

@gislerro
Copy link

I've been playing around with the alpha a bit and noticed that providers (tried it with google gen ai) may reject zod/v4 schemas - this issue occurs with z.literal and z.enum and possibly more.

Google expects enum properties in JSON schema to have a type property - otherwise an error is thrown. Zod does not currently add this property to the JSON schema.

So either the generated JSON schema has to be post-processed or zod's JSONschema generation has to be adapted.

Relevant issues in zod:
literals: colinhacks/zod#4249
enums: colinhacks/zod#4577

Here's a test that illustrates error:

import type { JSONSchema7 } from "ai@alpha";
import { jsonSchema } from "@ai-sdk/provider-utils@alpha";
import { generateObject } from "ai@alpha";
import { z } from "zod/v4";

import { providers } from "~/alpha/providers";

describe("zod v4 with literals & enum -> generateObject", () => {
  it("should work without patch", async () => {
    const schema = z.strictObject({
      food: z.literal(["pizza", "burger", "pasta"]),
      animal: z.enum(["cats", "dogs"]),
    });

    try {
      const JSONschema = z.toJSONSchema(schema, {
        io: "output",
        target: "draft-7",
      }) as JSONSchema7;

      const manual = jsonSchema(JSONschema, {
        validate(value) {
          const result = schema.safeParse(value);
          return result.success
            ? { success: true, value: result.data }
            : { success: false, error: result.error };
        },
      });
      
      // throws AI_APICallError
      const generated = await generateObject({
        schema: manual,
        model: providers.languageModel("google:gemini-1.5-flash"),
        system: "Generate an object based on the provided json schema",
        prompt: "Pick your favorite options among the available ones",
      });

      console.log(generated.object);
    } catch (e) {
      if (e instanceof Error) {
        console.log(`[API ERROR ${e.name}]\n${e.message}`);
      }
    }

    try {
      const JSONschema = z.toJSONSchema(schema, {
        io: "output",
        target: "draft-7",
      }) as JSONSchema7;

      // @ts-expect-error necessary patch
      JSONschema.properties.food.type = "string";

      // @ts-expect-error necessary patch
      JSONschema.properties.animal.type = "string";

      const manual = jsonSchema(JSONschema, {
        validate(value) {
          const result = schema.safeParse(value);
          return result.success
            ? { success: true, value: result.data }
            : { success: false, error: result.error };
        },
      });

      // doesn't throw
      const generated = await generateObject({
        schema: manual,
        model: providers.languageModel("google:gemini-1.5-flash"),
        system: "Generate an object based on the provided json schema",
        prompt: "Pick your favorite options among the available ones",
      });

      console.log(generated.object);
    } catch (e) {
      console.error(e);
      throw e;
    }
  });
});

Here's the output of the above test (I've intercepted the api calls in providers)

{
  input: 'https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent',
  method: 'POST',
  body: {
    generationConfig: {
      responseMimeType: 'application/json',
      responseSchema: {
        required: [ 'food', 'animal', [length]: 2 ],
        type: 'object',
        properties: {
          food: { enum: [ 'pizza', 'burger', 'pasta', [length]: 3 ] },
          animal: { enum: [ 'cats', 'dogs', [length]: 2 ] }
        }
      }
    },
    contents: [
      {
        role: 'user',
        parts: [
          {
            text: 'Pick your favorite options among the available ones'
          },
          [length]: 1
        ]
      },
      [length]: 1
    ],
    systemInstruction: {
      parts: [
        {
          text: 'Generate an object based on the provided json schema'
        },
        [length]: 1
      ]
    }
  }
}
[API ERROR AI_APICallError]
* GenerateContentRequest.generation_config.response_schema.properties[food].enum: only allowed for STRING type
* GenerateContentRequest.generation_config.response_schema.properties[animal].enum: only allowed for STRING type

{
  input: 'https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent',
  method: 'POST',
  body: {
    generationConfig: {
      responseMimeType: 'application/json',
      responseSchema: {
        required: [ 'food', 'animal', [length]: 2 ],
        type: 'object',
        properties: {
          food: {
            type: 'string',
            enum: [ 'pizza', 'burger', 'pasta', [length]: 3 ]
          },
          animal: { type: 'string', enum: [ 'cats', 'dogs', [length]: 2 ] }
        }
      }
    },
    contents: [
      {
        role: 'user',
        parts: [
          {
            text: 'Pick your favorite options among the available ones'
          },
          [length]: 1
        ]
      },
      [length]: 1
    ],
    systemInstruction: {
      parts: [
        {
          text: 'Generate an object based on the provided json schema'
        },
        [length]: 1
      ]
    }
  }
}
{ food: 'pizza', animal: 'cats' }

target: 'draft-7',
io: 'output',
reused: useReferences ? 'ref' : 'inline',
}) as JSONSchema7;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To post process the generated zod schema something like this would be needed

Suggested change
}) as JSONSchema7;
override({ zodSchema, jsonSchema }) {
if (zodSchema instanceof z.ZodLiteral) {
jsonSchema.type = "string";
}
if (zodSchema instanceof z.ZodEnum) {
jsonSchema.type = "string";
}
}) as JSONSchema7;

@gr2m
Copy link
Collaborator Author

gr2m commented Jun 1, 2025

Thanks for the detailed comment!

Looks like the fix for enums is already merged, I'll make sure to bump the version range accordingly

enums: colinhacks/zod#4577

For literals, I'll look into implementing it using an override if the change does land in zod

Comment on lines +75 to +77
| z4.$ZodType<OBJECT, any>
| z3.Schema<OBJECT, z3.ZodTypeDef, any>
| Schema<OBJECT>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wondering if it makes sense to extract this into a dedicated type e.g. extended schema

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.

5 participants