zenstackhq / zenstack

Fullstack TypeScript toolkit that enhances Prisma ORM with flexible Authorization layer for RBAC/ABAC/PBAC/ReBAC, offering auto-generated type-safe APIs and frontend hooks.
https://zenstack.dev
MIT License
2.07k stars 88 forks source link

Generated Zod types are lost with @@validate #676

Closed tlancina closed 1 year ago

tlancina commented 1 year ago

Hello! Generated model types for Zod change to ZodEffects and lose most of their helpful information when using a validate rule. This came up because I'd like to enforce some string literals and can't use enum because some of them have disallowed chars (I'd like to enforce that the field/form is one of "a" | "b" | "c'd" - if there's another way to do this please let me know.)

Using https://github.com/ymc9/zenstack-form-validation. The lines in question are

  // sqlite doesn't support enum, you should use enum in a real application
  @@validate(beverage in ['SODA', 'COFFEE', 'BEER', 'COCKTAIL'], 'Please choose a valid beverage')

  @@validate(adult || beverage in ['SODA', 'COFFEE'], 'You must be an adult to drink alcohol')

.zenstack/zod/models/Signup.schema.d.ts with @@validate:

import { z } from 'zod';
export declare const SignupSchema: z.ZodEffects<z.ZodEffects<z.ZodType<any, z.ZodTypeDef, any>, any, any>, any, any>;

Without validate:

import { z } from 'zod';
export declare const SignupSchema: z.ZodObject<{
    id: z.ZodNumber;
    name: z.ZodString;
    email: z.ZodString;
    adult: z.ZodBoolean;
    beverage: z.ZodString;
    createdAt: z.ZodDate;
    updatedAt: z.ZodDate;
}, "strip", z.ZodTypeAny, {
    id: number;
    name: string;
    email: string;
    adult: boolean;
    beverage: string;
    createdAt: Date;
    updatedAt: Date;
}, {
    id: number;
    name: string;
    email: string;
    adult: boolean;
    beverage: string;
    createdAt: Date;
    updatedAt: Date;
}>;

Environment (please complete the following information): Using https://github.com/ymc9/zenstack-form-validation. I removed package-lock because there's a reference to localhost, and updated to latest zenstack (beta 21).

Happy to contribute if you can point me in the right direction.

tlancina commented 1 year ago

I see from https://github.com/colinhacks/zod/issues/2474 that ZodEffects is an expected change, but the loss of type information I believe is the bug, since ideally instead of having to redefine the interface we would be able to use type Input = z.infer<typeof SignupCreateSchema>.

ymc9 commented 1 year ago

Hi @tlancina , thanks for bringing this up. I believe it's a limitation of zod that after .refine() call, the schema is not a ZodObject anymore. Zod's author confirmed this: https://github.com/colinhacks/zod/issues/2646.

Do you want to manipulate the generated object schema further and then use it for validation? Could you share the ideal way how you want to do it? Maybe we can come up with a workaround here.

tlancina commented 1 year ago

Hey thanks for the quick response. And apologies, the issue is a little unclear.

Here you have to redefine the type for react-hook-form: https://github.com/ymc9/zenstack-form-validation/blob/5ceea4423e13541e4ddeb49d157d7f6a41432a96/src/app/page.tsx#L33-L38.

What I'd like to do is replace those lines with type Input = z.infer<typeof SignupCreateSchema>. This way, if the schema changes, I don't have to manually update that type.

The issue is not the ZodEffects, it's that all type information gets lost during the zod plugin generation. If we copy and paste the generated schema, we can see that even with refine() and ZodEffects, the underlying types are still there:

const baseSchema = z.object({
    name: z.string(),
    email: z.string().email().endsWith("@zenstack.dev", { message: "Must be a @zenstack.dev email" }),
    adult: z.boolean(),
    beverage: z.string(),
});

function refine(schema: typeof baseSchema) {
    return schema.refine((value) => { var _a, _b; return ((_b = (_a = ['SODA', 'COFFEE', 'BEER', 'COCKTAIL']) === null || _a === void 0 ? void 0 : _a.includes(value === null || value === void 0 ? void 0 : value.beverage)) !== null && _b !== void 0 ? _b : false); }, { message: "Please choose a valid beverage" })
        .refine((value) => { var _a, _b; return ((value === null || value === void 0 ? void 0 : value.adult) || ((_b = (_a = ['SODA', 'COFFEE']) === null || _a === void 0 ? void 0 : _a.includes(value === null || value === void 0 ? void 0 : value.beverage)) !== null && _b !== void 0 ? _b : false)); }, { message: "You must be an adult to drink alcohol" });
}

const refinedSchema = refine(baseSchema)

type Input = z.infer<typeof refinedSchema>
image

Which works just fine:

image

Compare this to the generated schema type once we use @@validate:

image

The underlying types are gone (everything is just any), so it doesn't work anymore:

image
ymc9 commented 1 year ago

Thanks a lot for the detailed explanation and for making the PR @tlancina ! It makes very good sense, and the code changes look great! I'll merge it when CI passes and include it in the next release.

I'll probably make a further change after merging your PR to export the schemas before the refine() call and export the refine() function as well, so in case people need to manipulate the object schema further, they can do that and call refine by themselves. Probably a bit more flexible than it is today.

tlancina commented 1 year ago

Sounds good, thanks!