StefanTerdell / zod-to-json-schema

Converts Zod schemas to Json schemas
ISC License
917 stars 76 forks source link

Certain schemas to define a record's keys are discarded #124

Open Spappz opened 5 months ago

Spappz commented 5 months ago

Back again!

JSON obviously doesn't support records, so they're represented as objects with restrictions (where possible) on the property names and values. The package's handling is pretty good, but it seems to give up in two cases I've found. In both cases, the package converts the schemas correctly outside a record key, so I'm unsure why that same schema can't be simply bundled into JSON Schema's propertyNames property.

I unfortunately haven't managed to work out which part of the code actually causes this behaviour, though. I presume something about parsers/record.ts, since the schemas are converted properly in other environments, but I didn't see anything pop out as odd!

Minimal reproducible examples

Examples below using CJS require() so you can test in the REPL. I've used the same schema in both the record's keys and values to highlight that the issue only occurs with the former.

Composed schemas

The inclusion of a union or intersection causes the key schema to be discarded.

const { z } = require("zod");
const { zodToJsonSchema } = require("zod-to-json-schema");

const schema = z.string().url().or(z.string().email());
const zodSchema = z.record(schema, schema);

const jsonSchema = zodToJsonSchema(zodSchema);
console.log(JSON.stringify(jsonSchema, null, "   "));
// Output
{
   "type": "object",
   "additionalProperties": {
      "anyOf": [
         {
            "type": "string",
            "format": "uri"
         },
         {
            "type": "string",
            "format": "email"
         }
      ]
   },
   "$schema": "http://json-schema.org/draft-07/schema#"
}
// Expected
{
   "type": "object",
   "additionalProperties": {
      "anyOf": [
         {
            "type": "string",
            "format": "uri"
         },
         {
            "type": "string",
            "format": "email"
         }
      ]
   },
   "propertyNames": {
      "anyOf": [
         {
            "type": "string",
            "format": "email"
         },
         {
            "type": "string",
            "format": "uri"
         }
      ]
   },
   "$schema": "http://json-schema.org/draft-07/schema#"
}

z.literal()

z.literal() causes that schema to be discarded, rather than being correctly converted to JSON Schema's const.

The example below is fairly pathological, since the actual object being described here is just { foo?: "foo" }. The case in which I actually encountered the bug, before reducing it down, was the slightly more reasonable

z.record(externalStringSchema.or(z.literal("specialCase")), valueSchema)

This is a composed schema as above, but I determined that any amount of z.literal() causes a problem. So, although I do think the below case can fall under a wontfix/user error, I think it's probably still undesired due to the higher-order ramifications.

const { z } = require("zod");
const { zodToJsonSchema } = require("zod-to-json-schema");

const schema = z.literal("foo");
const zodSchema = z.record(schema, schema);

const jsonSchema = zodToJsonSchema(zodSchema);
console.log(JSON.stringify(jsonSchema, null, "   "));
// Output
{
   "type": "object",
   "additionalProperties": {
      "type": "string",
      "const": "foo"
   },
   "$schema": "http://json-schema.org/draft-07/schema#"
}
// Expected
{
   "type": "object",
   "additionalProperties": {
      "type": "string",
      "const": "foo"
   },
   "propertyNames": {
      "type": "string",
      "const": "foo"
   },
   "$schema": "http://json-schema.org/draft-07/schema#"
}
StefanTerdell commented 5 months ago

Back again!

🥳


Here's the block checking for propertyNames compatibility:

https://github.com/StefanTerdell/zod-to-json-schema/blob/3e7c648e626a73517bccb846869d15ef66531a00/src/parsers/record.ts#L62-L84

Adding your requirements here should do the trick. It could also use a general clean up, but the idea of ignoring any output that isn't a string is still sound as it follows spec (from what I can remember anyway).

"type": "string" is implicit in the root, that's why it's (clumsily) removed. "anyOf"'s with "type" should be fine though as per your example.