Skip to content

Support parametrisable schemas #153

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

Closed
jmacmahon opened this issue Sep 15, 2020 · 8 comments
Closed

Support parametrisable schemas #153

jmacmahon opened this issue Sep 15, 2020 · 8 comments

Comments

@jmacmahon
Copy link

It would be cool to be able to do something like this:

export type Foo<T> = {
  name: string
  value: T
}

const fooSchema = <T>(valueSchema: z.ZodSchema<T>): z.ZodSchema<Foo<T>> => z.object({
  name: z.string(),
  value: valueSchema
})

(In fact it would be even cooler to be able to use z.infer on the ReturnType<typeof fooSchema>, but TypeScript is not yet capable of this -- see microsoft/TypeScript#40542)

The above code produces the following compile error, which I have to be honest, is beyond my understanding:

error TS2322: Type 'ZodObject<{ name: ZodString; value: ZodType<T, ZodTypeDef>; }, { strict: true; }, { [k in keyof ({ [k in undefined extends T ? "value" : never]?: { name: string; value: T; }[k] | undefined; } & { [k in Exclude<...> | Exclude<...>]: { ...; }[k]; })]: ({ [k in undefined extends T ? "value" : never]?: { ...; }[k] | und...' is not assignable to type 'ZodType<Foo<T>, ZodTypeDef>'.
  Types of property '_type' are incompatible.
    Type '{ [k in keyof ({ [k in undefined extends T ? "value" : never]?: { name: string; value: T; }[k] | undefined; } & { [k in Exclude<"name", undefined extends T ? "value" : never> | Exclude<...>]: { ...; }[k]; })]: ({ [k in undefined extends T ? "value" : never]?: { ...; }[k] | undefined; } & { [k in Exclude<...> | Exclu...' is missing the following properties from type 'Foo<T>': name, value

45 const fooSchema = <T>(valueSchema: z.ZodSchema<T>): z.ZodSchema<Foo<T>> => z.object({
                                                                              ~~~~~~~~~~
46   name: z.string(),
   ~~~~~~~~~~~~~~~~~~~
47   value: valueSchema
   ~~~~~~~~~~~~~~~~~~~~
48 })
   ~~
@colinhacks
Copy link
Owner

This is possible! But generics are finicky as hell. Here's how to do it:

export type Foo<T> = {
  name: string;
  value: T;
};

const fooSchema = <T extends z.ZodTypeAny>(
  valueSchema: T,
): z.ZodObject<{ name: z.ZodString; value: T }> =>
  z.object({
    name: z.string(),
    value: valueSchema,
  });

const asdf = fooSchema(z.string());

Your issue was having your generic T infer the TS type of the schema instead of the schema itself. That's necessary in order for TypeScript to properly infer the subclass of the ZodSchema instance that you pass in. Otherwise everything will be inferred as a ZodSchema instance without any subclass-specific methods (though the inferred type will be correct!).

Btw your code works fine if you get rid of the return type annotation. TS can infer the output type correctly.

export type Foo<T> = {
  name: string
  value: T
}

const fooSchema = <T>(valueSchema: z.ZodSchema<T>) => z.object({
  name: z.string(),
  value: valueSchema
})

I'm planning to add a guide to the README detailing how to build generic methods on top of Zod - should have done it a long time ago!

@jmacmahon
Copy link
Author

jmacmahon commented Sep 15, 2020

Ah yes I see how you've done that.

I should have clarified that I want to reference Foo directly in the function signature of fooSchema (or vice versa). At my work, the reason we use Zod (rather than, say, validating using Joi and then casting), is so that we can couple the type to the validation schema. That way, it is impossible for them to get out of sync without TypeScript picking you up on it. Of course, "normally" this is possible by using z.infer to generate the type from the schema, but as mentioned, that can't be used here.

This is the reason I wanted to explicitly add the return type annotation to be Foo -- so that if you changed Foo without changing fooSchema, or vice versa, your code would fail to compile.

Both code examples you gave are susceptible to this problem, unfortunately.

In the end, we went with an approach like the second (i.e. omitting the return type annotation), and relying on some code in the tests that assigns the result of .parse on the schema to an explicitly-typed variable of type Foo, so that if the types don't line up it will break:

// Test code -- will not compile if fooSchema cannot be guaranteed to return a Foo
it('is able to assign the output of fooSchema(_).parse() to a variable of type Foo<_>', () => {
  const raw: unknown = 'value not important'
  try {
    const parsed: Foo<string> = fooSchema(z.string()).parse(raw)
  } finally { }
})

This is a bit of a hack, but I guess it works.

It would be nice not to have to do that though.

@colinhacks
Copy link
Owner

The reason this is hard is because it's the opposite of how Zod works. Normally it infer static type from validator structure, you want to "infer" validator structure from a static type. I talk about this concept a bit here: #53 (comment)

As that comment mentions, I published a separate npm module tozod that I've published but it should be considered a proof-of-concept only. It also doesn't play nice with generics and I couldn't get it to work with your use case so this is a pretty useless comment 🤷‍♂️

@itsfarseen
Copy link

I encountered this problem while trying to encode a Result<Ok, Err> type like Rust in Typescript and Zod.
After some work I was able to move the testcases to verify the return type of fooSchema function, from runtime to compile time.

import { z } from "zod";

export type Result<T, E> = {
  success: true,
  data: T
} | {
  success: false,
  error: E,
}

function zResult<Ok, Err>(
  okSchema: z.Schema<Ok>,
  errSchema: z.Schema<Err>
) {
  return z.union([
    z.object({
      success: z.literal(true),
      data: okSchema
    }),
    z.object({
      success: z.literal(false),
      error: errSchema
    })
  ])
}

// ------ Test ---------

type Assert<T, Ok, Err> = T extends z.Schema<Result<Ok, Err >> ? true : false;
const _testSchema = zResult(z.number(), z.string());
const _testSchema_: Assert<typeof _testSchema, number, string> = true;
// this will show error
// const b: Assert<typeof testSchema, number, number> = true;

const _testSchema2 = zResult(z.object({foo: z.number()}), z.object({bar: z.string()}));
const _testSchema2_: Assert<typeof _testSchema2, {foo: number}, {bar: string}> = true;

// this will show error
// const _testSchema2__: Assert<typeof _testSchema2, {foo: number}, {baz: string}> = true;

@itsfarseen
Copy link

In fact, I don't have to type the above test cases, because the way I'm using the result of zFailable(okSchema, errSchema)
(PS: zResult in the above snippet is same as zFailable in the below snippet.)

export type Parser<Out> = (data: unknown) => ParseResult<Out>;
export function zParser<Out, Def, In>(schema: z.Schema<Out, Def, In>): Parser<Out>;

export interface Endpoint<Req, Resp> {
  path: string,
  method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH",
  parseReq?: Parser<Req>,
  parseResp: Parser<Resp>,
}

export namespace GetCanteens {
  const okSchema = z.array(User);
  export type Ok = z.infer<typeof okSchema>;

  const errSchema = z.literal("UNAUTHORIZED");
  export type Err = z.infer<typeof errSchema>;

  const respParser = zParser(zFailable(okSchema, errSchema))
  export type Resp = Result<Ok, Err>;

  export const EP: Endpoint<never, Resp> = {
    path: "/api/users/",
    method: "GET",
    parseResp: respParser,
  }
}

Since Endpoint.parseResp is of type Parser<Resp>, zFailable(okSchema, errSchema) must be of type Resp which is equal to Result<Ok, Err>.

It feels like compile time duck typing.

@andreasdamm
Copy link

The problem appears to be the use of objectUtil.addQuestionMarks in z.object when expressing the output type of the parser. When providing an object type containing fields whose value is defined by a generic type argument to addQuestionMarks, it strips out these fields and hence the field missing error.

For now the workaround is to call the constructor of ZodObject directly:

function makeParser2<T>(valueParser: z.ZodSchema<T>): z.ZodSchema<{v: T}>
{
    const result: z.ZodSchema<{v: T}> = new z.ZodObject({
        shape: () => ({
            v: valueParser
        }),
        unknownKeys: 'strip',
        catchall: z.any()
    })

    return result
}

Note that the type of result has to be specified as ZodSchema, also the argument to shape is not getting checked anymore to make sure that the required fields are defined.

@Foo42
Copy link

Foo42 commented May 10, 2022

If I've understood it correctly, I believe the upcoming TypeScript 4.7 feature "Instantiation Expressions" may make the following possible:

const fooSchema = <T>(valueSchema: z.ZodSchema<T>) => z.object({
  name: z.string(),
  value: valueSchema
})

type Foo<T> = z.infer<ReturnType<typeof fooSchema<T>>>

@zomars
Copy link

zomars commented Dec 8, 2022

Sorry to bumping this but is there a reason on why this doesn't work with transforms? @colinhacks

const fooSchema = <T extends z.ZodTypeAny>(valueSchema: T) =>
  z
    .object({
      name: z.string(),
      value: valueSchema,
    })
    .transform((foo) => foo.value);
   //                         ^? Property 'value' does not exist on type '{ [k_1 in keyof addQuestionMarks<{ name: string; value: T["_output"]; }>]: addQuestionMarks<{ name: string; value: T["_output"]; }>[k_1]; }'.

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

6 participants