Работая с формой, часто нам нужно сделать так, чтобы на вход она принимала данные одного типа, а после валидации их тип меняется
Моя форма состоит из полей, начальное значение которых - пустая строка, а после валидации - число
Давайте попробуем создать схему для такой формы и вывести из нее тип
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)} ...
Все, теперь явно видно какие параметры получает форма на входе и выходе, плюс мы можем отделять части формы в отдельные константы, файлы и безболезненно их типизировать
