colinhacks / zod

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

Relationship between schema object fields #1136

Open alexandercerutti opened 2 years ago

alexandercerutti commented 2 years ago

Hello there! I was exploring Zod with the intent of migrating from Joi. This looks awesome and makes me feel the schemas are more solid. I think it might be an excellent ally for my open-source library!

I observed how Joi owns a way to specify a relationship between schema object fields through the properties when (doc) and with (doc).

So, I could write:

Joi.object().keys({
     ...,        
     value: Joi
        .alternatives(z.string().allow(""), z.number(), z.date().iso())
        .required(),
    numberStyle: Joi
        .string()
        .regex(
            /(aaaaa|bbbbbb|ccccccc|ddddddd)/,
        )
        .when("value", {
            is: Joi.number(),
            otherwise: Joi.string().forbidden(),
        }),
});

Which tells Joi to forbid numberStyle if value is a not a number, or

Joi.object().keys({ ... }).with("webServiceURL", "authenticationToken");

Which enforces that two properties must appear at the same time for the schema to be valid.

So, is there a way to define a relationship between keys, if necessary with a condition? I guess something could be done with z.never() but I can't figure it out right now.

A solution that comes to my mind might be to create a discriminated union of objects based on value for what concerns when.

I would do something like this:

z.object<Field>({
    /** The rest of props... */
}).merge(
    z.discriminatedUnion("value", [
        z.object<Field>({
            value: z.string().or(isoDateString),
            numberStyle: z.never(),
        }),
        z.object({
            value: z.literal(z.number()),
            numberStyle: z
                .string()
                .regex(
                    /(aaaaa|bbbbbb|ccccccc|dddddddd)/,
                )
                .optional(),
        }),
    ]),
);

But, it seems that value must be literal, but in my case, it is a generic string or a generic number. So how can I achieve this? Would union still be okay?

For what concerns what, instead, I don't yet know how to create the relationship.

I guess the big issue here is that I'm attempting to migrate my Joi schemas 1-to-1 to Zod, but it probably requires a whole mindset change. 😄

Thank you very much!

stale[bot] commented 2 years ago

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

alexandercerutti commented 2 years ago

Oh c'mon...

scotttrinh commented 2 years ago

Maybe something like this?

z.union([
  z.object({ value: z.string(), numberStyle: z.never() }),
  z.object({ value: z.number(), numberStyle: z.string().regex(abcdRegexp).optional() },
])
scotttrinh commented 2 years ago

Basically you're making a true union not a discriminated union. If you want to make this easier to narrow on the typescript side you could transform and add a discriminant, like:


z.union([
  z.object({ value: z.string(), numberStyle: z.never() }).transform(v => ({ ...v, type: "STRING" })),
  z.object({ value: z.number(), numberStyle: z.string().regex(abcdRegexp).optional().transform(v => ({ ...v, type: "NUMBER" })) },
])
alexandercerutti commented 2 years ago

Hey @scotttrinh, thanks for your reply. I have to try the solution you proposed, but it is not very clear to me the transform with "STRING" | "NUMBER". Does it have any special meaning?

Thank you

alexandercerutti commented 2 years ago

I've tried to apply what you said and it apparently seems to work (I have to thoroughly test it because of what follows).

I've done this:

export const OverridableProps = z.object({
        /** props **/
})
    .and(
        z.union([
            z.object({
                webService: z.never(),
                authenticationToken: z.never(),
            }),
            z.object({
                webService: z.string(),
                authenticationToken: z.string(),
            }),
        ]),
    );

This seems to return a ZodIntersection.

So I'm not able to merge it with other schemas to create a single object. Also, I'm not able to merge the union above with the object above instead of using and.

export const AllProps = z
    .object({})
    .merge(KindsProps)
    .merge(PropsFromMethods)
    .merge(OverridableProps);

(I used z.object() just as a test, but it is the same if I take, for example, KindsProps and use merge on it).

Seems like .merge does not support intersections or unions, so I'm stuck here...

stale[bot] commented 1 year ago

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

alexandercerutti commented 1 year ago

Don't. You. Dare.

stale[bot] commented 1 year ago

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

alexandercerutti commented 1 year ago

Nope.

andreafspeziale commented 1 year ago

Hello! This is something I'm interested too.

In my use-case I would really love to use zod along with NestJS in order to validate microservice required env variables.

For example in of my schemas using joi I have something like this:

SOME_TOKEN: Joi.when('NODE_ENV', {
    is: NodeEnv.Test,
    then: Joi.string().allow('').default(''),
    otherwise: Joi.string().required(),
  }),

For sure I'm still able to do the validation using zod setting SOME_TOKEN as string always required having

# .env
SOME_TOKEN=<real_token>
# .env.test
SOME_TOKEN=""
stale[bot] commented 1 year ago

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

alexandercerutti commented 1 year ago

Nope, but I'm curious about the new possible replacement switch, so let's wait!

stale[bot] commented 1 year ago

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

alexandercerutti commented 1 year ago

Still waiting

stale[bot] commented 1 year ago

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

alexandercerutti commented 1 year ago

Naaaaaa-ah.