Typecheck schemas against existing types
See original GitHub issueTL;DR
I would love to have a way to ensure that a Zod schema matches an existing type definition using the normal Typescript type checker. Below is an example of the desired functionality using one possible implementation stolen from this comment.
type Dog = {
name: string
neutered: boolean
}
//Passing "Dog" as a generic type parameter tells Typescript what the schema should look like
const dogSchema = z.object<Dog>({
name: z.string().min(3),
neutered: z.string(), //Error: string can't be assigned to boolean
});
Introduction
I originally raised this suggestion in #53, but decided it probably needs its own issue. See my original comments here and here. Based on the reactions to these comments, there are at least a few other people thinking along the same lines as I am. I will restate my thoughts below.
I want to start by saying that Zod is a really, really cool project. It is the best runtime validation system for typescript by far. I hope the following critiques are constructive.
My runtime validation requirements
I started implementing Zod in my project, and I went into this implementation assuming that Zod would meet the following two requirements:
- Zod schemas would provide run time checks of my data types. [true]
- Zod schemas would conform to my existing types, so that it is impossible to change the type without also changing the associated schema (and vice versa) . [only sort of true]
In order to get the effect of my second requirement, I discovered that I need to replace my existing code, eg…
export interface A {
readonly ID: number;
delayEnd: number;
userID: number;
reason: string;
taskID: number;
initiationDate: number;
days?: number;
userName?: string;
}
…with something like this…
export const aSchema = z.object({
ID: z.number(), //Note that I've lost the functionality of `readonly` in this conversion
delayEnd: z.number(),
userID: z.number(),
reason: z.string(),
taskID: z.number(),
initiationDate: z.number(),
days: z.number().optional(),
userName: z.string().optional()
});
//"A" is generated from the schema
export type A = z.infer<typeof aSchema>;
This makes it so that if I change aSchema
, A
will automatically update to match, which gives me most of what I was looking for. But there are some serious problems with this solution.
The Problems
The most obvious problem with the above code example is that it removes some really valuable typescript features: As just one example, the functionality of readonly
has been lost in the conversion to aSchema
. Perhaps it is possible to reintroduce that functionality with some fancy Typescript manipulation, but even if that is the case it is still not ideal.
Perhaps a more central problem, though, is that I need to strip out pretty much all of my current type definitions and replace them with Zod schemas. There are some tools out there that will do this work for you (issue #53 was originally and ultimately about building these sorts of tools), but the real issue for me isn’t the work of refactoring: The real problem is that such a refactor puts Zod in charge of my type system, which is very undesirable. In my opinion, Typescript should be the master of typing, and Zod should be the master of validation. In the current system, Typescript is subordinated to Zod rather than the other way around.
To make sure my intent is clear, here are a few re-statements of this idea:
- I want to keep all my types as they are and create schemas that conform to them.
- I do not want to replace my existing type definitions with schemas; instead I want to create schemas that match my existing types.
- I want to keep my type system in typescript and only use Zod to validate that objects fit my existing types.
To put it a different way, Zod is advertised as “Typescript first”, but right now it feels more like “Zod first with Typescript as a close second”. I say that because, currently, if I want to maintain type consistency I have to write the Zod schemas first, then use them to generate types. To be truly “Typescript first”, the schemas should conform to the types instead of the types being generated from the schemas.
The tozod
solution
A great idea that addresses these issues was introduced in this comment, discussed in this comment, then partially implemented in the tozod
library (see this comment; the library can be found here). The tozod
utility allows me to write the following in place of the above code example:
//My interface does not change
export interface A {
readonly ID: number;
delayEnd: number;
userID: number;
reason: string;
taskID: number;
initiationDate: number;
days?: number;
userName?: string;
}
//The use of toZod ensures that the schema matches the interface
export const aSchema: toZod<A> = z.object({
ID: z.number(),
delayEnd: z.number(),
userID: z.number(),
reason: z.string(),
taskID: z.number(),
initiationDate: z.number(),
days: z.number().optional(),
userName: z.string().optional()
});
This meets my requirements perfectly. It preserves my original types and has a schema that conforms to those types. It gives me the same strong typing as using z.infer
would have, but it leaves Typescript in charge of defining my type system and still gives me all the benefits of runtime validation. It also preserves certain Typescript functionality that can’t be modeled in Zod, like the readonly
in A
. I think this scenario gives the best of all worlds and is truly “Typescript-first”. I realize that it is slightly more verbose than just having a schema, but I think the benefits are well worth it. It fits much better into the established Typescript paradigm. I could go on and on about why this is a better solution.
The problem with tozod
There is just one problem with the tozod
utility. I quickly discovered, and the author of tozod
admits, that it is “pretty fragile” and can only handle the most basic types (see this comment). Even a simple union type will cause it to break. The tozod
library was a great step in the right direction, but in its current state it is virtually unusable in real application environments.
My suggestion
My suggestion is that we need something like tozod
, preferably built into Zod
, that allows schemas to be type checked against existing types in a real application environment. I don’t know if this is feasible – I’m not a Typescript expert, so it might not even be possible; but if it is possible, I think this change would be extremely beneficial.
Issue Analytics
- State:
- Created 2 years ago
- Reactions:32
- Comments:29 (5 by maintainers)
If you’re using Zod for API validation, I’d recommend looking into tRPC: https://trpc.io. No codegen, and it’s easier than going through OpenAPI as an intermediate representation. Plus if you set up a monorepo right (or if you use Next.js) you can share your Zod schemas between client and server which is a huge win. CC @fabien
As for the original question, I recommend using a utility function like this:
The nested functions may seem weird, but it’s necessary because there are two levels of generics required: the desired TypeScript type
T
and the inferred schemaS
which is constrained byextends z.ZodType<T, any, any>
. Don’t worry about what the other twoany
s are. Since we’re casting providingT
as a type hint directly, we need a separate function that lets us infer the type of S. This is because TypeScript requires you to either explicitly specify all generic parameters or let them all get inferred. You can’t mix and match, though there’s a proposal to support that here: https://github.com/microsoft/TypeScript/issues/26242This helper returns the schema as-is and the typechecker makes sure schema
S
validates the typeT
. CC @fabien0102 @derekparsons718Thanks to @ridem I made a version that error if a schema property doesn’t exist in the type to implement.
.nullish()
or.optional().nullable()
or just.nullable()
/.optional()
.All types seems preserved (even unions made with
z.enum(["val1", "val2"] as const)
).@karlhorky give it a try to that 😉 I use this with Prisma model, I’m now confident about my types