Add Type Safety to Formik’s Field Component
Formik is one of the oldest and most used React form libraries. We use it for all our forms.
Unfortunately, it has only limited type safety by default.
This Typescript example is taken from their docs. In this example, only initialValues
and onSubmit
is type safe . The Field
’s name
prop, for example, is not. The name
prop is type string
so you could add a name that is not in MyFormValues
and your app will compile but totally fail at runtime.
import * as React from 'react'; import { Formik, FormikHelpers, FormikProps, Form, Field, FieldProps, } from 'formik'; interface MyFormValues { firstName: string; } export const MyApp: React.FC<{}> = () => { const initialValues: MyFormValues = { firstName: '' }; return ( <div> <h1>My Example</h1> <Formik initialValues={initialValues} onSubmit={(values, actions) => { console.log({ values, actions }); alert(JSON.stringify(values, null, 2)); actions.setSubmitting(false); }} > <Form> <label htmlFor="firstName">First Name</label> <Field id="firstName" name="firstName" placeholder="First Name" /> <button type="submit">Submit</button> </Form> </Formik> </div> ); };
Type safety for <Field>
We implemented a user-land solution that adds full type safety to the Field
component.
The new makeForm()
utility takes a Zod schema and returns the Form
and Field
components.
Form
validation is automatically set to use that zod schema and to have initialValues
and onSubmit
typed based on that zod schema.
Field
component is also now fully typed based on the zod schema.
It’s used like this:
import {makeForm} from "@/form/Form" import {z} from "zod" import {FormField, FormInput, Button} from "@/component-library" const ResetPasswordFormSchema = z.object({ password: z.string(), token: z.string(), }) const {Formik, Field} = makeForm({schema: ResetPasswordFormSchema}) export default function Page() { // stuff return ( <Formik initialValues={{ password: "", token: useSearchParams()?.get("token"), }} onSubmit={async (values, form) => { try { await resetPasswordMutation(values) toast({ icon: "success", description: "Saved new password", }) } catch (error) { handleFormErrors({error, setFieldError: form.setFieldError, values}) } }} children={(form) => ( <form onSubmit={form.handleSubmit} className="flex flex-col"> <Field // 🔥 Fully type safe now! name="password" children={({field, meta}) => ( <FormField label="Your new password" error={meta.touched && meta.error}> <FormInput {...field} type="password" /> </FormField> )} /> <Button type="submit" disabled={form.isSubmitting} spin={form.isSubmitting}> Set password </Button> </form> )} /> ) }
Preserve types with nested form components
For large forms, it’s often beneficial to abstract some of the fields into another component, but you still want type safety.
Here’s how to do that.
The <AbstractedFields>
component should take form
and Field
as components.
import {makeForm, FormikProps, FieldComponent} from "@/form/Form" import {FormField, FormInput, Button} from "@/component-library" // Same makeForm usage at the top level const {Formik, Field} = makeForm({schema: MyFormSchema}) export function Form() { // stuff return ( <Formik initialValues={initialValues} onSubmit={onSubmit} children={(form) => ( <form onSubmit={form.handleSubmit}> {/* 🔥 pass in form and Field */} <AbstractedFields form={form} Field={Field} /> </form> )} /> ) } export type AbstractedFieldsProps = { form: FormikProps<typeof MyFormSchema> Field: FieldComponent<typeof MyFormSchema> } const AbstractedFields = ({form, Field}: AbstractedFieldsProps) => { // 🔥 form can be used in here with type safety return ( <Field // 🔥 Still has type safety name="myFieldName" children={({field, meta}) => ( <FormField label="My Field Name" error={meta.touched && meta.error}> <FormInput placeholder="Web server" {...field} disabled={disabled} /> </FormField> )} /> ) }
All the code you need
Here’s all the code for the makeForm
utility above. It's not for the faint of heart 😅
This code is also available in this Typescript Playground.
// src/form/Form.tsx "use client" import { FieldConfig, FieldProps, FormikConfig, FormikProps, FormikValues, GenericFieldHTMLAttributes, Field as OrigField, Formik as OrigFormik, } from "formik" import React from 'react' import {ZodError, ZodSchema, z} from "zod" export type DeepKeys<T> = unknown extends T ? string : // eslint-disable-next-line T extends readonly any[] ? DeepKeysPrefix<T, keyof T> : T extends object ? Exclude<keyof T, ObjectKeys<T>> | DeepKeysPrefix<T, keyof T> : never type DeepKeysPrefix<T, TPrefix> = TPrefix extends keyof T & (number | string) ? `${TPrefix}.${DeepKeys<T[TPrefix]> & string}` : never type ObjectKeys<T> = { [K in keyof T]: T[K] extends object ? K : never }[keyof T] export type DeepValue<T, TProp> = T extends Record<string | number, any> ? TProp extends `${infer TBranch}.${infer TDeepProp}` ? DeepValue<T[TBranch], TDeepProp> : T[TProp & string] : never export type FieldAttributes< FormValues extends FormikValues, Name extends DeepKeys<FormValues> = DeepKeys<FormValues>, > = Omit<GenericFieldHTMLAttributes, "children"> & Omit<FieldConfig, "name" | "component" | "as" | "render" | "children"> & { children: (props: FieldProps<DeepValue<FormValues, Name>, FormValues>) => React.JSX.Element name: Name } type FormikFormComponent<FormValues extends Record<string, unknown>> = React.FC< FormikConfig<FormValues> & { initialValues?: FormValues } > export const validateZodSchema = (schema: ZodSchema | undefined) => async (values: Record<string, unknown>) => { if (!schema) { return {} } try { await schema.parseAsync(values) return {} } catch (error: any) { return formatZodError(error) } } export function formatZodError(error: ZodError) { if (!error || typeof error.format !== "function") { throw new Error("The argument to formatZodError must be a zod error with error.format()") } const errors = error.format() return recursiveFormatZodErrors(errors) } export function recursiveFormatZodErrors(errors: any) { let formattedErrors: Record<string, any> = {} for (const key in errors) { if (key === "_errors") { continue } if (errors[key]?._errors?.[0]) { if (!isNaN(key as any) && !Array.isArray(formattedErrors)) { formattedErrors = [] } // @ts-expect-error this looks like a mistake from `formattedErrors = []` above formattedErrors[key] = errors[key]._errors[0] } else { if (!isNaN(key as any) && !Array.isArray(formattedErrors)) { formattedErrors = [] } // @ts-expect-error this looks like a mistake from `formattedErrors = []` above formattedErrors[key] = recursiveFormatZodErrors(errors[key]) } } return formattedErrors } function formikFactory<S extends z.ZodTypeAny>(schema: S): FormikFormComponent<z.input<S>> { return function CustomFormik({ children, ...props }: FormikConfig<z.input<S>> & {initialValues?: number}) { return ( <OrigFormik validate={validateZodSchema(schema)} {...props} children={(form) => children && typeof children === "function" && children(form)} /> ) } } function fieldFactory< FormValues extends FormikValues, Name extends DeepKeys<FormValues> = DeepKeys<FormValues>, >() { return function CustomField<N extends Name>(props: FieldAttributes<FormValues, N>) { return <OrigField {...props} /> } } export type FieldComponent<FormValues extends Record<string, unknown>> = < N extends DeepKeys<FormValues>, >( props: FieldAttributes<FormValues, N>, ) => JSX.Element export interface FormikContext<FormValues extends Record<string, unknown>> { Formik: FormikFormComponent<FormValues> Field: FieldComponent<FormValues> } export function makeForm<S extends z.ZodTypeAny>({schema}: {schema: S}): FormikContext<z.input<S>> { const result = { Formik: formikFactory(schema), Field: fieldFactory<z.input<S>>(), } return result }