typescriptreact-hook-formzod

z.input and z.output no different for zodResolver


I have a vector3d object

const vector3DBase = z
  .object({
    x: z.number().nullable(),
    y: z.number().nullable(),
    z: z.number().nullable(),
  })

When added to a bigger schema

export const formSchema = z.object({
  myPoint: vector3DSchema(),
});

MyPoint can be null from the API. However for validation purposes (and I pass it as lens.focus("myPoint")) I don't want it to be nullable. When I send it to the server I need to transform it and if x,y,z is null then return null, otherwise return object.

However, if I add

  .refine(
    (val) => {
      const allNull = val.x === null && val.y === null && val.z === null;
      const allSet = val.x !== null && val.y !== null && val.z !== null;
      return allNull || allSet;
    },
    {
      message: "Either all x,y,z must be set, or all must be null",
      path: ["x"],
    },
  )
.transform((val) => {
  const allNull = val.x === null && val.y === null && val.z === null;
  return allNull ? null : val;
});

zodResolver(formSchema)

Will say that myPoint can be null.

The only way I have found around this is to add .transform on the entire schema but then I have to transform each and every vector3d respectively.

export type FormValues = z.input<typeof formSchema>;
export type ApiValues = z.output<typeof formSchema>;

How can I get zodResolver to use the z.output<typeof formSchema> version of the schema?

Here is where I get a null, when I try to pass a lens to my objects. I have already overridden defaultvalues (from API) so that I guarantee that there are no null values.

<Vector3DComponent
  lens={lens.focus("myPoint")}
  label={"Adapter axis point"}
/>

Edit: I have added a codesamples. https://stackblitz.com/edit/vitejs-vite-plkpv7pz?file=src%2FMyFormComponent.tsx


Solution

  • You can use this schema to get either a vector with non-nullable coordinates or never instead of your vector object:

    const vector3DInput = z
      .object({
        x: z.number().nullable(),
        y: z.number().nullable(),
        z: z.number().nullable(),
      })
      .transform((data, context): Vector3D => {
        const { x, y, z: cz } = data;
    
        // Check how many coordinates are null
        const nullCount = [x, y, cz].filter((coord) => coord === null).length;
    
        // Return empty vector for
        if (nullCount === 3) {
          return z.NEVER;
        }
    
        // Return filled vector with annotation that it is not null
        if (nullCount === 0) {
          return { x: x!, y: y!, z: cz! };
        }
    
        // Create validation error for partially filled vectors
        const nullFields = [];
        if (x === null) nullFields.push('x');
        if (y === null) nullFields.push('y');
        if (cz === null) nullFields.push('z');
    
        context.addIssue({
          code: z.ZodIssueCode.custom,
          message: `Invalid vector: either all coordinates must be set or all must be null. Missing: ${nullFields.join(
            ', '
          )}`,
        });
    
        return z.NEVER;
      });
    

    I used a counting-based approach to work around checking every single combination. I also tried constructing a custom error message for easier debugging, should the edge case ever actually happen.

    I also adjusted your form component slightly to work with this approach:

    export const MyFormComponent = () => {
      const { control, handleSubmit } = useForm<FormValues, any, ApiValues>({
        resolver: zodResolver(formSchema),
        defaultValues: { myPoint: emptyVector3D },
      });
    
      const lens = useLens({ control });
    
      const onSubmit = (data: ApiValues) => {
        console.log(data);
      };
    
      return (
        <form onSubmit={handleSubmit(onSubmit)}>
          <SimpleGrid cols={{ base: 1, sm: 1, lg: 2 }} mt={'md'}>
            <MyVector3DComponent lens={lens.focus('myPoint')} />
          </SimpleGrid>
          <Group mt={'sm'} justify={'flex-end'}>
            <Button type="submit">Submit</Button>
          </Group>
        </form>
      );
    };