Работая с формой, часто нам нужно сделать так, чтобы на вход она принимала данные одного типа, а после валидации их тип меняется

Моя форма состоит из полей, начальное значение которых - пустая строка, а после валидации - число

Давайте попробуем создать схему для такой формы и вывести из нее тип

const EMPTY_NUMERIC = '';

const numericScheme = z.union([z.number(), z.literal('')]);

type NumericValue = z.infer<typeof numericScheme>;

const requiredNumberWithRefine = numericScheme.refine(
  (val) => val !== EMPTY_NUMERIC,
  {
    message: 'Это поле обязательно для заполнения',
  },
);

export const formSchema = z.object({
  amount: requiredNumberWithRefine,
  quantity: requiredNumberWithRefine,
});

export type FormDataInfer = z.infer<typeof formSchema>;

Стандартный способ с infer не подойдет в таком случае, при попытке типизировать форму и задать начальные значения в хуке useForm посыпятся ошибки типизации

Начиная с v7.44.0 (релиз) React Hook Form хук useForm стал выглядеть так

 useForm<TFieldValues extends FieldValues = FieldValues, TContext = any, TTransformedValues = TFieldValues>

Появился 3-ий дженерик TTransformedValues - он определяет выходные параметры формы, после их модификации

В нашей ситуации мы можем сузить наш начальный тип NumericValue до number с помощью одного из методов - refine, transform, superRefine с pipe. Мы уже используем refine в схеме requiredNumberWithRefine, он подходит, но приведу примеры c transform и superRefine+pipe*, если, например, вы захотите использовать контекст

* если использовать только superRefine, тип не будет сужен

const requiredNumberWithTransform = numericScheme.transform(
  (val: NumericValue, ctx: z.RefinementCtx) => {
    if (val === EMPTY_NUMERIC) {
      ctx.addIssue({
        code: 'custom',
        message: 'Это поле обязательно для заполнения',
      });
      return z.NEVER;
    }
    return val;
  },
);

const requiredNumberWithSuperRefineAndPipe = numericScheme
  .superRefine((val, ctx) => {
    if (val === EMPTY_NUMERIC) {
      ctx.addIssue({
        code: 'custom',
        message: 'Это поле обязательно для заполнения',
      });
    }
  })
  .pipe(z.number());

Далее выводим 2 отдельных типа FormDataInput и FormDataOutput с помощью дженериков  z.input и z.output 

export type FormDataInput = z.input<typeof formSchema>;
export type FormDataOutput = z.output<typeof formSchema>;

Если мы посмотрим, какие типы получились, то увидим

Теперь типизируем defaultValues с помощью FormDataInput, используем эти дженерики в хуке useForm (если мы не добавим типы в дженерики явно, они будут выведены самостоятельно, но давайте сделаем это для наглядности), в хендлере onSubmit используем FormDataOutput

Выглядеть это будет так:

const defaultValues: FormDataInput = {
  amount: '',
  quantity: '',
};


const Form = () => {
  const {
    handleSubmit,
    control,
    formState: { errors },
  } = useForm<FormDataInput, unknown, FormDataOutput>({
    resolver: zodResolver(formSchema),
    defaultValues,
  });

  const onSubmit = (data: FormDataOutput) => {
    saveData(data);
  };

  return (
    <form
      onSubmit={handleSubmit(onSubmit)}
      ...

Все, теперь явно видно какие параметры получает форма на входе и выходе, плюс мы можем отделять части формы в отдельные константы, файлы и безболезненно их типизировать