resolvers icon indicating copy to clipboard operation
resolvers copied to clipboard

issue: [zod] z.preprocess(): Type inferring for the input takes the type before the preprocess() method (expectation: use the type after `preprocess()`)

Open xli12 opened this issue 7 months ago • 2 comments

Version Number

5.0.1

Codesandbox/Expo snack

https://codesandbox.io/p/sandbox/vcgdy2

Steps to reproduce

Zod has a functionality called preprocess(): https://zod.dev/?id=preprocess. It allows applying transform to the input before parsing happens.

From 5.0.0, the resolver has this interface for indicating the types: useForm<FormInputValues, Context, FormOutputValues>();. It is also possible to get the typing from the schema directly, so after useForm the types can be left out.

However, FormInputValues takes the type before applying preprocess(), i.e. unknown.

To reproduce:

// schema.ts

export const preprocessArgument = (argument: string | string[] | undefined): string[] | undefined => {
  if (argument === undefined || Array.isArray(argument)) {
    return argument;
  }
  return [argument];
};

export const mySchema = z.object({
  name: z.preprocess((arg) => preprocessArgument(arg as PreprocessArgument), z.string().array().optional())
});
// MyComponent.tsx

const {
  control,
  handleSubmit,
  getValues
} = useForm({
  resolver: zodResolver(mySchema),
  defaultValues
});

Now if I inspect the type of resolver, I get:

(property) resolver?: Resolver<{
    name?: unknown;
}, unknown, {
    name?: string[] | undefined;
}> | undefined

Expected behaviour

I expect the resolver picks up the type after the preprocess() method.

What browsers are you seeing the problem on?

Chrome

Relevant log output


Code of Conduct

  • [x] I agree to follow this project's Code of Conduct

xli12 avatar Apr 18 '25 14:04 xli12

that explains why i needed to this, right?

const optionalPositiveNumber = z.preprocess(
  (value) => (value === '' ? undefined : Number(value)),
  z.number({ message: 'Must be numeric' }).min(1, 'Must be positive').nullish(),
) as unknown as z.ZodOptional<z.ZodNullable<z.ZodNumber>>;

kylemh avatar Apr 23 '25 19:04 kylemh

The current behavior is sound and intentional. Preprocess can accept anything, hence the input type is unknown. The defaultValues param lets you specify pre-transform defaults, so the value needs to conform to the schema's input type. Everything here is behaving as expected.

That said, the latest versions of Zod now auto-detect a manual input type signature in the preprocess function, so your example will work as is. Just upgrade to latest and give it another shot. But keep in mind that you're going around Zod here, and Zod is not able to guarantee that the argument to preprocessArgument is actually going to be string | string[] | undefined.


A more sound approach would be something like this:

export const preprocessArgument = (argument: string | string[] | undefined): string[] | undefined => {
  if (argument === undefined || Array.isArray(argument)) {
    return argument;
  }
  return [argument];
};

export const mySchema = z.object({
  name: z
    .union([z.string(), z.string().array()])
    .optional()
    .transform(preprocessArgument)
    .pipe(z.string().array().optional()),
});

But if you can trust (based on the structure of your form) that the name field will always be string | string[] | undefined then this is probably overkill.

colinhacks avatar Jun 09 '25 20:06 colinhacks