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

Refine validations on object definitions don't get triggered until all fields in the object exist. #479

Closed
ekawatani opened this issue May 31, 2021 · 73 comments
Labels
wontfix This will not be worked on

Comments

@ekawatani
Copy link

ekawatani commented May 31, 2021

Applies to zod v3. I have not tried the same scenario on older versions.

I have two dates, startDate and endDate and I want to verify that the former is always earlier than the latter. I tried doing something like this:

z.object({
  name: z.string(),
  startDate: z.date(),
  endDate: z.date(),
})
.refine((data) => checkValidDates(data.startDate, data.endDate), 'Start date must be earlier than End date.')

The problem is that the .refine() function defined on the object type doesn't get triggered until the all fields in the object are defined. So, imagine you have a form and user entered both startDate and endDate first, but in a wrong order. At this point, the validations for these dates do not trigger because user has not entered name. Then, user goes ahead enters name and user now sees the validations for the dates kicking in. This is very awkward experience.

I can see that in this issue #61, the example adds a .partial() to get around this problem, but this really isn't a solution because all of these fields are required in my case. It'd be nice if the .refine function defined on each individual field had access to the current form values, but it has access to its own value only. Any ideas, or did I miss something?

@ekawatani
Copy link
Author

What I mentioned related to the form is just an example. My point is still that refine does not get triggered until all the fields in the object definition is passed in. Thus, any form handling processes will have this issue because the refine validations are never surfaced.

I created a little code sandbox for you to try.
https://codesandbox.io/s/goofy-fog-evved?file=/src/App.js

Here, this is what I'm doing in the codesandbox.

z.object({
  name: z.string(),
  age: z.number()
})
.refine((data) => data.age >= 0, {
  path: ["age"],
  message: "Age must be greater than equal to 0"
})
.parse({ age: -1 })

For this, I expect these validation results to be produced: 1) "name" is required and 2) "age" must be greater than or equal to 0. However, what I see is only "name" is required.

@ekawatani ekawatani changed the title It doesn't seem possible to validate against more than one field Refine validations on object definitions don't get triggered until all fields in the object exist. Jun 1, 2021
@ekawatani
Copy link
Author

ekawatani commented Jun 2, 2021

@ivan-kleshnin I went through this again, but I still think what you mentioned contradicts with my findings. It'd be great if you could let me know how zod solves this issue.

@ekawatani
Copy link
Author

ekawatani commented Jun 2, 2021

Let me reiterate the issue in more detail.

It may be by design that refine validations on a z.object do not trigger until all fields in the object are filled in. For example, this is probably desired behavior when validating a password and a password confirmation. In this case, we'd expect the refine validations to trigger once both fields are filled in. However, this is only true if those are the only fields being validated. If this example had several more fields, then we still want to trigger the validation as soon as those two fields are filled in, not when all fields are provided.

One solution is to create a nested object schema and use refine on that. e.g.

z.object({
  name: z.string,
  dates: z.object({
    startDate: z.date(),
    endDate: z.date()
  })
  .refine((data) => checkValidDates(data.startDate, data.endDate), 'Start date must be earlier than End date.'),
})

Sure, this works, but what if there's another field that depends on start and end dates? In my scenario, the start and end date are the dates of an event. I also have a due date field which specifies when an event registration is due. So, I need to make sure it's earlier than the start date. Now, I need to wait until start and end dates are both filled in before using a refine validation. But, what I want is to trigger the validation as soon as start and due date are filled in. zod cannot currently handle this if I'm correct. Also, doing this breaks the structure, so it doesn't play nice with Blitz.js.

My suggestion is to have something like yup.when which gives you access to values of other fields. To enable this, the refine function may also give you back a function that retrieves the value of a specified field. For example:

z.object({
  name: z.string,
  startDate: z
    .date()
    .refine((value, getField) => checkValidDates(value, getField("endDate")), 'Start date must be earlier than End date.'),
  endDate: z
    .date()
    .refine((value, getField) => checkValidDates(getField("startDate"), value), 'Start date must be earlier than End date.'),
  dueDate: z
    .date()
    .refine((value, getField) => checkValidDates(value, getField("startDate")), 'Due date must be earlier than Start date.'),
})

This is my suggestion. By moving the refine into the field schema, then we can makes sure it gets triggered as soon as that field is modified. There's also no need to create a nested object schema. If there's an existing solution, then that's great. Please let met know.

I hope I made my point clear enough. I'm not sure if this is technically possible with how zod is written, but this feels like such a basic scenario, so it'd be great support this.

@ekawatani
Copy link
Author

ekawatani commented Jun 3, 2021

What I mentioned related forms are still just examples. It is not really what I want to discuss. (Well, if you are curious why missing keys in those "forms" do not have "" as the default value, then I can explain a bit more. Blitz.js which is also a sponsor of this project uses React Final Form and zod as a recommended way to perform validations on browser and server side. The same zod schema is used on both sides. React Final Form does not give "" for empty input fields. So, user doesn't get any custom validation results set on a z.object until all fields are provided. Again, this is just an example. It doesn't matter where the validation takes place.)

Currently, zod doesn't have a good way to do validations against more than one field and that's what I'm trying to point out here.

I also think this is a not a corner case. It's not uncommon to have fields that depend on some other fields (e.g. password and password confirmation, start/end dates, etc.), whether it's a form or any other json data, and we usually want to surface the error as soon as we detect them.

The suggestion to use "" for missing key sounds like a dirty hack. Doing so means modifying the data that's being validated. Well, I could make a deep clone of the data and keep the original one intact, but I still need to manually go through each field and add an empty string. I don't think I should be doing that just to allow multi-field validations.

I skimmed through the source code a bit today, but isn't it possible to hold a reference to the original data inside the ZodType when it calls parse? Then, we could pass a function to retrieve a specific value from it (like getField in my example above) or a deep clone of the data into the refine method?

@scotttrinh
Copy link
Collaborator

My two cents: refine and transform both assume that the object you're refining or transforming passes the initial schema. If it does not, than you need to translate your object into that schema or else the type of transform or refine can't be correct at that point. In the partial form case, you might want to make your schema .partial, but then you'll need to deal with that in your refine type, which makes sense to me.

You could be explicit bout what you are trying to do and make two schemas: one for the form and one for the model. Something like this? TypeScript Playground

import { z } from "zod";

const formSchema = z.object({
  name: z.string(),
  startDate: z.date(),
  endDate: z.date(),
}).partial();
type Form = z.infer<typeof formSchema>;

const modelSchema = formSchema.required().refine((input) => {
  return input.startDate < input.endDate;
}, "Start date must be earlier than end date");

type Model = z.infer<typeof modelSchema>;

const blankModel: Model = {
  name: "",
  startDate: new Date(),
  endDate: new Date(),
};

const currentForm: Form = {
  name: "A name",
};

const result = modelSchema.safeParse({ ...blankModel, ...currentFrom });

@scotttrinh
Copy link
Collaborator

A version that embraces the partial nature of the form: TypeScript Playground

import { z } from "zod";

const formSchema = z.object({
  name: z.string(),
  startDate: z.date(),
  endDate: z.date(),
}).partial().refine((partialInput) => {
  if (!partialInput.startDate || !partialInput.endDate) {
    return true;
  }

  return partialInput.startDate < partialInput.endDate;
});
type Form = z.infer<typeof formSchema>;

const currentForm: Form = {
  name: "A name",
};

const result = formSchema.safeParse(currentForm);

@ekawatani
Copy link
Author

Hi, thanks for you two for taking time for this issue. Your example gets the job done, but there's still one issue in the real world scenarios. If there are separate schemas, then I'll need to add the same refines to both of them. In my case, I at least have three refines to do as I showed earlier. (My actual schema has much more fields and I have several more refines to do.) To avoid duplication, I created a function to add them to the base schema, but I cannot get the TypeScript typing to play nice with this approach. The following is the best I came up with my scenario (Note: I added an optional field in this example, since using required as in your example will not work for schema with optional fields.)

I don't think there's a better way to handle this?

const CreateTournamentBaseSchema = z
  .object({
    name: z.string(),
    organizer: z.string().optional(),
    startDate: z.date(),
    endDate: z.date(),
    dueDateTime: z.date(),
  })
  .strict()

const CreateTournamentBaseSchemaPartial = CreateTournamentBaseSchema.partial()

// Accept a partial object type as an argument, so the refine methonds doesn't need to assume
// the fields in the object are always defined and it'll cover both cases.
const addRefines = (schema: typeof CreateTournamentBaseSchemaPartial) => {
  return schema.refine((data) => {
    if (!partialInput.startDate || !partialInput.endDate) {
      return true;
    }

    return partialInput.startDate < partialInput.endDate;
  });
};

export const CreateTournamentFormSchema = addRefines(CreateTournamentBaseSchemaPartial)
export const CreateTournamentModelSchema = addRefines(
  // I'll need to force cast it to the partial type to make the compiler happy.
  CreateTournamentBaseSchema as unknown as typeof CreateTournamentBaseSchemaPartial
)

@scotttrinh
Copy link
Collaborator

@ekawatani

Since those two schemas are not compatible with each other, I think you'll either need to be ok with the cast here, or use the fact that the partial schema will match the non-partial schema and define the refinement over the partial data. Something like this: TypeScript Playground

import { z } from "zod";

const CreateTournamentBaseSchema = z
  .object({
    name: z.string(),
    organizer: z.string().optional(),
    startDate: z.date(),
    endDate: z.date(),
    dueDateTime: z.date(),
  })
  .strict()

const CreateTournamentBaseSchemaPartial = CreateTournamentBaseSchema.partial()

// Accept a partial object type as an argument, so the refine methonds doesn't need to assume
// the fields in the object are always defined and it'll cover both cases.
const checkDates = (data: z.infer<typeof CreateTournamentBaseSchemaPartial>) => {
  if (!data.startDate || !data.endDate) {
    return true;
  }

  return data.startDate < data.endDate;
}

export const CreateTournamentFormSchema = CreateTournamentBaseSchemaPartial.refine(checkDates)
export const CreateTournamentModelSchema = CreateTournamentBaseSchema.refine(checkDates)

@ekawatani
Copy link
Author

Yeah, ok. That works out nicely. I did not think of using z.infer that way.

Though, I still think this should be somehow built-in to zod. I'm happy with this workaround now, but I believe supporting it would make zod even easier to work with. Well, I'll let the community decide... Anyways, thanks a lot for the help.

@scotttrinh
Copy link
Collaborator

Though, I still think this should be somehow built-in to zod. I'm happy with this workaround now, but I believe supporting it would make zod even easier to work with.

Ultimately, I see the value of zod (and other similar libraries) as being something that can turn an unknown into a concrete type that I can reliably depend on at runtime. Being such a general-purpose tool means that it's less specifically adapted to things like form validation, or automatic ORM generation, or any other such specific use-case for a runtime representation of your types. But, I believe that there is an opportunity to write abstractions that leverage zod to provide a nice experience for form validation, or ORM tooling, or GraphQL generation, etc. etc. I suspect we'll see form libraries (e.g. formik, et al) start to help with that abstraction as zod gains more users, but until then, there will be some work to create these abstractions yourself for your use cases, I think.

Somewhat related to this, there is a PR in the works that adds a concept of "pre-processing" (#468) that allows transforming the input before the schema is checked that might help a bit with this, but not much more elegant (imo) than the solution I outlined above I think.

Anyways, thanks a lot for the help.

Glad to help!

@gcourtemanche
Copy link

I'm currently in the process of testing Zod to migrate from Yup. I love it so far, but the issue is also an irritant for us

@jonathanRinciari
Copy link

This seems to be similar to #690, and based on @colinhacks response he is working on a change to the parser to support this in 3.10. You can test it by trying the beta release via yarn add zod@beta

@kariae
Copy link

kariae commented Oct 20, 2021

I don't think this is similar to #690, a use case for triggering refine before filling all the form fields or having access to the other values on refine is where we're having a form in a wizard, where we need to do validation step by step. If in the first step we need to conditionally validate field_2 based on the value of field_1 (both on step 1) we can't do it since fields from the other steps are not yet filled.

@scotttrinh
Copy link
Collaborator

If in the first step we need to conditionally validate field_2 based on the value of field_1 (both on step 1) we can't do it since fields from the other steps are not yet filled.

I think if you have multiple subsets of the final data structure that need to be validated together, you might want to consider making these separate schemas and then combining them together into a single schema (if you need that single schema for something else).

Conversely you could use partial and then you can just be extremely guarded in your refinements where you check for presence for every key before relying on it, and then using the non-partial version for the type of the final type. If you provide some examples of what you're doing, maybe we can suggest ways of structuring the schemas?

@kariae
Copy link

kariae commented Oct 21, 2021

I think if you have multiple subsets of the final data structure that need to be validated together, you might want to consider making these separate schemas and then combining them together into a single schema (if you need that single schema for something else).

I think this is the approach I'm looking for since I need the combined schema because I'm using Zod as a resolver on react-hook-forms. Can you tell me how can I combine two schemas, please? I did go over the docs but didn't see anything in that matter, thank you. I guess you were talking about .merge()

@kariae
Copy link

kariae commented Oct 21, 2021

Didn't do the trick, or at least I didn't find how to make it work. Let's imagine that I have the following two schemas

const step1 = z.object({ name: z.string().nonempty('Name is required') });
const step2 = z.object({
    company: z.string().nonempty('Company is required'),
    position: z.string(),
});
const step3 = z.object({ team: z.string().nonempty('Team is required') });

const allSteps = step1.merge(step2);
const allStepsWithRefine = step1
    .merge(
        step2.refine(
            ({ company, position }) => {
                if (company) {
                    return '' !== position;
                }
                return true;
            },
            {
                message: "String can't be more than 255 characters",
            },
        ),
    )
    .merge(step3);

I'm looking for a way to make position required only if company was set. Using a refine on step2 should do the work, but when I try to merge them it breaks Typescript.

Argument of type 'ZodEffects<ZodObject<{ company: ZodString; position: ZodString; }, "strip", ZodTypeAny, { company: string; position: string; }, { company: string; position: string; }>, { ...; }, { ...; }>' is not assignable to parameter of type 'AnyZodObject'.
  Type 'ZodEffects<ZodObject<{ company: ZodString; position: ZodString; }, "strip", ZodTypeAny, { company: string; position: string; }, { company: string; position: string; }>, { ...; }, { ...; }>' is missing the following properties from type 'ZodObject<any, any, any, { [x: string]: any; [x: number]: any; [x: symbol]: any; }, { [x: string]: any; [x: number]: any; [x: symbol]: any; }>': _shape, _unknownKeys, _catchall, _cached, and 16 more.

@stale
Copy link

stale bot commented Mar 2, 2022

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.

@stale stale bot added the wontfix This will not be worked on label Mar 2, 2022
@stale stale bot closed this as completed Mar 9, 2022
@chawes13
Copy link

This is a non-starter for us to switch from Yup to Zod. The use case that we have might be addressed by #1394

@joaobonsegno
Copy link

Really sad about this behaviour, tbh. Idk if I'm being too shallow in my thoughts, but to me it seems you could just execute the refinements even if the schema validation fails, then this problem would be solved. But ok, let's try to make workarounds for this

@Yin117
Copy link

Yin117 commented May 5, 2023

Just come across this 2 years after it was opened, and also find that this is an annoyance for us.

We're using react-hook-form which takes Zod in its stride, but in this case with have an object for the data with many properties including a start and end date, using refine we can validate that the end date is after the start, but this validation doesn't run and present itself until all other checks in the non-refine step have been resolved.

If we had someway to reference other data, that would be perfect:

endDate: z
  .date({ required_error: 'endDateRequired' })
  .refine(
    (endDate, { startDate }) =>
      endDate > startDate,
    { message: 'datesReversed', path: 'endDate' }
  )

But this is not made available, and so it seems we are doomed to post-validation refine, or splitting our Zod up into pieces

@Yin117
Copy link

Yin117 commented May 5, 2023

Solution

Use z.intersection on two objects:

const zodForDates: ZodType = z
  .object({
    startDate: z.date({ required_error: 'startDateRequired' }),
    endDate: z.date({ required_error: 'endDateRequired' }),
  })
  .refine((data) => data.endDate > data.startDate, {
    message: 'datesReversed',
    path: ['endDate'],
  });

const zodForRest = z.object({
  someProperty: z.string.min(1),
});

export const endResultZodWithTyping: ZodType<IYourInterface> = z.intersection(zodForDates, zodForRest);

Hope this helps someone

@zaferkadi
Copy link

I'm currently facing the same problem as everyone has mentioned already, refine/superRefine function is not getting called or triggered, I was going through the docs to see why it is happening but nth was mentioned there, if thats the expected behaviour then please update the docs, otherwise I do hope this interesting behaviour is actually fixed.

@LeulAria
Copy link

LeulAria commented Apr 21, 2024

    selectPaths: [['startDate'], ['endDate']],

can you like give some detailed example bro, im trying to use inside useForm also plus we need zod solution...

@netojose
Copy link

I came up with this helper that treats the data as if its a zod any but then returns the type that was passed into it. Not sure if theres any differences between z.any and z.*, but all I need from this is refine before using the product as a child validation unit or for final validation. My use-case for this is validating forms with react hook forms, where the validator gets run on field change and the data partially fills the schema.

export function zodAlwaysRefine<T extends z.ZodTypeAny>(zodType: T) {
  return z.any().superRefine(async (value, ctx) => {
    const res = await zodType.safeParseAsync(value);

    if (res.success === false)
      for (const issue of res.error.issues) {
        ctx.addIssue(issue);
      }
  }) as unknown as T;
}

// example:

zodAlwaysRefine(
  z.array(
    z.object({
      key: z.string(),
      name: z.string(),
    }),
  ),
).superRefine((value, ctx) => {
  // this should get run even if any `name`s are missing, and the zod type is untouched for further chaining
  // even though certain properties could be undefined (so its technically not typesafe, but hopefully logic in the refine is small)
  //
  // ex. *find duplicates and add errors*
  // ...
});

However, it would be nice to have some sort of flag that you can pass into any zod unit that validates regardless of the intrinsic validation result.

This was amazing @Mjtlittle , thanks!

@milon27
Copy link

milon27 commented May 25, 2024

I came up with this helper that treats the data as if its a zod any but then returns the type that was passed into it. Not sure if theres any differences between z.any and z.*, but all I need from this is refine before using the product as a child validation unit or for final validation. My use-case for this is validating forms with react hook forms, where the validator gets run on field change and the data partially fills the schema.

export function zodAlwaysRefine<T extends z.ZodTypeAny>(zodType: T) {
  return z.any().superRefine(async (value, ctx) => {
    const res = await zodType.safeParseAsync(value);

    if (res.success === false)
      for (const issue of res.error.issues) {
        ctx.addIssue(issue);
      }
  }) as unknown as T;
}

// example:

zodAlwaysRefine(
  z.array(
    z.object({
      key: z.string(),
      name: z.string(),
    }),
  ),
).superRefine((value, ctx) => {
  // this should get run even if any `name`s are missing, and the zod type is untouched for further chaining
  // even though certain properties could be undefined (so its technically not typesafe, but hopefully logic in the refine is small)
  //
  // ex. *find duplicates and add errors*
  // ...
});

However, it would be nice to have some sort of flag that you can pass into any zod unit that validates regardless of the intrinsic validation result.

this is working. thanks @Mjtlittle

@freestingo
Copy link

I came up with this helper that treats the data as if its a zod any but then returns the type that was passed into it. Not sure if theres any differences between z.any and z.*, but all I need from this is refine before using the product as a child validation unit or for final validation. My use-case for this is validating forms with react hook forms, where the validator gets run on field change and the data partially fills the schema.

export function zodAlwaysRefine<T extends z.ZodTypeAny>(zodType: T) {
  return z.any().superRefine(async (value, ctx) => {
    const res = await zodType.safeParseAsync(value);

    if (res.success === false)
      for (const issue of res.error.issues) {
        ctx.addIssue(issue);
      }
  }) as unknown as T;
}

// example:

zodAlwaysRefine(
  z.array(
    z.object({
      key: z.string(),
      name: z.string(),
    }),
  ),
).superRefine((value, ctx) => {
  // this should get run even if any `name`s are missing, and the zod type is untouched for further chaining
  // even though certain properties could be undefined (so its technically not typesafe, but hopefully logic in the refine is small)
  //
  // ex. *find duplicates and add errors*
  // ...
});

However, it would be nice to have some sort of flag that you can pass into any zod unit that validates regardless of the intrinsic validation result.

this just saved me hours of manual reverse-engineering of automatically-generated zod objects. thank you so much @Mjtlittle

@Drezir
Copy link

Drezir commented Jul 18, 2024

I came up with this helper that treats the data as if its a zod any but then returns the type that was passed into it. Not sure if theres any differences between z.any and z.*, but all I need from this is refine before using the product as a child validation unit or for final validation. My use-case for this is validating forms with react hook forms, where the validator gets run on field change and the data partially fills the schema.

export function zodAlwaysRefine<T extends z.ZodTypeAny>(zodType: T) {
  return z.any().superRefine(async (value, ctx) => {
    const res = await zodType.safeParseAsync(value);

    if (res.success === false)
      for (const issue of res.error.issues) {
        ctx.addIssue(issue);
      }
  }) as unknown as T;
}

// example:

zodAlwaysRefine(
  z.array(
    z.object({
      key: z.string(),
      name: z.string(),
    }),
  ),
).superRefine((value, ctx) => {
  // this should get run even if any `name`s are missing, and the zod type is untouched for further chaining
  // even though certain properties could be undefined (so its technically not typesafe, but hopefully logic in the refine is small)
  //
  // ex. *find duplicates and add errors*
  // ...
});

However, it would be nice to have some sort of flag that you can pass into any zod unit that validates regardless of the intrinsic validation result.

this just saved me hours of manual reverse-engineering of automatically-generated zod objects. thank you so much @Mjtlittle

This works for me nicely, thanks

@jimmi-joensson
Copy link

I came up with this helper that treats the data as if its a zod any but then returns the type that was passed into it. Not sure if theres any differences between z.any and z.*, but all I need from this is refine before using the product as a child validation unit or for final validation. My use-case for this is validating forms with react hook forms, where the validator gets run on field change and the data partially fills the schema.

export function zodAlwaysRefine<T extends z.ZodTypeAny>(zodType: T) {
  return z.any().superRefine(async (value, ctx) => {
    const res = await zodType.safeParseAsync(value);

    if (res.success === false)
      for (const issue of res.error.issues) {
        ctx.addIssue(issue);
      }
  }) as unknown as T;
}

// example:

zodAlwaysRefine(
  z.array(
    z.object({
      key: z.string(),
      name: z.string(),
    }),
  ),
).superRefine((value, ctx) => {
  // this should get run even if any `name`s are missing, and the zod type is untouched for further chaining
  // even though certain properties could be undefined (so its technically not typesafe, but hopefully logic in the refine is small)
  //
  // ex. *find duplicates and add errors*
  // ...
});

However, it would be nice to have some sort of flag that you can pass into any zod unit that validates regardless of the intrinsic validation result.

Thanks for this helper @Mjtlittle!

@colinhacks this solution would be amazing as a flag option as requested here

@Yin117
Copy link

Yin117 commented Sep 16, 2024

Over a year since my last input on this and we still don't have a good solution, but I'm back with a new approach, I'll preface this by saying I equally hate this approach and I'm not yet sure how it'll impact React Hook Form.

My goal was to find a solution that gives me the "everything gets validated" that I wanted to reach without splitting out the schema.

The concept is:

  1. Make everything optional
  2. Implement non-optional ourselves
  3. Use ctx.addIssue to apply all our checks

Like I said, I don't like it, but it gives a workable result that is still type-strong, and is decently readable.

type ZodPath =  (string | number)[] | undefined;

function customRequired(ctx: z.RefinementCtx, value: unknown, path: ZodPath,  message: string) {
  if (!value && value !== 0 && value !== false) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message,
      path,
    })
  }
}

export const theZodSchema = z.object({
  iAmRequired: z.string().optional(),
  startDate : z.date().optional(),
  variableABoolean : z.coerce.boolean().optional(),
  variableBString: z.string().optional(),
})
  .superRefine((data, ctx) => {

    customRequired(
      ctx,
      data.iAmRequired,
      ['iAmRequired'],
      'This is required',
    );

    if (!data.startDate || data.startDate < new Date()) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: 'Start date must be in the future',
        path: ['startDate'],
      })
    }

    if (data.variableABoolean && !data.variableBString) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: 'If Variable A is true, then Variable B must be specficied',
        path: ['variableB'],
      })
    }
  })
;

Resulting type:

export const theZodSchema: 
  z.ZodEffects<
    z.ZodObject<{
        iAmRequired: z.ZodOptional<z.ZodString>,
        startDate: z.ZodOptional<z.ZodDate>,
        variableABoolean: z.ZodOptional<z.ZodBoolean>,
        variableBString: z.ZodOptional<z.ZodString>
      },
      "strip",
      z.ZodTypeAny,
      {},
      {}
    >, // ZodObject
    {},
    {}
  > // ZodEffects

This is an imperfect solution for a problem without one, so take it as you like; feedback and extension welcome.

@david-arteaga
Copy link

david-arteaga commented Sep 19, 2024

Edit:
Use @jedwards1211 solution below:
#479 (comment)


My previous wrong suggestion:

Building on top of @Mjtlittle 's solution, I added some code to make sure that any transformations that the original schema declares are also applied when the validation succeeds:

import { z } from 'zod';

/**
 * Helper function to make `refine` and `superRefine` always get called
 * even if schema validation fails.
 */
export default function zodAlwaysRefine<ZodSchema extends z.ZodTypeAny>(
  schema: ZodSchema,
) {
  type Value = any;
  type TransformedValue = z.infer<ZodSchema>;
  // using this transformCache because I couldn't find a way to only run the transform without running parse/parseAsync
  // Also it's probably a good idea to not re-run validations/transformations if we can avoid it
  const transformCache: WeakMap<Value, TransformedValue> = new WeakMap();

  return z
    .any()
    .superRefine(async (value, ctx) => {
      const res = await schema.safeParseAsync(value);

      if (res.success) {
        transformCache.set(value, res.data);
      } else {
        for (const issue of res.error.issues) {
          ctx.addIssue(issue);
        }
      }
    })
    .transform((value) => {
      if (transformCache.has(value)) {
        const cached = transformCache.get(value);
        transformCache.delete(value);
        return cached;
      }

      // we can use parseAsync because zod only runs `transform` when the value is valid
      return schema.parseAsync(value);
    }) as unknown as ZodSchema;
}

I ran some tests and the object reference used for the value param (the first param passed to both superRefine and transform) is the same one, which is why I used that as the key to the WeakMap.

The ctx param is unfortunately not the same for both calls, so passing in the transformed values can't be done that way.

If someone else comes up with another more elegant solution please post it!

But in the meantime this at least gets the job done.

@PiotrSkrzynski88
Copy link

PiotrSkrzynski88 commented Oct 7, 2024

Isn't it a bug that causes the issue that is discussed here?

const obj = z
  .object({
    foo: z.string().min(1),
  })
  .superRefine((val, ctx) => {
    ctx.addIssue({
      message: 'Error from superRefine',
    });
  });

Why does the obj.safeParse({}) return:

[
  {
    "code": "invalid_type",
    "expected": "string",
    "received": "undefined",
    "path": [
      "foo"
    ],
    "message": "Required"
  }
]

but obj.safeParse({ foo: '' }) returns:

[
  {
    "code": "too_small",
    "minimum": 1,
    "type": "string",
    "inclusive": true,
    "exact": false,
    "message": "String must contain at least 1 character(s)",
    "path": [
      "foo"
    ]
  },
  {
    "message": "Error from superRefine",
    "path": []
  }
]

Shouldn't the custom error from superRefine be present in both cases? Why does it only appear when foo is an empty string, but not when it's missing? Notice that in the second case, foo is still invalid.

@jedwards1211
Copy link

jedwards1211 commented Oct 22, 2024

The proper solution for this already exists, it's z.preprocess.

Don't use @Mjtlittle's solution or @david-arteaga's workaround that supports transforms. They aren't type safe and overcomplicate what z.preprocess can do by itself.

In this example, we want to validate that min <= max, with the added twist that min starts as a string and gets transformed into a number.

const base = z.object({
  foo: z.string(),
  min: z.string().transform((s) => Number(s)),
  max: z.number(),
})

const schema = z.preprocess((input, ctx) => {
  // input is unknown so parse only the fields that are involved in cross-field validation
  // (this is what you should be doing anyway if using @Mjtlittle's workaround!)
  const parsed = base.pick({ min: true, max: true }).safeParse(input)
  if (parsed.success) {
    const { min, max } = parsed.data
    // the code you would usually put in `refine` or `superRefine` goes here:
    if (min > max) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        path: ['min'],
        message: 'must be <= max',
      })
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        path: ['max'],
        message: 'must be >= min',
      })
    }
  }
  // pass through the input so that subsequent parsing works
  return input
}, base)

for (const value of [
  { foo: 'a', min: '1', max: 2 },
  { min: '1', max: 2 },
  { min: '2', max: 1 },
  { foo: 'a', min: '2', max: 1 },
]) {
  console.log('Value:', value, 'safeParse:', { ...schema.safeParse(value) })
}

Output:

Value: { foo: 'a', min: '1', max: 2 } safeParse: { success: true, data: { foo: 'a', min: 1, max: 2 } }
Value: { min: '1', max: 2 } safeParse: {
  success: false,
  error: ZodError: [
    {
      "code": "invalid_type",
      "expected": "string",
      "received": "undefined",
      "path": [
        "foo"
      ],
      "message": "Required"
    }
  ]
      at get error [as error] (/Users/andy/gh/zod-forms/node_modules/.pnpm/[email protected]/node_modules/zod/lib/types.js:55:31)
      at Object.<anonymous> (/Users/andy/gh/zod-forms/src/temp.js:35:58)
      at Module._compile (node:internal/modules/cjs/loader:1376:14)
      at Module._extensions..js (node:internal/modules/cjs/loader:1435:10)
      at Module.load (node:internal/modules/cjs/loader:1207:32)
      at Module._load (node:internal/modules/cjs/loader:1023:12)
      at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:135:12)
      at node:internal/main/run_main_module:28:49 {
    issues: [ [Object] ],
    addIssue: [Function (anonymous)],
    addIssues: [Function (anonymous)],
    errors: [ [Object] ]
  }
}
Value: { min: '2', max: 1 } safeParse: {
  success: false,
  error: ZodError: [
    {
      "code": "custom",
      "path": [
        "min"
      ],
      "message": "must be <= max"
    },
    {
      "code": "custom",
      "path": [
        "max"
      ],
      "message": "must be >= min"
    },
    {
      "code": "invalid_type",
      "expected": "string",
      "received": "undefined",
      "path": [
        "foo"
      ],
      "message": "Required"
    }
  ]
      at get error [as error] (/Users/andy/gh/zod-forms/node_modules/.pnpm/[email protected]/node_modules/zod/lib/types.js:55:31)
      at Object.<anonymous> (/Users/andy/gh/zod-forms/src/temp.js:35:58)
      at Module._compile (node:internal/modules/cjs/loader:1376:14)
      at Module._extensions..js (node:internal/modules/cjs/loader:1435:10)
      at Module.load (node:internal/modules/cjs/loader:1207:32)
      at Module._load (node:internal/modules/cjs/loader:1023:12)
      at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:135:12)
      at node:internal/main/run_main_module:28:49 {
    issues: [ [Object], [Object], [Object] ],
    addIssue: [Function (anonymous)],
    addIssues: [Function (anonymous)],
    errors: [ [Object], [Object], [Object] ]
  }
}
Value: { foo: 'a', min: '2', max: 1 } safeParse: {
  success: false,
  error: ZodError: [
    {
      "code": "custom",
      "path": [
        "min"
      ],
      "message": "must be <= max"
    },
    {
      "code": "custom",
      "path": [
        "max"
      ],
      "message": "must be >= min"
    }
  ]
      at get error [as error] (/Users/andy/gh/zod-forms/node_modules/.pnpm/[email protected]/node_modules/zod/lib/types.js:55:31)
      at Object.<anonymous> (/Users/andy/gh/zod-forms/src/temp.js:35:58)
      at Module._compile (node:internal/modules/cjs/loader:1376:14)
      at Module._extensions..js (node:internal/modules/cjs/loader:1435:10)
      at Module.load (node:internal/modules/cjs/loader:1207:32)
      at Module._load (node:internal/modules/cjs/loader:1023:12)
      at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:135:12)
      at node:internal/main/run_main_module:28:49 {
    issues: [ [Object], [Object] ],
    addIssue: [Function (anonymous)],
    addIssues: [Function (anonymous)],
    errors: [ [Object], [Object] ]
  }
}

@david-arteaga
Copy link

Thanks @jedwards1211 .
That's a lot cleaner. I hadn't taken the time to go through zod's API but this is definitely a much better solution. Just migrated to it.

@jedwards1211
Copy link

jedwards1211 commented Oct 22, 2024

@david-arteaga yeah it's understandable, I landed on this issue because I was about to make a similar feature request, but then I thought to double-check the preprocess API. I had never used it to flag issues before.

Here's code for a helper I created for doing conditional validation in @jcoreio/zod-forms. You can use it like

const schema = conditionalValidate(
  z.object({
    foo: z.string(),
    min: z.number().finite(),
    max: z.number().finite(),
  })
).conditionalRefine(
  // Pick the fields the refinement depends on here
  (s) => s.pick({ min: true, max: true }),
  // This refinement will only be checked if min and max are successfully parsed
  ({ min, max }) => min <= max,
  [
    { path: ['min'], message: 'must be <= max' },
    { path: ['max'], message: 'must be >= min' },
  ]
)
import z from 'zod'

type DeepPartial<T> = T extends object | any[]
  ? { [K in keyof T]?: DeepPartial<T[K]> }
  : T

type ConditionalCheck<T extends z.ZodTypeAny> = {
  schema: T
  check: (value: z.output<T>, ctx: z.RefinementCtx) => void | Promise<void>
  async: boolean
}

interface ConditionalValidatorDef<T extends z.ZodTypeAny>
  extends z.ZodEffectsDef<T> {
  checks: ConditionalCheck<any>[]
}

type ConditionalRefineMessage<Output> =
  | string
  | z.CustomErrorParams
  | z.CustomErrorParams[]
  | ((value: Output) => z.CustomErrorParams | z.CustomErrorParams[])

type ConditionalRefineSchema<T extends z.ZodTypeAny> =
  | z.ZodType<DeepPartial<z.output<T>>, any, DeepPartial<z.input<T>>>
  | ((
      schema: T
    ) => z.ZodType<DeepPartial<z.output<T>>, any, DeepPartial<z.input<T>>>)

function resolveSchema<T extends z.ZodTypeAny>(
  baseSchema: T,
  refineSchema: ConditionalRefineSchema<T>
) {
  return typeof refineSchema === 'function'
    ? refineSchema(baseSchema)
    : refineSchema
}

export class ConditionalValidator<
  T extends z.ZodTypeAny,
  Output = z.output<T>,
  Input = z.input<T>
> extends z.ZodEffects<T, Output, Input> {
  declare _def: ConditionalValidatorDef<T>

  constructor(schema: T, checks: ConditionalCheck<any>[]) {
    super({
      ...makePreprocess(schema, checks)._def,
      checks,
    } satisfies ConditionalValidatorDef<T> as z.ZodEffectsDef<T>)
  }

  conditionalRefine(
    schema: ConditionalRefineSchema<T>,
    check: (value: z.output<T>) => unknown,
    message: ConditionalRefineMessage<z.output<T>>
  ) {
    return this.conditionalSuperRefine(schema, (value, ctx) => {
      if (!check(value)) {
        addIssues(ctx, value, message)
      }
    })
  }
  conditionalRefineAsync(
    schema: ConditionalRefineSchema<T>,
    check: (value: z.output<T>) => unknown | Promise<unknown>,
    message: ConditionalRefineMessage<z.output<T>>
  ) {
    return this.conditionalSuperRefineAsync(schema, async (value, ctx) => {
      if (!(await check(value))) {
        addIssues(ctx, value, message)
      }
    })
  }
  conditionalSuperRefine(
    schema: ConditionalRefineSchema<T>,
    check: (value: z.output<T>, ctx: z.RefinementCtx) => void
  ) {
    return new ConditionalValidator(this._def.schema, [
      ...this._def.checks,
      { schema: resolveSchema(this._def.schema, schema), check, async: false },
    ])
  }
  conditionalSuperRefineAsync(
    schema: ConditionalRefineSchema<T>,
    check: (value: z.output<T>, ctx: z.RefinementCtx) => void | Promise<void>
  ) {
    return new ConditionalValidator(this._def.schema, [
      ...this._def.checks,
      { schema: resolveSchema(this._def.schema, schema), check, async: true },
    ])
  }
}

export function conditionalValidate<T extends z.ZodTypeAny>(
  schema: T
): ConditionalValidator<T> {
  return new ConditionalValidator(schema, [])
}

function makePreprocess<T extends z.ZodTypeAny>(
  schema: T,
  checks: ConditionalCheck<any>[]
): z.ZodEffects<T> {
  return z.preprocess((input, ctx) => {
    const results = checks.map(({ schema, check, async }) => {
      function handleParsed(
        parsed: z.SafeParseReturnType<z.input<T>, z.output<T>>
      ) {
        if (!parsed.success) return
        return check(parsed.data, ctx)
      }
      return async
        ? schema.safeParseAsync(input).then(handleParsed)
        : handleParsed(schema.safeParse(input))
    })
    return checks.some((c) => c.async)
      ? Promise.all(results).then(() => input)
      : input
  }, schema)
}

const asArray = <T>(value: T | T[]) => (Array.isArray(value) ? value : [value])

function addIssues<Output>(
  ctx: z.RefinementCtx,
  value: Output,
  message: ConditionalRefineMessage<Output>
) {
  const issues = asArray(
    typeof message === 'function'
      ? message(value)
      : typeof message === 'string'
      ? { message }
      : message
  )
  for (const issue of issues) {
    ctx.addIssue({ code: z.ZodIssueCode.custom, ...issue })
  }
}

@alanost
Copy link

alanost commented Oct 23, 2024

Hey @jedwards1211, I have the same problem with conditional validation and have been following this thread for a long time. I am working on an application that involves a lot of conditional validation, which needs to be written into superRefine (unfortunately with zod). The problem was that superRefine was not executed immediately but only after other inputs were made. I solved this by following the method described earlier, using two schemas and then an intersection. But your tip saved me 30 lines of code in the subsequent schema. Now my question is: how do you write a TypeScript function like the one you mentioned earlier to save even more lines of code, like in my complicated example? Is that possible?

My example:

const { data, success, error } = parentIncomeSchema().safeParse({ ...req.body })

export const parentIncomeSchema = () => {
  const schema = z
    .object({
      'BAFOEG.PARENT_ONE_INCOME_EXIST': z.enum(['YES', 'NO'], {
        message: 'Please select an option.',
      }),
      'BAFOEG.PARENT_ONE_INCOME': z.coerce.number().min(0, 'Please enter a valid amount.').optional(),
      'BAFOEG.PARENT_ONE_INCOME_CAPITAL': z.coerce.number().min(0, 'Please enter a valid amount.').optional(),
      'BAFOEG.PARENT_ONE_INCOME_MINI': z.coerce.number().min(0, 'Please enter a valid amount.').optional(),
      'BAFOEG.PARENT_ONE_INCOME_ABROAD_DEDUCT': z.coerce.number().min(0, 'Please enter a valid amount.').optional(),
      'BAFOEG.PARENT_ONE_INCOME_ABROAD': z.string().optional(),
      'BAFOEG.PARENT_ONE_INCOME_ABROAD_TAX': z.coerce.number().min(0, 'Please enter a valid amount.').optional(),
      'BAFOEG.PARENT_ONE_INCOME_ABROAD_BRUTTO': z.coerce.number().min(0, 'Please enter a valid amount.').optional(),
      'BAFOEG.PARENT_ONE_INCOME_PENSION': z.enum(['YES', 'NO', ''], { message: 'Please select an option.' }).optional(),
      'BAFOEG.PARENT_ONE_INCOME_PENSION_1_TYPE': z.string().optional(),
      'BAFOEG.PARENT_ONE_INCOME_PENSION_1_AMOUNT': z.coerce.number().min(0, 'Please enter a valid amount.').optional(),
      'BAFOEG.PARENT_ONE_INCOME_PENSION_1_START': z
        .string()
        .refine((date) => !date || /^\d{4}-\d{2}-\d{2}$/.test(date), 'Please enter a valid date in the format YYYY-MM-DD.')
        .optional(),
      'BAFOEG.PARENT_ONE_INCOME_ALIMONY': z.enum(['YES', 'NO', ''], { message: 'Please select an option.' }).optional(),
      'BAFOEG.PARENT_ONE_INCOME_ALIMONY_NAME': z.string().optional(),
      'BAFOEG.PARENT_ONE_INCOME_ALIMONY_RELATION': z.string().optional(),
      'BAFOEG.PARENT_ONE_INCOME_ALIMONY_AMOUNT': z.coerce.number().min(0, 'Please enter a valid amount.').optional(),
      'BAFOEG.PARENT_ONE_INCOME_OTHER_BENEFITS': z.enum(['YES', 'NO', ''], { message: 'Please select an option.' }).optional(),
      'BAFOEG.PARENT_ONE_INCOME_OTHER_BENEFITS_1_TYPE': z.string().optional(),
      'BAFOEG.PARENT_ONE_INCOME_OTHER_BENEFITS_1_AMOUNT': z.coerce.number().min(0, 'Please enter a valid amount.').optional(),
    })
    .strip()

  return z.preprocess((input, ctx) => {
    const { data } = schema.safeParse(input)

    if (!data) {
      return input
    }

    if (data['BAFOEG.PARENT_ONE_INCOME_EXIST'] === 'YES') {
      if (!data['BAFOEG.PARENT_ONE_INCOME_PENSION']) {
        ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Please select an option.', path: ['BAFOEG.PARENT_ONE_INCOME_PENSION'] })
      }

      if (!data['BAFOEG.PARENT_ONE_INCOME_OTHER_BENEFITS']) {
        ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Please select an option.', path: ['BAFOEG.PARENT_ONE_INCOME_OTHER_BENEFITS'] })
      }
    }

    if (data['BAFOEG.PARENT_ONE_INCOME_ABROAD'] && data['BAFOEG.PARENT_ONE_INCOME_ABROAD'].trim() !== '') {
      if (!data['BAFOEG.PARENT_ONE_INCOME_ABROAD_TAX']) {
        ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Please provide the taxes paid abroad.', path: ['BAFOEG.PARENT_ONE_INCOME_ABROAD_TAX'] })
      }
      if (!data['BAFOEG.PARENT_ONE_INCOME_ABROAD_BRUTTO']) {
        ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Please provide the gross annual income earned abroad.', path: ['BAFOEG.PARENT_ONE_INCOME_ABROAD_BRUTTO'] })
      }
    }

    if (data['BAFOEG.PARENT_ONE_INCOME_PENSION'] === 'YES') {
      if (!data['BAFOEG.PARENT_ONE_INCOME_PENSION_1_TYPE']) {
        ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Please provide the type of pension.', path: ['BAFOEG.PARENT_ONE_INCOME_PENSION_1_TYPE'] })
      }
      if (!data['BAFOEG.PARENT_ONE_INCOME_PENSION_1_AMOUNT']) {
        ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Please provide the pension amount.', path: ['BAFOEG.PARENT_ONE_INCOME_PENSION_1_AMOUNT'] })
      }
      if (!data['BAFOEG.PARENT_ONE_INCOME_PENSION_1_START']) {
        ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Please provide the start date of the pension.', path: ['BAFOEG.PARENT_ONE_INCOME_PENSION_1_START'] })
      }

      if (!data['BAFOEG.PARENT_ONE_INCOME_ALIMONY']) {
        ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Please select an option.', path: ['BAFOEG.PARENT_ONE_INCOME_ALIMONY'] })
      }

      if (data['BAFOEG.PARENT_ONE_INCOME_ALIMONY'] === 'YES') {
        if (!data['BAFOEG.PARENT_ONE_INCOME_ALIMONY_NAME']) {
          ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Please provide the name of the person who provided alimony.', path: ['BAFOEG.PARENT_ONE_INCOME_ALIMONY_NAME'] })
        }
        if (!data['BAFOEG.PARENT_ONE_INCOME_ALIMONY_RELATION']) {
          ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Please provide the relationship.', path: ['BAFOEG.PARENT_ONE_INCOME_ALIMONY_RELATION'] })
        }
        if (!data['BAFOEG.PARENT_ONE_INCOME_ALIMONY_AMOUNT']) {
          ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Please provide the amount of alimony.', path: ['BAFOEG.PARENT_ONE_INCOME_ALIMONY_AMOUNT'] })
        }
      }
    }

    if (data['BAFOEG.PARENT_ONE_INCOME_OTHER_BENEFITS'] === 'YES') {
      if (!data['BAFOEG.PARENT_ONE_INCOME_OTHER_BENEFITS_1_TYPE']) {
        ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Please provide the type of benefit.', path: ['BAFOEG.PARENT_ONE_INCOME_OTHER_BENEFITS_1_TYPE'] })
      }
      if (!data['BAFOEG.PARENT_ONE_INCOME_OTHER_BENEFITS_1_AMOUNT']) {
        ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Please provide the amount of benefit.', path: ['BAFOEG.PARENT_ONE_INCOME_OTHER_BENEFITS_1_AMOUNT'] })
      }
    }

    return input
  }, schema)
}

@jedwards1211
Copy link

jedwards1211 commented Oct 23, 2024

@alanost Yeah if you use the conditionalValidate helper in my last comment you could do it like this:

const schema = conditionalValidate(
  z.object({
    // I'd recommend putting .trim() on any strings you want to trim in here
    ...
  })
).conditionalRefine(
  // pick the properties this refinement depends on
  (s) =>
    s.pick({
      'BAFOEG.PARENT_ONE_INCOME_EXIST': true,
      'BAFOEG.PARENT_ONE_INCOME_PENSION': true,
    }),
  // this function only gets called if the input was successfully
  // parsed with the subschema you picked
  (data) =>
    data['BAFOEG.PARENT_ONE_INCOME_EXIST'] !== 'YES' ||
    data['BAFOEG.PARENT_ONE_INCOME_PENSION'],
  // this issue gets added if the above function returns false
  {
    path: ['BAFOEG.PARENT_ONE_INCOME_PENSION'],
    message: 'Please select an option.',
  }
)
.conditionalRefine(
  (s) =>
    s.pick({
      'BAFOEG.PARENT_ONE_INCOME_EXIST': true,
      'BAFOEG.PARENT_ONE_INCOME_OTHER_BENEFITS': true,
    }),
  (data) =>
    data['BAFOEG.PARENT_ONE_INCOME_EXIST'] !== 'YES' ||
    data['BAFOEG.PARENT_ONE_INCOME_OTHER_BENEFITS'],
  {
    path: ['BAFOEG.PARENT_ONE_INCOME_OTHER_BENEFITS'],
    message: 'Please select an option.',
  }
)
// etc

If you do it manually, it would have to be like

  z.preprocess((input, ctx) => {
    function withSafeParse<T extends z.ZodTypeAny, R>(
      schema: T,
      handler: (data: z.output<T>) => R
    ): R | void {
      const parsed = schema.safeParse(data)
      if (parsed.success) return handler(parsed.data)
    }

    withSafeParse(
      schema.pick({
        'BAFOEG.PARENT_ONE_INCOME_EXIST': true,
        'BAFOEG.PARENT_ONE_INCOME_PENSION': true,
      }),
      (data) => {
        if (
          data['BAFOEG.PARENT_ONE_INCOME_EXIST'] === 'YES' &&
          !data['BAFOEG.PARENT_ONE_INCOME_PENSION']
        ) {
          ctx.addIssue({
            code: z.ZodIssueCode.custom,
            message: 'Please select an option.',
            path: ['BAFOEG.PARENT_ONE_INCOME_PENSION'],
          })
        }
      }
    )

    // etc for other groups of fields

    return input
  }, schema)

Of course, you might want to extract each schema.pick into a constant.

This is basically what conditionalValidate does under the hood, with the added benefit that the .pick is only performed once for each group of fields, and can be declared inline.

@jedwards1211
Copy link

jedwards1211 commented Oct 23, 2024

@alanost btw doing one big const { data } = schema.safeParse(input) at the beginning of preprocess doesn't work any better than .superRefine, because in both cases you can't run any of your validations if any field in the input fails to parse. That's why you have to individually safe parse each subset of fields that are interpedendent.

@LeulAria
Copy link

What happened here haha :) what's the solution guy's ?

@jedwards1211
Copy link

@LeulAria starts here: #479 (comment)

@jedwards1211
Copy link

@PiotrSkrzynski88 in your example

const obj = z
  .object({
    foo: z.string().min(1),
  })
  .superRefine((val, ctx) => {
    ctx.addIssue({
      message: 'Error from superRefine',
    });
  });

superRefine is typed to pass val: { foo: string } (the output of the schema) to your function. It wouldn't be type safe if Zod called your function with inputs like {} that don't match that type, and failed to parse.

@PiotrSkrzynski88
Copy link

@jedwards1211 I believe that TypeScript shouldn’t justify this behavior or limit functionality here. We should focus on logical consistency and user expectations rather than TypeScript constraints.

In my opinion, the issue is that superRefine should allow adding custom error messages regardless of the initial parsing result, especially since it’s designed for handling additional validation logic. The fact that superRefine doesn’t trigger when foo is missing feels inconsistent and reduces control over custom error handling. Workarounds and hacks won’t truly resolve this issue I think

@vendramini
Copy link

@jedwards1211 I believe that TypeScript shouldn’t justify this behavior or limit functionality here. We should focus on logical consistency and user expectations rather than TypeScript constraints.

In my opinion, the issue is that superRefine should allow adding custom error messages regardless of the initial parsing result, especially since it’s designed for handling additional validation logic. The fact that superRefine doesn’t trigger when foo is missing feels inconsistent and reduces control over custom error handling. Workarounds and hacks won’t truly resolve this issue I think

Totally agree with you. But lets face the reality, this issue has been around since 2021, apparently they don't want to deal with/change it so we have 2 options:

  1. Adopt one of the many solutions presented here.
  2. Work with another library.

I remember when I had to face it for the first time and rewrite my entire forms, splitting them up into isolated schemas and creating intersections, it was a pain.

Zod's owner has never replied here so...

@david-arteaga
Copy link

I think this issue is not actually a real bug or feature request.

If anyone wants to solve this, use z.preprocess instead of superRefine like mentioned here:

#479 (comment)

That’s the API that’s meant to allow you to add custom errors even if schema validation fails, which is what this issue is about.

Maybe this should be mentioned in the docs more clearly to prevent further confusion.

@jedwards1211
Copy link

jedwards1211 commented Nov 14, 2024

@PiotrSkrzynski88 it's not about TypeScript for TypeScript's sake, it's about not giving people a false expectation about the value type so they don't accidentally create bugs.

If Zod called superRefine even when parsing fails, you'd see a lot of people complaining their refinement logic is throwing runtime errors because the value doesn't actually match the z.output type. To fix that, the parameter type would get changed to unknown, same as z.preprocess. Then, because dealing with unknown is inconvenient, people would ask for a validator that only runs after parsing succeeds, same as today's superRefine.

For anyone just getting here see #479 (comment)

@Effanuel
Copy link

Effanuel commented Dec 16, 2024

z.preprocess doesn't work if baseSchema has .refine(). It overrides all errors in baseSchema

@jedwards1211
Copy link

jedwards1211 commented Dec 16, 2024

@Effanuel it doesn't actually override, just seems like that because it doesn't even parse with baseSchema if the preprocess adds an issues. In your situation the goal is to get issues from both of the following simultaneously:

  • checks that run if the input partially matches the underlyingSchema (preprocess)
  • checks that run only if the input fully matches the underlyingSchema (refine)

Where underlyingSchema is:

const baseSchema = underlyingSchema.refine(...)
                   ^^^^^^^^^^^^^^^^

I guess a way to mark a preprocess as non-fatal would help accomplish this.

In the meantime, if you want to be able to get all the refinement errors together, you would have to refactor from

const baseSchema = z.object({ ... }).refine(refinement, message)
const finalSchema = z.preprocess((input, ctx) => {
  ...
}, baseSchema)

to (I haven't type checked this, but you get the gist):

const baseSchema = z.object({ ... }).refine(refinement, message)
const finalSchema = z.preprocess((input, ctx) => {
  ...
  const parsed = baseSchema.parse(input)
  if (!parsed.success) {
    for (const issue of parsed.error.issues) {
      ctx.addIssue(issue)
    }
  }
})

@mk26710
Copy link

mk26710 commented Jan 10, 2025

I genuinely don't understand why .superRefine()/.refine() is a problem meanwhile everything else like .min() or .max() can run just fine and all the errors are present withing issues array. Is there no sane workaround? No plans to at least maybe add something like idk .refineImmediately()? This is such a disappointing issue to deal with and having to deal with this after selling zod to the team is just sad :/

@PiotrSkrzynski88
Copy link

@mk26710 I feel the same way. According to the documentation, preprocess is intended for:

Typically Zod operates under a "parse then transform" paradigm. Zod validates the input first, then passes it through a chain of transformation functions ... But sometimes you want to apply some transform to the input before parsing happens. A common use case: type coercion. Zod enables this with the z.preprocess().

const castToString = z.preprocess((val) => String(val), z.string());

Refinements are documented as:

Zod lets you provide custom validation logic via refinements. (For advanced features like creating multiple issues and customizing error codes, see .superRefine.)

Given this, performing custom validation with preprocess seems inappropriate since it's designed for preprocessing rather than validation. In any future version, the author could introduce changes that make validation within preprocess a breaking change, as this isn't its intended purpose. Still, I've applied it also because it's the simplest and quickest workaround for the problem.

@jedwards1211
Copy link

jedwards1211 commented Jan 10, 2025

For anyone just getting here see #479 (comment)

I genuinely don't understand why .superRefine()/.refine() is a problem meanwhile everything else like .min() or .max() can run just fine and all the errors are present withing issues array

Min/max can run as long as the field is valid, even if other fields aren't.

It's the same if you put refinements on an individual field. The refinements won't run if the value doesn't parse (min/max or custom refinements won't run if the value isn't a number)

It's the same if you put refinements on an entire object. The refinements won't run if the value doesn't parse (doesn't have all the required fields, or some fields fail to parse)

It just seems different because you're only thinking about when the fields you care about are valid, but Zod doesn't know which fields those are.

The solution is to specify which fields you care about, as in my conditionalValidate helper above. If Zod core provided a solution it would have to be just like that to be type safe.

This is a fundamental challenge in form validation, it's not just specific to Zod. For example, in redux-form, my cross-field validations would always run, but I had to parse the relevant fields manually or I would get accidental runtime errors. We would have the same problem if Zod ran refinements without parsing the input object first.

Given this, performing custom validation with preprocess seems inappropriate since it's designed for preprocessing rather than validation.

Transforms and refinements are designed to operate on parsed (output) values. "Preprocessing" is just doing operations on the raw unparsed (input) values. When transforming, the input could be wrong and untransformable; that's why .transform and z.preprocess have access to the same issue reporting context as refinements. I don't see a reason that will change

@caderitter
Copy link

likely switching to yup because of zod lacks this feature. would really, really appreciate its addition!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
wontfix This will not be worked on
Projects
None yet
Development

No branches or pull requests