-
-
Notifications
You must be signed in to change notification settings - Fork 1.2k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Typecheck schemas against existing types #372
Comments
Just to add some ideas, I'm actually using the following pattern to compare zod & typescript import { z } from "zod"
function expectType<T>(_: T) {
/* noop */
}
type Something = string;
const somethingSchema = z.string();
type SomethingFromZod = z.infer<typeof somethingSchema>;
expectType<Something>({} as SomethingFromZod);
expectType<SomethingFromZod>({} as Something); // Please note that we need to try both directions! I tried export type Equal<X, Y> =
(<T>() => T extends X ? 1 : 2) extends
(<T>() => T extends Y ? 1 : 2) ? true : false but had some problem with At the end this simple empty function was the most reliable (I tried on 500ish type definitions to find edge cases on my ts-to-zod generator) But, I do agree, a little |
@fabien0102 Your workaround is a great idea! I hadn't considered doing anything like that and it is good to know such a workaround is available. It is not ideal, but certainly better than nothing! I wonder how feasible it is to implement something like this in large application? I'm excited to try it out. I love your second idea of improving the |
@derekparsons718 For a large application, my current plan is the following:
I'm currently working on adding a generated zod middleware on my fetchers, but works a bit to well, I'm waiting for some fixes on my backend 😁 So far this is for the frontend part, and utilized restful-react / open-api for the types generation, but I'm working on a more agnostic solution with a friend, still around open-api to generate some type definitions but not dependant to restful-react (even if you can already use restful-react just to generate the types (https://github.com/contiamo/restful-react#only-generating-custom-code-no-react-hookscomponents)) To summarized, two choices for me: zod + I hope this can help you 😃 |
EDIT: I recommend a more rigorous solution here: #372 (comment) I recommend using a utility function like this: const schemaForType = <T>() => <S extends z.ZodType<T, any, any>>(arg: S) => {
return arg;
};
// use like this:
const dog = schemaForType<Dog>()(
z.object({
name: z.string(),
neutered: z.boolean(),
})
); The nested functions may seem weird, but it's necessary because there are two levels of generics required: the desired TypeScript type This helper returns the schema as-is and the typechecker makes sure schema 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 |
@colinhacks Wow, that utility function seems to work perfectly! (Typescript was not happy about that second Unfortunately I have moved to other projects where I can't easily implement this in a large application for a while. But I don't see why it wouldn't work. Is it feasible to add this utility function to the official zod package? That would be the icing on the cake! |
(Only tested in Zod3) In case this helps someone else, for simple cases I've also found it useful to work with shapes to describe existing types that are to be used with This makes autocompletion work as expected (keys from your type are suggested for your schema), and I've found it convenient to deal with simple object merges: Given type Dog = {
name: string;
neutered: boolean;
remarks?: string;
}; and a utility that conforms to type ZodShape<T> = {
// Require all the keys from T
[key in keyof T]-?: undefined extends T[key]
? // When optional, require the type to be optional in zod
z.ZodOptionalType<z.ZodType<T[key]>>
: z.ZodType<T[key]>;
}; You can typecheck against existing types in the following way: const dogShape: ZodShape<Dog> = {
name: z.string(),
neutered: z.boolean(),
remarks: z.string().optional(),
};
const dogSchema = z.object(dogShape); Then if you care about dealing with the original shape, you can use the trick from #372 (comment), but with shapes: const shapeForType = <T>() => <S extends ZodShape<T>>(arg: S) => {
return arg;
};
...
const dogShape = shapeForType<Dog>()({
name: z.string(),
neutered: z.boolean(),
remarks: z.string().optional()
});
const dogSchema = z.object(dogShape); That way you'd still have autocompletion and the distinction between optional and required keys. |
@ridem that's basically a less complete version of the It's not ideal because to lose internal type information for each element of the ZodObject (since everything gets casted to ZodType): But if it's good enough for your needs, then by all means use that approach! Nothing inherently wrong with it, it just has limitations. |
Yes, no matter how fancy the conversion utility needs to be, thinking in terms of shapes has been the most efficient way for us to implement zod with existing types, so I just wanted to share this on this thread too. What I shared is definitely not a way to convert types to zod, just a way to go about checking they're in sync and writing zod. |
@colinhacks Just out of curiosity, what info is lost in 'shapeForType' compared to what you suggested in #372 (comment) ? |
type Dog = {
name: string;
neutered: boolean;
};
const myObject: toZod<Dog> = z.object({
name: z.string(),
neutered: z.boolean()
});
type ZodShape<T> = {
// Require all the keys from T
[key in keyof T]-?: undefined extends T[key]
? // When optional, require the type to be optional in zod
z.ZodOptionalType<z.ZodType<T[key]>>
: z.ZodType<T[key]>;
};
const yourShape: ZodShape<Dog> = {
name: z.string(),
neutered: z.boolean(),
}
;
const yourObject = z.object(yourShape)
myObject.shape.name; // ZodString
myObject.shape.name.max(5) // has string methods
yourObject.shape.name; // ZodType
// can't use ZodString-specific methods without
// TypeScript getting mad |
@derekparsons718 Oh yeah, that code assumes Zod 3 (which has three parameters on ZodType). Good call. |
@colinhacks any way to get this to work? Tried |
@mmahalwy This is the recommended approach : #372 (comment) |
For anyone looking to validate the input schema against an existing type, here's the small tweak I made to #372 (comment) that works great: const inputSchemaForType =
<TInput>() => <S extends z.ZodType<any, any, TInput>>(arg: S) => {
return arg;
};
// Then test it against an input schema
const InputSchema = z.string().transform((value) => Number.parseInt(value, 10));
// Validates
inputSchemaForType<string>()(InputSchema); |
Is there a way to make #372 (comment) require optional keys also be in the schema (as optional)? |
@colinhacks It seems the recommended approach #372 (comment) allows additional object properties: const schemaForType = <T>() => <S extends z.ZodType<T, any, any>>(arg: S) => {
return arg;
};
const extra = schemaForType<{foo:string}>()(
z.object({
foo:z.string(),
bar: z.number(),
})
); |
@colinhacks was there a reason why this wouldn't work in place of nested functions? const schemaForType = <Type, Schema = z.ZodType<Type, SAFE_ANY, SAFE_ANY>>(schema: Schema) => {
return schema;
}; EDIT: Usage example export interface Folder {
type: 'Folder';
id: number;
createdAt: string;
ownerId?: string;
name: string;
}
export const folderSchema = schemaForType<Folder>(
z.object({
type: z.literal('Folder').default('Folder'),
id: z.number(),
createdAt: z.string(),
ownerId: z.string().optional(),
name: z.string(),
})
); |
Ah yeah, this is a pretty important point! Maybe it would be possible with a utility like For now, because there is no robust solution to use existing types built in to Zod, I'm staying away from the library. Don't want to rewrite all of our TS types in a non-standard, proprietary syntax - high risk if Zod becomes unmaintained or for some other reason becomes a bad choice in the future. But if there was a robust, built-in solution for validating schemas against existing TS types, would love to give Zod another look!! |
@colinhacks I'm in love with Zod, the biggest issue I'm facing right now is validating objects against Prisma types. Mainly the fact that Prisma types return nullable but my API needs to allow for optional. Is there any way of letting Zod know it can be optional or transforming the value? type Dog = {
name: string;
remark: boolean | null;
};
const dogPrismaSchema: toZod<Dog> = z.object({
name: z.string(),
// I need this to be optional without throwing an error.
remark: z.boolean().nullable(),
}); |
@TechnoPvP how about |
Thanks to @ridem I made a version that error if a schema property doesn't exist in the type to implement.
type Implements<Model> = {
[key in keyof Model]-?: undefined extends Model[key]
? null extends Model[key]
? z.ZodNullableType<z.ZodOptionalType<z.ZodType<Model[key]>>>
: z.ZodOptionalType<z.ZodType<Model[key]>>
: null extends Model[key]
? z.ZodNullableType<z.ZodType<Model[key]>>
: z.ZodType<Model[key]>;
};
export function implement<Model = never>() {
return {
with: <
Schema extends Implements<Model> & {
[unknownKey in Exclude<keyof Schema, keyof Model>]: never;
}
>(
schema: Schema
) => z.object(schema),
};
}
// usage
export type UserModel = {
id: string
phoneNumber: string
email: string | null
name: string
firstName: string
companyName: string
avatarURL: string
createdAt: Date
updatedAt: Date
}
export const UserModelSchema = implement<UserModel>().with({
id: z.string(),
phoneNumber: z.string(),
email: z.string().email().nullable(),
name: z.string(),
firstName: z.string(),
avatarURL: z.string(),
companyName: z.string(),
createdAt: z.date(),
updatedAt: z.date(),
}); All types seems preserved (even unions made with @karlhorky give it a try to that ;) |
@colinhacks what do you think of @rphlmr's approach above? Do you think this could be integrated into Zod for first-class type comparison support? |
The only thing I can't type is |
@rphlmr This is exactly what I was looking for. Nice job! |
@rphlmr great solution, thank you!!!!! I'm quite new to Zod and I am trying to cover the discriminated unions too, but I cannot come to a solution. For instance, starting from your type User = {
id: number;
name?: string;
dob: Date;
color: 'red' | 'blue';
};
type HawaiianPizzaUser = User &
(
| {
country: 'italy';
dontLikeHawaiianPizza: true;
}
| {
country: 'us';
likeHawaiianPizza: true;
}
);
export const hawaiianPizzaUserSchema = implement<HawaiianPizzaUser>().with(
{
// ... ???
}
); can you think of anything? |
I'm trying to make a required field where the value can be undefined, which maybe zod can't do?. Despite the zod bug, I get the same
|
const customObject = <Base>(
shape: Record<keyof Base, ZodTypeAny>,
params?: z.RawCreateParams
) => z.object(shape as ZodRawShape, params); |
As an alternative to the solution posted in #372 (comment) , with the main focus of working with const parse = <T extends object>() => ({
with: <S extends { [P in keyof S]: P extends keyof T ? ZodTypeAny : never } & Record<keyof T, ZodTypeAny>>(
shape: S
) => z.object(shape)
})
// ? Example
interface Person {
name: string
age: number
}
const parser = parse<Person>().with({
name: z.string(),
age: z.number() // removing this line will prompt TS to complain
// address: z.string() // adding this line will prompt TS to complain
})
parser.parse({ name: 'Juanito', age: 35}) Feels simpler to me in terms of not needing to define custom complex / nested generic types, and following |
Is there anyway to make #372 (comment) work when nested properties are added by mistake? For example: interface Person {
age: number;
name: {
first: string;
last: string;
};
}
export const test = implement<Person>().with({
age: z.number(),
mistake: z.string(), // This prompts TS to complain
name: z.object({
first: z.string(),
last: z.string(),
nestedMistake: z.string(), // This does NOT prompt TS to complain
}),
}); |
Are we at some solid solution? |
any solution here ? |
Not sure if anyone is still following here, but I was able to get to a reasonable solution that handles defaults and works a bit better than the solutions above for me with giving you richer type-assist like suggesting the keys and works somewhat recursively. type ZodPrimitive<T> = T extends string
? z.ZodString | z.ZodEnum<any>
: T extends number
? z.ZodNumber
: T extends boolean
? z.ZodBoolean
: z.ZodAny;
type ZodType<T> = T extends Array<infer U>
? z.ZodArray<z.ZodObject<ZodShape<U>>>
: T extends object
? z.ZodObject<ZodShape<T>>
: ZodPrimitive<T>;
type OptionalKeys<T> = {
[P in keyof T]-?: undefined extends T[P] ? P : never;
}[keyof T];
type MaybeZodDefault<T extends z.ZodTypeAny> = z.ZodDefault<T> | T;
type ZodShape<T> = {
[K in keyof T]: MaybeZodDefault<
K extends OptionalKeys<T>
? z.ZodOptional<ZodType<T[K]>> | ZodType<T[K]>
: ZodType<T[K]>
>;
}; Curious if anybody here has suggestions on how to make this more succinct, but it seems to be working pretty well for me. I use it together with the satisfies solution above for maximum type safety like so interface Person {
name: string;
email?: string;
}
const schema = z.object({
name: z.string(),
email: z.string().email().optional()
} satisfies ZodShape<Person>) satisfies ZodType<Person> it works recursively, though sometimes in deeper hierarchy you might need more satisfies hints lower down. I also opted to allow the schema to be more restrictive than the base type, allowing it to require fields that are otherwise optional because I needed that for my use case. |
@mshafir oh that sounds pretty great! Would you be open to creating a PR so that @colinhacks could take a look and it could be pulled into Zod as a core feature? |
WOW, how should newbies to zod figure out which of the above solution or a mix of solution should be used? is this somehow in the radar of Zod maintainers at least to put the recommended solution in the docs? Update {
"strictNullChecks": true,
"strict": true
} for whoever is coming next, don't do my mistake, you have first to enable those two flags in your tsconfig and THEN try the above solutions |
I modified the solution in #372 (comment) to allow use of the preprocess utility. NOTE: the use of
|
Relevant to this discussion; this same question was asked over in Typebox... the answer:
sinclairzx81/typebox#317 (comment) Also, about |
@kalnode this is an incorrect assumption. It's quite possible to check a runtime schema against a type - the work just needs to be done and published. Check out Yup's "Ensuring a schema matches an existing type": |
Not sure if this will satisfy anyone else's requirements, but I found this incredibly simple syntax provided me with the errors I wanted when the types diverge. const addsEventSchema: ZodSchema<PublishEventArguments> = z.tuple([
z.string(),
z.record(z.string(), z.unknown()).optional(),
]); |
My solution: import { z } from "zod";
type ZodTypeValue<Value, ZodType extends z.ZodType> = undefined extends Value
? null extends Value
? z.ZodNullableType<z.ZodOptionalType<ZodType>>
: z.ZodOptionalType<ZodType> | z.ZodDefault<ZodType>
: null extends Value
? z.ZodNullableType<ZodType>
: ZodType;
type SameOutputAndInputShape<Output> = {
[K in keyof Output]: ZodTypeValue<Output[K], z.ZodType<Output[K]>>;
};
type AnotherOutputAndInputShape<Output, Input> = {
[K in keyof (Output | Input)]: ZodTypeValue<
Input[K],
z.ZodType<Output[K], z.ZodTypeDef, Input[K]>
>;
};
export const createSchema = <Output, Input = null>(
shape: Input extends null
? Required<SameOutputAndInputShape<Output>>
: Required<AnotherOutputAndInputShape<Output, Input>>,
) => {
return z.object(shape);
}; [x] Intellisense of schema properties only simple usage: interface UserData {
name: string;
email: string;
}
// invalid case (unknown email2 property)
const userDataCreateSchemaError = createSchema<UserData>({
name: z.string(),
email: z.string(),
email2: z.string()
});
// ok
const userDataCreateSchemaOk = createSchema<UserData>({
name: z.string(),
email: z.string(),
}); example with transformations: class Name {
public name: string;
constructor(name: string) {
this.name = name.trim();
}
}
interface UserData {
name: Name;
email: string;
}
interface UserDataCreate {
name: string;
email: string;
}
// invalid case (should receive Name instead of string)
const userDataCreateSchema = createSchema<UserData>({
name: z.string().transform(val => new Name(val)),
email: z.string(),
});
// ok (receive and return Name)
const userDataCreateSchema2 = createSchema<UserData>({
name: z.instanceof(Name),
email: z.string(),
});
// ok (receive string, return Name)
const userDataCreateSchema3 = createSchema<UserData, UserDataCreate>({
name: z.string().transform(val => new Name(val)),
email: z.string(),
}); |
Just in case someone stumbles here looking for a relatively easy solution: interface Foo {
bar: string;
baz: number;
}
type ZodOutputFor<T> = z.ZodType<T, z.ZodTypeDef, unknown>; // ignore the input type
export const FooSchema = z.object({
bar: z.string().default("default"), // Notice this default, this would fail if using satisfies z.ZodType<Foo>
baz: z.number(),
}) satisfies ZodOutputFor<Foo>; // OK |
Only using interface Person {
name: string;
email?: string;
}
const schema = z.object({
name: z.string(),
email: z.string().email().optional()
}) satisfies z.ZodSchema<Person> |
Satisfies only check that all fields of are included, but it does not fails if more fields are added to z.object interface Person {
name: string;
email?: string;
}
const schema = z.object({
name: z.string(),
email: z.string().email().optional(),
foo: z.string(), // this does not fail
}) satisfies z.ZodSchema<Person> Is there a better type check here |
Thank you @scamden, your answer (using |
It's laughable that zod can't handle this. Literally the only use for a library like this and it's not able to do it. Now it's just a library for those who like to bloat their codebase with useless definitions all over the place and writing everything twice or more. TypeScript already added this bloat, now you can do it again with zod! What's wrong with the coding community? |
@rphlmr Thanks for your work (#372 (comment)), it has been a huge help in finding a way to create type-safe validation schemas from model types, instead of working the other way around. Your solution looked fine at first, and it has been very helpful. However, it still has a few issues. If we let TypeScript decide whether our schema extends the required schema, it will be too lax. Some examples:
implement<{ a: string | number }>().with({
b: z.string(), // no error...
});
implement<{ b: 'x' | 'y' | 'z' }>().with({
b: z.literal('x'), // no error...
}); Basically, the main problem comes down to this: if our validation extends the required validation, TS will say that's fine, e.g. This is why I took some time to experiment with all solutions mentioned above and came up with something more comprehensive: import { z } from 'zod';
export type InnerZodType<T> = T extends z.ZodType<infer U, any, any> ? U : never;
type ZodTypeValue<Value, ZodType extends z.ZodType> = undefined extends Value
? null extends Value
// Nullable and optional. This supports `.optional().nullable()`, `.nullable().optional()` and `.nullish()`.
// REVIEW: Do we need z.ZodDefault<ZodType> here too?
? z.ZodNullableType<z.ZodOptionalType<ZodType>> | z.ZodOptionalType<z.ZodNullableType<ZodType>>
// Optional, not nullable. This requires `.optional()`.
: z.ZodOptionalType<ZodType> | z.ZodDefault<ZodType>
: null extends Value
// Nullable, not optional. This requires `.nullable()`.
? z.ZodNullableType<ZodType>
// Not nullable, not optional. Leave as is.
: ZodType;
type SchemaFromIO<
OutputModel extends Record<string, unknown>,
InputModel extends Record<string, unknown>,
> = {
[K in keyof (OutputModel | InputModel)]: ZodTypeValue<
InputModel[K],
z.ZodType<OutputModel[K], z.ZodTypeDef, InputModel[K]>
>;
};
type IsExactly<T, U> = [T] extends [U] ? [U] extends [T] ? true : false : false;
export type ModelMatchesSchema<
Model extends Record<string, unknown>,
Schema extends z.ZodObject<Record<string, z.ZodTypeAny>>,
SchemaModel = z.infer<Schema>,
> = IsExactly<Model, SchemaModel>;
/**
* Validates if the calculated schema for a model and the
* provided schema are 100% the same.
*
* The `extends` check TypeScript normally does is too lax
* for this purpose. When a model says property `a` is `string | number`,
* TypeScript will allow `z.string()` and `z.number()` as well,
* while it should have been `z.union([z.string(), z.number()])`.
*
* This function makes sure the provided schema extends the
* calculated schema, but also the other way around.
*/
type ValidateSchema<
CalculatedSchema extends Record<string, z.ZodTypeAny>,
ProvidedSchema extends Record<keyof CalculatedSchema, z.ZodTypeAny>,
> = {
[K in keyof CalculatedSchema]: IsExactly<InnerZodType<CalculatedSchema[K]>, InnerZodType<ProvidedSchema[K]>> extends false
? `Error: Incomplete validation` & {
property: K;
expectedValidation: InnerZodType<CalculatedSchema[K]>;
actualValidation: InnerZodType<ProvidedSchema[K]>;
}
: ProvidedSchema[K];
};
export function createSchema<
// A definition of the output model (after transforms).
OutputModel extends Record<string, unknown> = never,
// Optional: a definition of the input model (before transforms). Defaults to `OutputModel`.
InputModel extends Record<string, unknown> = OutputModel,
// We calculate a schema based on the provided models.
CalculatedSchema extends Record<string, z.ZodTypeAny> = Required<SchemaFromIO<OutputModel, InputModel>>,
> () {
return <
// Now we "catch" the provided schema in this generic.
ProvidedSchema extends Record<keyof CalculatedSchema, z.ZodTypeAny> & {
// Disallow non-existing keys.
[unknownKey in Exclude<keyof ProvidedSchema, keyof OutputModel>]: never;
},
>(
/*
This intersection makes it possible to double check the
provided schema with the calculated schema.
We actively check if they are 100% the same, instead of
just relying on TypeScript's subtype system, using
the `ValidateSchema` helper.
*/
schema: ProvidedSchema & ValidateSchema<CalculatedSchema, ProvidedSchema>,
) => {
const result = z.object(schema as ProvidedSchema);
return result as ModelMatchesSchema<OutputModel, typeof result> extends true ? typeof result : never;
};
} Now we can be absolutely sure that we create a validation schema that is comprehensively type checked against the model: type Model = {
a?: boolean;
b: 'x' | 'y' | 'z';
c: 'x' | 'y' | 'z';
d: 'x' | 'y' | 'z';
e: 'x' | 'y' | 'z';
f: 'x' | 'y' | 'z';
g: 'x' | 'y' | 'z';
h: string | number;
i: string | number;
j: string | number;
x: 1 | 2 | 3;
};
// `typeof schema` -> `never`, because we have errors
const schema = createSchema<Model>()({
a: z.boolean().optional(),
// @ts-expect-error -- forgot 'z'
b: z.enum(['x', 'y']),
c: z.enum(['x', 'y', 'z']),
// @ts-expect-error -- 'zzzzz' is not a subtype of 'x' | 'y' | 'z'
d: z.enum(['x', 'y', 'zzzzz']),
// @ts-expect-error -- forgot 'z'
e: z.union([z.literal('x'), z.literal('y')]),
f: z.union([z.literal('x'), z.literal('y'), z.literal('z')]),
// @ts-expect-error -- 'zzzzz' is not a subtype of 'x' | 'y' | 'z'
g: z.union([z.literal('x'), z.literal('y'), z.literal('zzzzz')]),
// @ts-expect-error -- should be z.union([z.string(), z.number()])
h: z.string(),
// @ts-expect-error -- should be z.union([z.string(), z.number()])
i: z.number(),
j: z.union([z.string(), z.number()]),
x: z.union([z.literal(1), z.literal(2), z.literal(3)]),
// // @ts-expect-error -- 'z' should not be defined
// z: z.string(),
}); I also took some time to create (somewhat) meaningful error messages. Curious to hear what others think of the implementation above. I might create a package out of this if this is indeed more complete than any of the other solutions mentioned above. |
Having something published would be incredible! Maybe you could consider creating PR for it to Zod core itself instead - this should be a built-in feature. |
@karlhorky I would love to do that, but maybe @colinhacks could let us know if a PR is welcome first. Personally, I think this adds a lot of value to Zod. Coming from Yup and being used to writing For the record, I'm writing tests as we speak, so that would of course be included in given PR. |
Ok great, I think Colin has not been very active on this issue (closed issue, maybe he has a filter for these). But he has been very active in reviewing PRs. I tried to reach out privately to him to see whether he can reply here. In the meantime, I would suggest trying to get feedback via opening a draft PR with preliminary, unpolished changes. |
It's true I haven't looked into this issue for a long time, since my earlier solution is probably fine for most cases. Plus trying to do this at all ("define a Zod schema matching type T") is almost always an anti-pattern. If you want Zod schemas that agree with some external source of typesafety (Prisma, etc), you should be codegen-ing those schemas. If your codegen tool is reliable then you can have confidence that the types match up without any extra shenanigans. Writing schemas y hand is duplicative. I recommend trying to avoid the need for this. That said if you want TypeScript to help you define a Zod type that exactly matches some known type, I've since found a decent pattern for achieving this. It's a more rigorous version of what I linked to a few years ago. import { z } from "zod";
// details aren't important but this is a simple way to check that two types
// are exactly equivalent
export type AssertEqual<T, U> = (<V>() => V extends T ? 1 : 2) extends <
V,
>() => V extends U ? 1 : 2
? true
: false;
const matches =
<T>() =>
<S extends z.ZodType<T, z.ZodTypeDef, unknown>>(
schema: AssertEqual<S["_output"], T> extends true
? S
: S & {
"types do not match": {
expected: T;
received: S["_output"];
};
},
): S => {
return schema;
};
// example
type User = {
name: string;
age?: number;
};
// the User schema type is properly inferred as ZodObject
const User = matches<User>()(
z.object({
name: z.string(),
age: z.number().optional(),
// extra: z.string(), // uncomment to see error
}),
); This ensures that the Zod schema type exactly matches the type you pass in. It doesn't allow extra properties. Optional properties in the target interface are still required to exist. I believe this meets all the constraints others have expressed in this thread. It's also a lot more elegant than some of the recursive type aliases others have come up with (impressive as they are). |
Hmm, interesting. Code generation for schemas sounds nice in theory. But I guess it would not allow for any custom validation beyond super simple types. (happy to be proven wrong on this, if there is a way to "annotate" TS types with Zod custom validation, so that codegen works - I like the idea of things like It seems like Zod schemas are almost always duplicative by design, because of this fact. So, working from the assumption that Zod schemas are duplicative by design, then it's important to try to enforce a matching contract between the two duplicated things. Our Use CaseFor us, the TypeScript entity types come from our database code and should be the single source of truth. They sometimes rely on foreign key properties and third party types. Creating types only from Zod schemas is not always possible because of these extra database fields and third party dependencies. (Simplified) example repo: https://github.com/upleveled/next-js-example-spring-2024-atvie/
Comparing the types from 1 to 2 seems like a reasonable pattern to create guardrails ensuring that the schemas match our entity types. What about only relying on parameter type checks in the database query functions?It could be argued that it may be enough that the valid data in 4 would be checked against the parameter types of the database query functions. I think it almost is - you would still get errors at some point in your code. But the errors are further away from the source of the problem (the schema), so this makes for a worse DX. |
@colinhacks maybe you can consider the above and whether code generation should really be a good option for all projects, also beyond simple types. Resolving / Closing this IssueTo finally resolve and close this issue, it would be interesting to get a documented official position from Zod on the issue of "Synchronizing Zod schemas with an external source of truth" (not buried in a closed issue here, but more accessibly, publicly documented somewhere). The 2 options I can see right now are:
Would you be open to a PR for either of these options? |
@colinhacks Brilliant work! Your solution even understands (discriminative) unions and intersections, that's fantastic. Including this into Zod would be perfect. Maybe calleld something like I don't think code generation is always the better solution. My case isn't about having external model types and trying to generate validation schema's for it. For me, model definitions are more easily readable and more important than the validation schemas. I see validation as secondary to models. Models are portable, validation libraries sometimes need replacing when more modern alternatives arise. That's why I don't see this as an anti-pattern. Luckily, you've found a solution that serves users that feel the same way. I agree with @karlhorky this issue needs an ending. Personally, I would prefer having the |
@colinhacks This latest solution works great, but I have 1 issue with it. The error message is lacking some clarity in my opinion: It doesn't clearly state that the On the other hand, with a solution I made that mixes both of your import { z } from 'zod'
type isAny<T> = [any extends T ? 'true' : 'false'] extends ['true'] ? true : false
type nonoptional<T> = T extends undefined ? never : T
type nonnullable<T> = T extends null ? never : T
type equals<X, Y> = [X] extends [Y] ? ([Y] extends [X] ? true : false) : false
type zodKey<T> =
isAny<T> extends true
? 'any'
: equals<T, boolean> extends true //[T] extends [booleanUtil.Type]
? 'boolean'
: [undefined] extends [T]
? 'optional'
: [null] extends [T]
? 'nullable'
: T extends any[]
? 'array'
: equals<T, string> extends true
? 'string'
: equals<T, bigint> extends true //[T] extends [bigintUtil.Type]
? 'bigint'
: equals<T, number> extends true //[T] extends [numberUtil.Type]
? 'number'
: equals<T, Date> extends true //[T] extends [dateUtil.Type]
? 'date'
: T extends { [k: string]: any } //[T] extends [structUtil.Type]
? 'object'
: 'rest'
export type ToZod<T> = {
any: never
optional: z.ZodOptional<ToZod<nonoptional<T>>>
nullable: z.ZodNullable<ToZod<nonnullable<T>>>
array: T extends Array<infer U> ? z.ZodArray<ToZod<U>> : never
string: z.ZodString
bigint: z.ZodBigInt
number: z.ZodNumber
boolean: z.ZodBoolean
date: z.ZodDate
object: z.ZodObject<{ [k in keyof T]: ToZod<T[k]> }>
rest: z.ZodType<T>
}[zodKey<T>]
export type ZodShape<T> = {
// Require all the keys from T
[key in keyof T]-?: ToZod<T[key]>
}
type User = {
name: string
age?: number
}
const userShape: ZodShape<User> = {
name: z.string(),
age: z.number().optional(),
// extra: z.string(), // uncomment to see error
}
const userSchema = z.object(userShape) |
TL;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.
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:
In order to get the effect of my second requirement, I discovered that I need to replace my existing code, eg...
...with something like this...
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 toaSchema
. 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:
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
solutionA 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). Thetozod
utility allows me to write the following in place of the above code example: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 thereadonly
inA
. 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 oftozod
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. Thetozod
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 intoZod
, 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.The text was updated successfully, but these errors were encountered: