Skip to content
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

Closed
derekparsons718 opened this issue Mar 30, 2021 · 72 comments
Closed

Typecheck schemas against existing types #372

derekparsons718 opened this issue Mar 30, 2021 · 72 comments

Comments

@derekparsons718
Copy link

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.

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:

  1. Zod schemas would provide run time checks of my data types. [true]
  2. 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.

@fabien0102
Copy link

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 tsd and the following helper before:

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 Record<string, any> vs {[key: string]: any} comparison

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 toZod<A> sounds amazing to avoid this ugly checking 😁

@fabien0102
Copy link

In regard to the API, I don't think we need a new toZod, if possible, we should just improve the safety of z.ZodSchema<>

image

@derekparsons718
Copy link
Author

@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 ZodSchema type as well. I agree that it is probably a better alternative than a new tozod.

@fabien0102
Copy link

@derekparsons718 For a large application, my current plan is the following:

  • Every user inputs (forms, url query, localstorage) are validated with zod, and I'm just inferring the types with z.infer<> to have only one source of truth
  • All my backend response types are generated from the open-api specs (with restful-react) and from those types I'm generating the zod schema (with ts-to-zod), so both zod & types are in sync because they are generated at the same time.

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 + z.infer<> or fully generated to have types & zod in sync

I hope this can help you 😃

@colinhacks
Copy link
Owner

colinhacks commented Apr 25, 2021

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 T and the inferred schema S which is constrained by extends z.ZodType<T, any, any>. Don't worry about what the other two anys are. Since we're casting providing T 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: microsoft/TypeScript#26242

This helper returns the schema as-is and the typechecker makes sure schema S validates the type T. CC @fabien0102 @derekparsons718


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

@derekparsons718
Copy link
Author

@colinhacks Wow, that utility function seems to work perfectly! (Typescript was not happy about that second any, saying that ZodType requires 1 or 2 arguments instead of 3, but I just removed that last any and it seems to work fine.) Based on the quick experiments I did, this is exactly what I am looking for! It's beautiful.

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!

@ridem
Copy link

ridem commented Apr 30, 2021

(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 z.object().

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: z.object(...shape1, ...shape2).

Given

type Dog = {
  name: string;
  neutered: boolean;
  remarks?: string;
};

and a utility that conforms to ZodRawShape:

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.

@colinhacks
Copy link
Owner

@ridem that's basically a less complete version of the toZod utility mentioned earlier on this thread, which tries to map a typescript type to a Zod schema type: https://github.com/colinhacks/tozod/blob/master/src/index.ts

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.

@ridem
Copy link

ridem commented May 1, 2021

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.

@ridem
Copy link

ridem commented May 1, 2021

@colinhacks Just out of curiosity, what info is lost in 'shapeForType' compared to what you suggested in #372 (comment) ?

@colinhacks
Copy link
Owner

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

@colinhacks
Copy link
Owner

Typescript was not happy about that second any, saying that ZodType requires 1 or 2 arguments instead of 3, but I just removed that last any and it seems to work fine

@derekparsons718 Oh yeah, that code assumes Zod 3 (which has three parameters on ZodType). Good call.

@mmahalwy
Copy link

mmahalwy commented Jul 9, 2021

@colinhacks any way to get this to work? Tried ZodSchema and its throwing a number of errors :S

@colinhacks
Copy link
Owner

@mmahalwy This is the recommended approach : #372 (comment)

@mutewinter
Copy link

mutewinter commented Aug 9, 2021

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);

@BrettBedarf
Copy link

Is there a way to make #372 (comment) require optional keys also be in the schema (as optional)?

@BrettBedarf
Copy link

@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(),
    })
);

@cdierkens
Copy link

cdierkens commented Mar 9, 2022

@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(),
  })
);

@karlhorky
Copy link
Contributor

@colinhacks It seems the recommended approach #372 (comment) allows additional object properties:

Ah yeah, this is a pretty important point!

Maybe it would be possible with a utility like Exact from ts-toolbelt - preventing the extra properties... 🤔

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!!

@adamghowiba
Copy link

adamghowiba commented Jun 24, 2022

@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(),
});

@ASR4
Copy link

ASR4 commented Sep 28, 2022

@TechnoPvP how about z.boolean().optional();

@rphlmr
Copy link

rphlmr commented Oct 16, 2022

Thanks to @ridem I made a version that error if a schema property doesn't exist in the type to implement.

  • handles requirement for .nullish() or .optional().nullable() or just .nullable() / .optional().
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 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

@karlhorky
Copy link
Contributor

karlhorky commented Oct 17, 2022

@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?

@rphlmr
Copy link

rphlmr commented Oct 17, 2022

The only thing I can't type is .nullable().optional() 🤷‍♂️ It must be .optional().nullable() or .nullish().
Not a big deal I think.

@R-Bower
Copy link

R-Bower commented Oct 18, 2022

@rphlmr This is exactly what I was looking for. Nice job!

@NoriSte
Copy link

NoriSte commented Nov 11, 2022

@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 User type

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?

@AndrewRayCode
Copy link

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 unwrap error when try to implement it:

const schema = implement<MySchema>().with({
  description: z.string().or(z.undefined()),
});

Property 'unwrap' is missing in type 'ZodUnion<[ZodString, ZodUndefined]>' but required in type 'ZodOptional<ZodType<string | undefined, ZodTypeDef, string | undefined>>'.ts(2741)
types.d.ts(767, 5): 'unwrap' is declared here.

@wilgnne
Copy link

wilgnne commented Jul 14, 2023

const customObject = <Base>(
  shape: Record<keyof Base, ZodTypeAny>,
  params?: z.RawCreateParams
) => z.object(shape as ZodRawShape, params);

@espetro
Copy link

espetro commented Aug 22, 2023

As an alternative to the solution posted in #372 (comment) , with the main focus of working with zod objects and typescritp types/interfaces:

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 zod's parse, don't validate nomenclature 😄

@BenJackGill
Copy link

BenJackGill commented Oct 9, 2023

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
  }),
});

@SwapnilSoni1999
Copy link

Are we at some solid solution?

@LeulAria
Copy link

any solution here ?

@mshafir
Copy link

mshafir commented Jan 4, 2024

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.

@karlhorky
Copy link
Contributor

@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?

@Nilegfx
Copy link

Nilegfx commented Jan 8, 2024

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
I ended up using the most simple approach in #372 (comment) and enabling those two configs in my tsconfig.json

   {
    "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

@KumailB
Copy link

KumailB commented Jan 14, 2024

I modified the solution in #372 (comment) to allow use of the preprocess utility.

NOTE: the use of unknown is because the user-defined function in the preprocess utility has an unknown return type.

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
      // Added a union to allow preprocess utility
      z.ZodOptionalType<z.ZodType<T[key]>> | z.ZodEffects<z.ZodOptionalType<z.ZodType<T[key]>>, T[key],  unknown>
    : z.ZodType<T[key]> | z.ZodEffects<z.ZodType<T[key]>, T[key], unknown>;
};

@kalnode
Copy link

kalnode commented Feb 3, 2024

Relevant to this discussion; this same question was asked over in Typebox... the answer:

I'd like to make a Typebox type based on a Typescript type or an interface. Is it possible?

Unfortunately this isn't possible in TypeScript as all type information is erased on compilation. Because type information is erased, there is nothing available to compute the Type.Array(Type.Number()) at runtime. However...

sinclairzx81/typebox#317 (comment)


Also, about toZod, other than it being "pretty fragile" and "can only handle the most basic types", isn't an obvious annoyance that you must write things twice, or am I wrong? First, the basic TS typing to satisfy everything we like about TS, then the Zod typing to satisfy runtime type validation.

@karlhorky
Copy link
Contributor

Unfortunately this isn't possible in TypeScript

@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":

@jharlow
Copy link

jharlow commented Mar 21, 2024

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(),
]);

@Leo-Henrique
Copy link

Leo-Henrique commented Mar 29, 2024

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
[x] No permission to specify unknown properties
[x] Does not interfere with transformations by correctly specifying the input and return of the schema
[x] Force specify null or undefined fields

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(),
});

@aripalo
Copy link

aripalo commented Apr 12, 2024

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

@raulvictorrosa
Copy link

raulvictorrosa commented May 17, 2024

Only using satisfies should be enough, but it is something that the lib need to adapt I suppose. Instead we need to go with all this workaround just to satisfy the type.

interface Person {
  name: string;
  email?: string;
}

const schema = z.object({
   name: z.string(),
   email: z.string().email().optional()
}) satisfies z.ZodSchema<Person>

@T04435
Copy link

T04435 commented May 27, 2024

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

@francoishill
Copy link

Thank you @scamden, your answer (using z.object({ ... }) satisifes z.ZodType<Dog, any, any>) is exactly what I was looking for

@10xchs
Copy link

10xchs commented Sep 30, 2024

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?

@Anoesj
Copy link

Anoesj commented Oct 29, 2024

@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:

  • If the model requires property a to be a string | number, TS won't warn us if we use z.string() as validation for a. This is because string extends string | number.
implement<{ a: string | number }>().with({
  b: z.string(), // no error...
});
  • If the model requires property b to be 'x' | 'y' | 'z', TS will think it's fine if we use z.literal('x') only.
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. string extends string | number. But we not only need to check if string extends string | number, but also if string | number extends string.

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.

@karlhorky
Copy link
Contributor

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.

@Anoesj
Copy link

Anoesj commented Oct 29, 2024

@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 const schema = ObjectSchema<Model>({...}), this is exactly what I was missing in Zod. Now that the method above works this well, I feel safe enough to switch and make use of all the goodness Zod has compared to Yup.

For the record, I'm writing tests as we speak, so that would of course be included in given PR.

@karlhorky
Copy link
Contributor

karlhorky commented Oct 29, 2024

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.

@colinhacks
Copy link
Owner

colinhacks commented Oct 29, 2024

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).

cc @karlhorky @Anoesj

@karlhorky
Copy link
Contributor

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.

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 ts-to-zod, but it seems like they are lossy)

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 Case

For 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/

  1. Write TS entity types by hand, colocated in database migration files https://github.com/upleveled/next-js-example-spring-2024-atvie/blob/a337af304a4763f66e5a39cdaaf508a336017507/migrations/00008-createTableNotes.ts#L4-L9
    1. Side note: these are checked against PostgreSQL via SafeQL https://github.com/upleveled/next-js-example-spring-2024-atvie/blob/a337af304a4763f66e5a39cdaaf508a336017507/database/notes.ts#L20-L35
  2. Zod schemas for accepting user data, also colocated in the database migration files https://github.com/upleveled/next-js-example-spring-2024-atvie/blob/a337af304a4763f66e5a39cdaaf508a336017507/migrations/00008-createTableNotes.ts#L11-L14
  3. Using those schemas to parse and validate data sent to the API https://github.com/upleveled/next-js-example-spring-2024-atvie/blob/a337af304a4763f66e5a39cdaaf508a336017507/app/api/notes/route.ts#L23
  4. Passing that valid data to database query functions https://github.com/upleveled/next-js-example-spring-2024-atvie/blob/a337af304a4763f66e5a39cdaaf508a336017507/app/api/notes/route.ts#L37-L44

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.

@karlhorky
Copy link
Contributor

karlhorky commented Oct 30, 2024

@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 Issue

To 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:

  1. Docs PR: Recommend code generation of schemas as a path for using an alternative "source of truth" - it would be good to get 1+ officially recommended libraries or projects for this, which also support custom validation
  2. Feature PR: Build something like your solution in to Zod as a 1st-class API - with "Caveats" or "Recommendations" on the part of the docs, to outline your notes about it sometimes being an antipattern

Would you be open to a PR for either of these options?

@Anoesj
Copy link

Anoesj commented Oct 30, 2024

@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 z.schemaFor<Model>()(...) or z.validateSchemaFor<Model>(...)?

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 matches helper (or renamed) in Zod. But if it's not added, it would indeed be nice to mention this topic in the docs and provide an alternative for users.

@BobWithHair
Copy link

BobWithHair commented Nov 9, 2024

@colinhacks This latest solution works great, but I have 1 issue with it. The error message is lacking some clarity in my opinion:

image

It doesn't clearly state that the age is missing.

On the other hand, with a solution I made that mixes both of your toZod solution and the ZodShape solution from @ridem, I get a much clearer feedback of what is wrong with the zod object I'm building:

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)

image

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests