Настройка типизации формы React Hook Form (≥ v7.44.0) + Zod с разными входными и выходными типами

от автора

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

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

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

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)}      ...

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

ссылка на оригинал статьи https://habr.com/ru/articles/1038440/