colinhacks / zod

TypeScript-first schema validation with static type inference
https://zod.dev
MIT License
32.82k stars 1.14k forks source link

`parse.typed(...)` or some kind of typed parse method #1748

Open mmkal opened 1 year ago

mmkal commented 1 year ago

It'd be nice to have zod help the developer max out their chances of parsing a valid object, when it's manually constructed. Example:

const User = z.object({
  name: z.string().regex(/^\w+ \w+$/),
  age: z.number(),
})

User.parse({
  name: 'Bob',
  birthdate: new Date('1970-01-01'),
})

The above code compiles, but we have enough information to know that it'll throw a ZodError at runtime. It'd be nice if there was a method like

User.parse.typed({
  name: 'Bob',
  birthdate: new Date('1970-01-01'),
})

Where the expected input type was Input (from the ZodType<Output, Def, Input>) rather than unknown. So the above code would error because the developer used birthdate instead of age.

This would be especially useful for types with a custom .refine or .regex method or similar where it could be misleading to use just a type declaration:

const user: z.infer<typeof User> = {
  name: "Robert'; -- DROP TABLE Students; --"
  age: 40,
}

The above will compile, and implies that the const user is a validated User instance, but it isn't because name doesn't match the regex. The current options are to do the above, which is safe-looking at compile time, but unsafe at runtime, or to use .parse(...) which is unsafe at compile time and safe-ish at runtime (but will throw errors).

JacobWeisenburger commented 1 year ago

Is this what you are looking for?

const userSchema = z.object( {
    name: z.string().regex( /^\w+ \w+$/ ),
    age: z.number(),
} )
type User = z.infer<typeof userSchema>

userSchema.parse( {
    name: 'Bob',
    birthdate: new Date( '1970-01-01' ),
} ) // runtime error, but no compile time error

userSchema.parse( {
    name: 'Bob',
    birthdate: new Date( '1970-01-01' ),
} as User ) // runtime and compile time error
mmkal commented 1 year ago

Not exactly, because 1) as-casting isn't really safe, since it allows for missing properties and 2) it involves having a type reference for the schema, or creating one manually via z.infer<...>.

Re 1) this would compile fine:

userSchema.parse({
  name: 'Bob',
  // no age!
} as User)

But maybe on latest typescript versions satisfies would do the trick:

userSchema.parse({
  name: 'Bob',
  // no age!
} satisfies User) // compile time error

For 2) though, it's quite cumbersome when you don't already happen to have a type reference for the schema, e.g.:

loadAPISchemaInfo().lookupProcedure('user/create').getInputSchema().parse({
  name: 'Bob',
} satisfies z.infer<???>)

The above code would need to be refactored to extract the schema into a variable, import {z} from 'zod', and then use z.infer<...>. Those are all barriers on top of the temptation to just use .parse(...) which accepts unknown so will compile just fine - much less discoverable and much less likely to be used.

JacobWeisenburger commented 1 year ago

Would typedParse() work for you?

const typedParse = <Schema extends z.ZodSchema> (
    schema: Schema, data: z.infer<Schema>
): z.infer<Schema> => schema.parse( data )

const userSchema = z.object( {
    name: z.string().regex( /^\w+ \w+$/ ),
    age: z.number(),
} )

const user = typedParse( userSchema, {
    name: 'Bob',
    birthdate: new Date( '1970-01-01' ),
} ) // runtime and compile time error
// const user: {
//     name: string
//     age: number
// }

typedParse(
    loadAPISchemaInfo().lookupProcedure( 'user/create' ).getInputSchema(),
    {
        name: 'Bob',
    }
)
JacobWeisenburger commented 1 year ago

I don't think that this will ever be added to Zod, because it's my understanding that parse is meant to take something that is unknown and turn it into something that is known at compile time. So when you are trying to parse an object that is statically in the code base, that's something that is already known at compile time. In example code we do this all the time for learning/teaching purposes, but I don't know that I have ever done that in a real app. In real apps I use Zod for checking user input or a response from an api, which are things that can't be known at compile time. I hope this makes sense. Please let me know if you have questions or if I am misunderstanding what you are talking about.

mmkal commented 1 year ago

Yes it's a slightly different use case than validating API inputs, and I usually want this when using refinement types, or regex strings, or similar, which perform runtime validation which is not reflected at compile time. And yes that helper function does the trick for most cases - what I'm asking is whether it could become part of zod. There are some edge cases like .transform types with a different input typearg which it'd be sensible to solve in one official place.

sneko commented 1 year ago

Just posted https://github.com/colinhacks/zod/issues/1892 that looks finally really the same than what @mmkal is looking for.

I totally agree with him on implementing it inside Zod. Since Typescript no longer exist at runtime, helping people in writing valid code at compilation helps.

In my case I write fixtures and Storybook stories with mocked data and it's sad Zod cannot tell me my object is wrong when it's missing 90% of the expected props. For sure it's not a magic silver bullet since Typescript wouldn't help with custom validation (min/max/regex...) but at least it helps on having the right properties, and the almost expected property types.

JacobWeisenburger commented 1 year ago

Another way you can solve this problem:

type TypedParseSchema<Schema extends z.ZodSchema> = Omit<Schema, 'parse'> & {
    parse ( data: z.infer<Schema> ): z.infer<Schema>
}
const useTypedParse = <Schema extends z.ZodSchema>
    ( schema: Schema ): TypedParseSchema<Schema> => schema

const schemaWithTypedParse = useTypedParse( z.object( {
    email: z.string(),
} ) )

schemaWithTypedParse.parse( 'hello' )
//                          ^^^^^^^
// Argument of type 'string' is not assignable to parameter of type '{ email: string; }'.

@mmkal and @sneko Would this work for you?

sneko commented 1 year ago

Yes it would. It's a bit sad to have to make "parser helpers" for each schema. Do you still think this should be kept outside the zod library?

JacobWeisenburger commented 1 year ago

It's a bit sad to have to make "parser helpers" for each schema

What do you mean "for each schema"? The way I wrote it above can be reused on any schema. So you only need 1 useTypedParse.


Do you still think this should be kept outside the zod library?

I don't know if it should be added in or not. I feel like this is up to @colinhacks.

JacobWeisenburger commented 1 year ago

I made a package that has a few utilities for zod and typed parse is one of them. Let me know if it helps. https://github.com/JacobWeisenburger/zod_utilz#usetypedparsers

sneko commented 1 year ago

What do you mean "for each schema"? The way I wrote it above can be reused on any schema. So you only need 1 useTypedParse.

By making "helpers" I mean if I have UserSchema, CarSchema... for each one I need to do:

const schemaWithXxxTypedParse = useTypedParse(XxxSchema)

(but I agree that's a decent workaround for now, even if I need to write it everywhere I have a schema.

mmkal commented 1 year ago

I'm inclined to agree - absolutely, that helper works, but if the use case is valid enough for it to exist in a util library, then I'd argue it'd make sense to exist in zod too. It doesn't really add any meaningful maintenance burden, I don't think, since the implementation is just the same as the untyped parse. A util library is less discoverable, requires separate installation and importing, might break with new versions etc. etc.

JacobWeisenburger commented 1 year ago

I totally agree. But I don't feel like I have the authority to add a feature like this to someone else's library. I am totally fine with maintaining it on my util library because I have the authority to do so.

TomKaltz commented 1 year ago

https://github.com/JacobWeisenburger/zod_utilz#usetypedparsers should be part of zod. Very helpful! @colinhacks

wbobeirne commented 1 month ago

I just wanted to voice my support for this. In addition to validating user inputs to my API, I'm using Zod to format responses from my API and I have tripped over this a few times where the type that I'm passing in obviously won't parse, like a Promise if I mistakenly forgot to await something, or passing in a T | null or T | undefined where I should have first checked if the value was there. While this isn't the main use-case for Zod, this feels like a really low-cost method to implement that would be useful for cases where you have semi-trusted input you want to parse.

mmkal commented 1 month ago

@colinhacks now that zod is seeing more activity recently, what do you think?

wbobeirne commented 1 month ago

Decided to put my money where my mouth is and put up https://github.com/colinhacks/zod/pull/3629