zod icon indicating copy to clipboard operation
zod copied to clipboard

v4: toJSONSchema on optionals should mark them somehow

Open mmkal opened this issue 6 months ago • 3 comments

Related to https://github.com/colinhacks/zod/pull/4090 and https://github.com/colinhacks/zod/pull/4124 and https://github.com/colinhacks/zod/issues/4123

I agree with the gist of #4090 - it's more precise to avoid a union type since optional/undefined isn't a concept for root-level values in JSON schema. But in practice it's sad that #4090 ended up dropping useful information, even if it wasn't perfectly accurate. It'd be helpful to retain that "this thing was optional when it was a zod type". My use case is trpc-cli - I am working on adding zod4 support, but this is a minor snag:

t.router({
  lint: t.procedure
    .input(z.string().describe('filepath or glob pattern').optional())
    .mutation(async ({input: filepath}) => runLinter(filepath))
})

The above, assuming runLinter's input is optional too, should result in a CLI that you can call like mylinter lint somefile.txt OR mylinter lint. This was possible with zod3 + zod-to-json-schema because they do the awkward { oneOf: [{type: 'string'}, {not: {}}] } which trpc-cli can specifically look for.

I can understand if we want to be a bit more precise than that, the {not: {}} thing is clearly a hack. But could we use another property to give a hint to people who specifically want to know if the type was optional? Some options:

z.toJSONSchema(z.string().optional()) // {type: 'string', $comment: '(optional)'}

z.toJSONSchema(z.string().optional()) // {type: 'string', $zodFlags: ['optional']}

Of those I think the second is better - it's explicitly allowed by json-schema to do that kind of thing: https://json-schema.org/draft/2019-09/json-schema-core#rfc.section.6.5 and expecting developers to start parsing $comment seems like a bad idea.

mmkal avatar Apr 15 '25 14:04 mmkal

You could potentially declare an override in your call to toJSONSchema to change how the optional behaviour generates for your usecase?

I think..

I'm yet to tinker extensively with it

samchungy avatar Apr 15 '25 15:04 samchungy

Yes, that does work! I was previously just modifying the output after converting, but override is better, thank you.

Still, I think it'd be nice to build it in. Could help with unrepresentable and/or a place to put "hey this might be something you want to handle explictly" too:

z.toJSONSchema(z.string().optional()) // {type: 'string', $zod: {optional: true}}

z.toJSONSchema(z.bigint(), {unrepresentable: 'any'}) // {$zod: {unrepresentable: 'bigint'}}

(maybe some kind of pipes marker too? so that library authors can throw helpful errors if necessary?)

mmkal avatar Apr 15 '25 17:04 mmkal

I think the optional should include the describe, the optional can customized by override is ok for me.

console.log(z.toJSONSchema(z.string().optional().describe("title")))

Workaround

I prefer nullable instead of anyOf

z.toJSONSchema(schema, {
        unrepresentable: 'any',
        override: ({ zodSchema, jsonSchema: js }) => {
          const def = zodSchema._zod.def;
          const meta = z.globalRegistry.get(zodSchema);

          switch (def.type) {
            case 'nullable':
            case 'optional':
              js.description ||= meta?.description;
              match(js)
                .with({ anyOf: [P.select(), { type: 'null' }] }, (select) => {
                  delete js['anyOf'];
                  Object.assign(js, select);
                  js.nullable = true;
                })
                .otherwise((js) => {
                  js.nullable = true;
                });
              break;
          }
        },
      })

wenerme avatar Apr 22 '25 04:04 wenerme

Hi, @mmkal. I'm Dosu, and I'm helping the Zod team manage their backlog. I'm marking this issue as stale.

Issue Summary:

  • The issue discusses the need for JSON schema generated from Zod types to retain information about optional fields.
  • A suggestion was made to use a custom property like $zodFlags: ['optional'].
  • @samchungy proposed using an override in the toJSONSchema call, which you acknowledged as a better approach.
  • @wenerme provided a workaround using describe in optional fields and preferring nullable over anyOf.

Next Steps:

  • Please let me know if this issue is still relevant to the latest version of the Zod repository. If so, you can keep the discussion open by commenting on the issue.
  • Otherwise, the issue will be automatically closed in 28 days.

Thank you for your understanding and contribution!

dosubot[bot] avatar Jul 25 '25 16:07 dosubot[bot]