zod icon indicating copy to clipboard operation
zod copied to clipboard

v4: toJSONSchema: inability to customize references

Open RobinTail opened this issue 11 months ago • 3 comments

Even when using an external registry, external storage for the definitions and uri function returning a string

{
  external: { registry: z.registry(), defs: {}, uri: () => "TEST" }
}

the references produced by toJSONSchema looks this way:

{
  "$ref": "TEST#/$defs/schema0"
}
  • what if I wanna give those references a naming of my choice (TEST instead of schema0) ?
  • if the external/defs is used then why $ref still refers via the "document root" (#) ? There is even no $defs in that case.
  • what if the produced JSON Schema is not the final document itself, but a much deeper part of an actual document (OpenAPI), so that the "document root" is beyond the awareness of toJSONSchema and therefore external/defs is what I wanna use to manage it myself ?

Suggestion

I suggest uri (or similar hook) to be able to replace the whole $ref string, instead of prefixing it, so that I could give it a name and adjust its path:

#/components/schemas/MyFancySchema

RobinTail avatar Apr 25 '25 06:04 RobinTail

I've been on holiday this week so I haven't been able to dig as deep as I would have liked but from memory if you assign it an id via meta, the schema will be named as that.

samchungy avatar Apr 25 '25 06:04 samchungy

The problem with that approach, @samchungy , is that toJSONSchema decides itself which schemas it meets frequently enough to extract them into a reference (I'm dealing with circular ones in particular). So it would be guessing external logic instead of an explicit configuration.

For example

    const category = z.interface({
        name: z.string(),
        get subcategories() {
          return z.array(category);
        },
      });

Does this schema needs id? To answer that you'd need to go resolve its properties to find the nested circular reference yourself, implying that most likely toJSONSchema would extract it into defs. That would be a replication of external logic. I'd like to avoid that.

Or every schema should be given an id which is also expensive.

But anyway, thank you for your suggestions!

RobinTail avatar Apr 25 '25 06:04 RobinTail

The uri parameter is intended to update the base URI (as you're seeing). If your schema contains a sub-schema that is referenced multiple times (and you aren't specifying reused: "inline") then Zod pulls it out into a def. As you're seeing it just uses an autoincrementer to ensure that there aren't naming comflicts in the defs.

I could provide an API for this but it would be a footgun...the user would need to ensure that the def ids have no conflicts. Users trying to set it to a static string like "TEST" is exactly what I'm afraid of.

With Sam's suggestion, the registry automatically prevents duplicate id values so this isn't a problem. That seems like a much easier solution for the set of people who (for whatever reason) don't want the autogenerated def names. Regardless, I'll leave this open to gauge demand. Feel free to propose an API.

colinhacks avatar May 17 '25 01:05 colinhacks

Okay just banged my head against this wall, trying to get lazy schemas to work since cycles is by default set to generate a ref.

My previous behaviour was to throw if we ran into a cycle but the auto register feature would be amazing to have instead. I'd love to gracefully handle more situations compared to my last version.

So +1 from me on RobinTail's suggestion.

It's pretty gross so don't judge me, but I'm essentially doing:

  const patchedSchema = JSON.stringify(jsonSchema).replace(
    '#/components/schemas/__shared#/$defs/schema0',
    '#/components/schemas/mySchemaName',
  );

  return JSON.parse(patchedSchema) as oas31.SchemaObject;

to achieve the component renaming 😅

I've got no helpful suggestions for an API right now. Just trying to get v4 support up and running in the interim but thought I'd share my approach right now.

--

Of note, I haven't quite decided if I should generate JSON Schema for things on a per route basis or if I should load everything up into a zod object and do the schema generation all in one big bang.

eg.

z.object({
  '/job POST': z.object({}),
  '/job GET': z.object({}),
});

It might be tricky to get any of the reused schemas consistently generated across all of the schemas otherwise.

samchungy avatar Jun 07 '25 04:06 samchungy

Of note: It might be super handy if a $ref is extracted - to be able to register it in the registry so that other toJSONSchema calls would be able to use it too?

samchungy avatar Jun 08 '25 04:06 samchungy

The happy path approach if you want to customize inter-schema references is to create a registry and pass that into z.toJSONSchema with an appropriate uri parameter. The external param is no longer supported when passing a single schema into z.toJSONSchema() (this has been discussed elsewhere). Closing. If I'm missing something, feel free to open a new issue.

colinhacks avatar Jul 03 '25 08:07 colinhacks