StefanTerdell / zod-to-json-schema

Converts Zod schemas to Json schemas
ISC License
879 stars 70 forks source link

Add Support for z.input / z.output #4

Closed FlorianWendelborn closed 3 years ago

FlorianWendelborn commented 3 years ago

Currently, this package seems to only be able to generate z.input. There’s differences between the two when using .default() for example.

StefanTerdell commented 3 years ago

Hello! Care to elaborate with an example?

FlorianWendelborn commented 3 years ago

@StefanTerdell sure, here’s the documentation section for z.input/z.output: https://github.com/colinhacks/zod#what-about-transforms

While the example is an extreme case where it switches from string to number, a more common one would be using .default which switches from T | undefined to just T.

So, inferring z.input would yield T | undefined while the output (post-parsing) is just T.

const usingDefault = z.string().default('example')
type zInput = z.input<typeof usingDefault> // should be string | undefined
type zOutput = z.output<typeof usingDefault> // should be string (z.infer is an alias for z.output)
StefanTerdell commented 3 years ago

Thanks!

I may have been eating crayons again, but I don't see how this is relevant when converting to a Json Schema as the output schema would depend on the implementation of the Json Schema parser.

For instance, Ajv will quietly ignore defaults unless the useDefaults flag is passed as true and will leave the property undefined if not. Like so:

const mySchema = z.object({
    myString: z.string().default("hello"),
  });

const jsonSchema = zodToJsonSchema(mySchema);

const ajvWithDefaults = new Ajv({ useDefaults: true });
const dataWithDefaults = {}
ajvWithDefaults.validate(jsonSchema, dataWithDefaults)
console.log(dataWithDefaults)
// outputs: { myString: "hello" }

const ajvWithoutDefaults = new Ajv();
const dataWithoutDefaults = {}
ajvWithoutDefaults.validate(jsonSchema, dataWithoutDefaults)
console.log(dataWithoutDefaults)
// outputs: { }

Zod marks fields within an object with defaults as optional both in input and output and only "secretly guarantees" a value in the end. Like so:

const mySchema = z.object({
  myString: z.string().default("hello"),
});

type input = z.input<typeof mySchema>;
// Compiles to:
// type input = {
//    myString?: string;
// }
type output = z.output<typeof mySchema>;
// Also compiles to:
// type output = {
//     myString?: string;
// }

const data = mySchema.parse({})
// data = {
//   myString: "hello"
// }

The example you're using is also a bit broken since both types are inferred to simply string. In zod^3.x.x, optional fields aren't unions with undefined anymore under the hood.

But I'm getting the feeling that I'm missing something here.. Would be open to a clearer issue or a PR of course, but I think I'll go ahead and close this issue in the meantime.

Thanks for reaching out!

FlorianWendelborn commented 3 years ago

Regarding your example, I think this may be different in the way you tested it. Perhaps a different TS version or no strict mode?

Here’s a repro for yielding two different input/output types from your example: TypeScript Playground

As you can see, the tooltip shows it as non-optional, as it should: image

Here is the repro of my example, where you can also see it’s string | undefined vs string respectively:

TypeScript Playground

FlorianWendelborn commented 3 years ago

I don't see how this is relevant when converting to a Json Schema

As far as I’m concerned, at least for my particular use-cases so far, JSON Schemas are just an intermediary step to convert X -> JSON -> TypeScript Interfaces with X being OpenAPI or zod.

And I’d be surprised if the JSON schema for an optional property looks exactly the same as the JSON schema for a non-optional property. I don’t really want to use JSON schemas in the first place, but they’re a pretty widely-supported intermediary between different typed formats, so maybe my assumptions are somehow wrong. That’s also why I can’t provide the expected generated types, as I’ve never used JSON schemas for anything but converting it to something else.

Also, there’s still the other edge-case with transforms where the input type could be string while the output is number. That certainly has to be reflected in the schema (although I personally only care about the default/partial/undefined support as I don’t use other forms of transforms)

StefanTerdell commented 3 years ago

Would you look at that! Looks like I have some form of environmental issue.

Anyway, I took a look at it and it seems like z.output just uses the inferred return type from the transform function, so there's no actual runtime schema for the output. In other words: it can't be done with the current Zod implementation. Sorry :(

Maybe running https://www.npmjs.com/package/ts-json-schema-generator or something like it on the inferred type could work but I haven't given it a try.

StefanTerdell commented 3 years ago

What I would need is something like transformInto(nextSchema, func) that would pass the new schema down the chain. There's a PR here for something similiar, but looks like it's gone stale: https://github.com/colinhacks/zod/pull/420

FlorianWendelborn commented 3 years ago

Then I think this should just be documented that this package handles z.input and not z.output aka z.infer