colinhacks / zod

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

Typecheck schemas against existing types #372

Closed derekparsons718 closed 3 years ago

derekparsons718 commented 3 years ago

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:

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 commented 3 years ago

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 commented 3 years ago

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 commented 3 years ago

@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 commented 3 years ago

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

I hope this can help you πŸ˜ƒ

colinhacks commented 3 years ago

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:

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: https://github.com/microsoft/TypeScript/issues/26242

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

derekparsons718 commented 3 years ago

@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 commented 3 years ago

(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 https://github.com/colinhacks/zod/issues/372#issuecomment-826380330, 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 commented 3 years ago

@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 commented 3 years ago

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 commented 3 years ago

@colinhacks Just out of curiosity, what info is lost in 'shapeForType' compared to what you suggested in https://github.com/colinhacks/zod/issues/372#issuecomment-826380330 ?

colinhacks commented 3 years ago
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 commented 3 years ago

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 commented 3 years ago

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

colinhacks commented 3 years ago

@mmahalwy This is the recommended approach : https://github.com/colinhacks/zod/issues/372#issuecomment-826380330

mutewinter commented 3 years ago

For anyone looking to validate the input schema against an existing type, here's the small tweak I made to https://github.com/colinhacks/zod/issues/372#issuecomment-826380330 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 commented 3 years ago

Is there a way to make https://github.com/colinhacks/zod/issues/372#issuecomment-830713601 require optional keys also be in the schema (as optional)?

BrettBedarf commented 3 years ago

@colinhacks It seems the recommended approach https://github.com/colinhacks/zod/issues/372#issuecomment-826380330 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 commented 2 years ago

@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 commented 2 years ago

@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 commented 2 years ago

@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 commented 1 year ago

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

rphlmr commented 1 year ago

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 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 commented 1 year ago

@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 commented 1 year ago

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 commented 1 year ago

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

NoriSte commented 1 year ago

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

stijnjanmaat commented 1 year ago

@rphlmr your exact example doesn't work for me somehow.. It gives the error:

Property 'unwrap' is missing in type 'ZodString' but required in type 'ZodNullable<ZodOptionalType<ZodType<string, ZodTypeDef, string>>>'.ts(2741)
types.d.ts(654, 5): 'unwrap' is declared here.
test.ts(26, 3): The expected type comes from property 'id' which is declared here on type 'Implements<UserModel> & {}'

All attributes of UserModelSchema give this error. I'm using zod 3.19.1. Any thoughts?

EDIT Found it! I needed to set "strictNullChecks": true, in my tsconfig.json. thanks @rphlmr for this nice little tool!

ldiego08 commented 1 year ago

@rphlmr I'm getting a weird behavior when using z.enum(['a', 'b'] as const).default('a') in my zod schema:

Type 'undefined' is not assignable to type '"a" | "b"'.

Any ideas about what might I be missing? :/

rphlmr commented 1 year ago

@ldiego08 I think it's because when you set a .default, It guesses that the field can be nullable (undefined or null).

This works πŸ‘‡

const Schema = implement<{ enum?: "a" | "b" }>().with({
  enum: z
    .enum(["a", "b"] as const)
    .default("a")
    .optional(),
});
FrenchTechLead commented 1 year ago

For the other way ( from zod schema to typescript) i'm using the following code:

const PostData = z.object({
  title: z.string(),
  description: z.string(),
  authorID: z.string(),
  keywords: z.array(z.string()),
  tags: z.array(z.string()),
  lang: z.string(),
  draft: z.boolean(),
});

export type PostType = Required<typeof PostData._type>;
kevbook commented 1 year ago
const Schema = implement<{ enum?: "a" | "b" }>().with({
  enum: z
    .enum(["a", "b"] as const)
    .default("a")
    .optional(),
});

@rphlmr - I am very new to TS; in this above example, when I remove b, it does not error. Any way to fix that?

 const Schema = implement<{ enum?: "a" | "b" }>().with({
   enum: z
     .enum(["a"] as const)
     .default("a")
     .optional(),
});
rphlmr commented 1 year ago

@kevbook I guess it's because enum? is optional. So, z.enum(xx).optional() requires at least one of the possible values but doesn't error if missing one (it still throws an error if it's not a possible value of enum?: "a" | "b"). I don't know how to enforce it to require all values in the schema, sorry πŸ₯²

swwelch commented 1 year ago

I think it's also possible to update the solution in https://github.com/colinhacks/zod/issues/372#issuecomment-826380330 to prevent additional object properties by requiring that any keys of the inferred type of S are also keys of T:

export const schemaForType =
  <T>() =>
  <
    S extends z.ZodType<T, any, any> &
      z.ZodType<
        {
          [K in keyof z.infer<S>]: K extends keyof T ? z.infer<S>[K] : never;
        },
        any,
        any
      >,
  >(
    arg: S,
  ): S => {
    return arg;
  };

const extra = schemaForType<{foo:string}>()(
    z.object({
        foo: z.string(),
    bar: z.number(),
    })
);

With error message:

    The types of '_type.bar' are incompatible between these types.
      Type 'number' is not assignable to type 'never'.ts(2345)
scamden commented 1 year ago

As for the original question, 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(),
  })
);

for anyone else finding this, it can now be done super cleanly in ts 4.9 with satisfies

 // use like this:
 const dog = z.object({
     name: z.string(),
     neutered: z.boolean(),
   }) satisifes z.ZodType<Dog, any, any>;
karlhorky commented 1 year ago

it can now be done super cleanly in ts 4.9 with satisfies

Oh wow, this satisfies example looks great! Very simple and readable.

@colinhacks should this be the official, documented solution for this problem? Are there any downsides to this vs. @rphlmr's approach from his comment above?

@rphlmr also posted a discussion about getting his approach added as a helper to Zod, but it has not received any feedback.

rphlmr commented 1 year ago

it can now be done super cleanly in ts 4.9 with satisfies

Oh wow, this satisfies example looks great! Very simple and readable.

@colinhacks should this be the official, documented solution for this problem? Are there any downsides to this vs. @rphlmr's approach from his comment above?

@rphlmr also posted a discussion about getting his approach added as a helper to Zod, but it has not received any feedback.

I have tonnes of schemas in my app, will give a feedback tomorrow 🀩

ryoppippi commented 1 year ago

hi I think using satisfies is not enough Here is the result I got

import { z } from 'zod';

type User = {
    id: number
    name: string
    // age: number
}

const UserSchema = z.object({
    id: z.number(),
    name: z.string(),
    age: z.number() // <- should be an error
}) satisfies z.ZodType<User> 

const aa ={
    name: 'hello',
    id: 1,
    age: 3, // causes an error
} satisfies User

Here is the typescript playground

ryoppippi commented 1 year ago

So I think @rphlmr's approach from https://github.com/colinhacks/zod/issues/372#issuecomment-1280054492 works fine overall

twfehr commented 1 year ago

@stijnjanmaat, I'm getting a similar 'unwrap' error that you were getting when I try to use toZod. Any idea how to get rid of this error?

I'm trying to do this: const TreeSchema: toZod<ReturnTypeTree> = z.object({...schema properties go here})

But I'm getting this error:

Property 'unwrap' is missing in type 'ZodObject<{}, "strip", ZodTypeAny, {}, {}>' but required in type 'ZodOptional<ZodArray<ZodObject<{ product: ZodNullable<ZodObject<{ id: ZodString...etc

soupdash commented 1 year ago

@rphlmr

Doesn't seem to work properly for nested types. Any ideas on how to fix this?

import { z } from "zod";

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

type InterfacePurpose = "public" | "vlan";

interface Interface {
  label: string | null;
  purpose: InterfacePurpose;
}

interface UserData {
  user_data: string | null;
}

type ComputeModel = {
  metadata: UserData;
  interfaces: Interface[];
  scripts: any;
};

export const ComputeModelSchema = implement<ComputeModel>().with({
  // missing nullable, but doesn't error out
  metadata: z.object({ user_data: z.string() }),
  interfaces: z
    .object({
      // missing nullable, but doesn't error out
      label: z.string(),
      //  missing "vlan", but it doesn't error out
      purpose: z.enum(["public"]),
      // extra object, but doesn't error out
      extra: z.string().nullable(),
    })
    .array(),
  // generates error:
  // (property) test: z.ZodNullableType<z.ZodOptionalType<z.ZodType<any, z.ZodTypeDef, any>>>
  // Property 'unwrap' is missing in type 'ZodAny' but required in type 'ZodNullable<ZodOptionalType<ZodType<any, ZodTypeDef, any>>>'.ts(2741)
  scripts: z.any(),
});

type ComputePayload = z.infer<typeof ComputeModelSchema>;
rphlmr commented 1 year ago

@soupdash Try this :

// String litteral
type InterfacePurpose = "public" | "vlan";

// Real enum
enum EnumPurpose {
    public,
    vlan
}

interface Interface {
  label: string | null;
  purpose: EnumPurpose;
}

interface UserData {
  user_data: string | null;
}

type ComputeModel = {
  metadata: UserData;
  interfaces: Interface[];
  scripts: any;
};

export const ComputeModelSchema = implement<ComputeModel>().with({
  // missing nullable, but doesn't error out
  metadata: implement<UserData>().with({ user_data: z.string().nullable() }),
  interfaces: z.array(implement<Interface>().with({
      // missing nullable, error out
      label: z.string().nullable(),
      //  missing "vlan", but it's expected because it's not an enum but a string literal
      purpose: z.nativeEnum(EnumPurpose),
      // extra object, error out
      extra: z.string().nullable(),
    })),
  // generates error:
  // (property) test: z.ZodNullableType<z.ZodOptionalType<z.ZodType<any, z.ZodTypeDef, any>>>
  // Property 'unwrap' is missing in type 'ZodAny' but required in type 'ZodNullable<ZodOptionalType<ZodType<any, ZodTypeDef, any>>>'.ts(2741)
  scripts: z.any().optional(),
});

type ComputePayload = z.infer<typeof ComputeModelSchema>;

For purpose, when using a string literal you have to provide at min one value but can't require all. To do that, your enum have to be a "true" enum. For "any", I have no idea but I try to avoid any and unknown in my schema 😬

soupdash commented 1 year ago

@rphlmr Thanks. It's working better now, but unfortunately I'm pulling in third-party types that uses any/unknown. I'm assuming for my use case, it would be better to craft the zod schemas manually or use the ts-to-zod package?

rhinodavid commented 1 year ago

As mentioned, the approach in https://github.com/colinhacks/zod/issues/372#issuecomment-1280054492 seems to fail for default values:

type T = {
    n: number;
};
const zT = implement<T>().with({
    n: z.number().default(5),
});
Type 'ZodDefault<ZodNumber>' is not assignable to type 'ZodType<number, ZodTypeDef, number>'.
  Types of property '_input' are incompatible.
    Type 'number | undefined' is not assignable to type 'number'.
      Type 'undefined' is not assignable to type 'number'.ts(2322)

I think this can be fixed by a small modification to the Implements type:

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]> | z.ZodDefault<z.ZodType<Model[key]>>;
};
rhinodavid commented 1 year ago

Team -- I dug a little deeper with this and mashed it up with https://github.com/colinhacks/tozod.

See this PR for more: https://github.com/colinhacks/tozod/pull/28/files

It's still not super-robust, but it will disallow unknown keys and works with defaults:

        type Example = {
            n: number;
            s: string;
            b: boolean;
            bi: bigint;
            d: Date;
            a: string[];
        };

        const zExample = implement<Example>().with(
            z.object({
                a: z.array(z.string()).default(['a', 'b', 'c']),
                d: z.date().default(new Date()),
                s: z.string().default('string'),
                n: z.number().default(808),
                b: z.boolean().default(true),
                bi: z.bigint().default(BigInt(100)),
            }),
        );

        type _ = Expect<Equal<z.infer<typeof zExample>, Example>>;
AndrewRayCode commented 1 year ago

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 commented 1 year ago
const customObject = <Base>(
  shape: Record<keyof Base, ZodTypeAny>,
  params?: z.RawCreateParams
) => z.object(shape as ZodRawShape, params);
espetro commented 1 year ago

As an alternative to the solution posted in https://github.com/colinhacks/zod/issues/372#issuecomment-1280054492 , 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 commented 11 months ago

Is there anyway to make https://github.com/colinhacks/zod/issues/372#issuecomment-1280054492 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 commented 9 months ago

Are we at some solid solution?

LeulAria commented 8 months ago

any solution here ?