Add Type Safety to Formik’s Field Component

By Brandon Bayer · Jun 20, 2024

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.

1import * as React from 'react'; 2import { 3 Formik, 4 FormikHelpers, 5 FormikProps, 6 Form, 7 Field, 8 FieldProps, 9} from 'formik'; 10 11interface MyFormValues { 12 firstName: string; 13} 14 15export const MyApp: React.FC<{}> = () => { 16 const initialValues: MyFormValues = { firstName: '' }; 17 return ( 18 <div> 19 <h1>My Example</h1> 20 <Formik 21 initialValues={initialValues} 22 onSubmit={(values, actions) => { 23 console.log({ values, actions }); 24 alert(JSON.stringify(values, null, 2)); 25 actions.setSubmitting(false); 26 }} 27 > 28 <Form> 29 <label htmlFor="firstName">First Name</label> 30 <Field id="firstName" name="firstName" placeholder="First Name" /> 31 <button type="submit">Submit</button> 32 </Form> 33 </Formik> 34 </div> 35 ); 36};

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:

1import {makeForm} from "@/form/Form" 2import {z} from "zod" 3import {FormField, FormInput, Button} from "@/component-library" 4 5const ResetPasswordFormSchema = z.object({ 6 password: z.string(), 7 token: z.string(), 8}) 9 10const {Formik, Field} = makeForm({schema: ResetPasswordFormSchema}) 11 12export default function Page() { 13 // stuff 14 return ( 15 <Formik 16 initialValues={{ 17 password: "", 18 token: useSearchParams()?.get("token"), 19 }} 20 onSubmit={async (values, form) => { 21 try { 22 await resetPasswordMutation(values) 23 toast({ 24 icon: "success", 25 description: "Saved new password", 26 }) 27 } catch (error) { 28 handleFormErrors({error, setFieldError: form.setFieldError, values}) 29 } 30 }} 31 children={(form) => ( 32 <form onSubmit={form.handleSubmit} className="flex flex-col"> 33 <Field 34 // 🔥 Fully type safe now! 35 name="password" 36 children={({field, meta}) => ( 37 <FormField label="Your new password" error={meta.touched && meta.error}> 38 <FormInput {...field} type="password" /> 39 </FormField> 40 )} 41 /> 42 43 <Button type="submit" disabled={form.isSubmitting} spin={form.isSubmitting}> 44 Set password 45 </Button> 46 </form> 47 )} 48 /> 49 ) 50}

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.

1import {makeForm, FormikProps, FieldComponent} from "@/form/Form" 2import {FormField, FormInput, Button} from "@/component-library" 3 4// Same makeForm usage at the top level 5const {Formik, Field} = makeForm({schema: MyFormSchema}) 6 7export function Form() { 8 // stuff 9 return ( 10 <Formik 11 initialValues={initialValues} 12 onSubmit={onSubmit} 13 children={(form) => ( 14 <form onSubmit={form.handleSubmit}> 15 {/* 🔥 pass in form and Field */} 16 <AbstractedFields form={form} Field={Field} /> 17 </form> 18 )} 19 /> 20 ) 21} 22 23export type AbstractedFieldsProps = { 24 form: FormikProps<typeof MyFormSchema> 25 Field: FieldComponent<typeof MyFormSchema> 26} 27 28const AbstractedFields = ({form, Field}: AbstractedFieldsProps) => { 29 // 🔥 form can be used in here with type safety 30 return ( 31 <Field 32 // 🔥 Still has type safety 33 name="myFieldName" 34 children={({field, meta}) => ( 35 <FormField label="My Field Name" error={meta.touched && meta.error}> 36 <FormInput placeholder="Web server" {...field} disabled={disabled} /> 37 </FormField> 38 )} 39 /> 40 ) 41}

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.

1// src/form/Form.tsx 2"use client" 3import { 4 FieldConfig, 5 FieldProps, 6 FormikConfig, 7 FormikProps, 8 FormikValues, 9 GenericFieldHTMLAttributes, 10 Field as OrigField, 11 Formik as OrigFormik, 12} from "formik" 13import React from 'react' 14import {ZodError, ZodSchema, z} from "zod" 15 16export type DeepKeys<T> = unknown extends T 17 ? string 18 : // eslint-disable-next-line 19 T extends readonly any[] 20 ? DeepKeysPrefix<T, keyof T> 21 : T extends object 22 ? Exclude<keyof T, ObjectKeys<T>> | DeepKeysPrefix<T, keyof T> 23 : never 24 25type DeepKeysPrefix<T, TPrefix> = TPrefix extends keyof T & (number | string) 26 ? `${TPrefix}.${DeepKeys<T[TPrefix]> & string}` 27 : never 28 29type ObjectKeys<T> = { 30 [K in keyof T]: T[K] extends object ? K : never 31}[keyof T] 32 33export type DeepValue<T, TProp> = T extends Record<string | number, any> 34 ? TProp extends `${infer TBranch}.${infer TDeepProp}` 35 ? DeepValue<T[TBranch], TDeepProp> 36 : T[TProp & string] 37 : never 38 39export type FieldAttributes< 40 FormValues extends FormikValues, 41 Name extends DeepKeys<FormValues> = DeepKeys<FormValues>, 42> = Omit<GenericFieldHTMLAttributes, "children"> & 43 Omit<FieldConfig, "name" | "component" | "as" | "render" | "children"> & { 44 children: (props: FieldProps<DeepValue<FormValues, Name>, FormValues>) => React.JSX.Element 45 name: Name 46 } 47 48type FormikFormComponent<FormValues extends Record<string, unknown>> = React.FC< 49 FormikConfig<FormValues> & { 50 initialValues?: FormValues 51 } 52> 53 54export const validateZodSchema = 55 (schema: ZodSchema | undefined) => async (values: Record<string, unknown>) => { 56 if (!schema) { 57 return {} 58 } 59 try { 60 await schema.parseAsync(values) 61 return {} 62 } catch (error: any) { 63 return formatZodError(error) 64 } 65 } 66 67export function formatZodError(error: ZodError) { 68 if (!error || typeof error.format !== "function") { 69 throw new Error("The argument to formatZodError must be a zod error with error.format()") 70 } 71 72 const errors = error.format() 73 return recursiveFormatZodErrors(errors) 74} 75 76export function recursiveFormatZodErrors(errors: any) { 77 let formattedErrors: Record<string, any> = {} 78 79 for (const key in errors) { 80 if (key === "_errors") { 81 continue 82 } 83 84 if (errors[key]?._errors?.[0]) { 85 if (!isNaN(key as any) && !Array.isArray(formattedErrors)) { 86 formattedErrors = [] 87 } 88 // @ts-expect-error this looks like a mistake from `formattedErrors = []` above 89 formattedErrors[key] = errors[key]._errors[0] 90 } else { 91 if (!isNaN(key as any) && !Array.isArray(formattedErrors)) { 92 formattedErrors = [] 93 } 94 // @ts-expect-error this looks like a mistake from `formattedErrors = []` above 95 formattedErrors[key] = recursiveFormatZodErrors(errors[key]) 96 } 97 } 98 99 return formattedErrors 100} 101 102function formikFactory<S extends z.ZodTypeAny>(schema: S): FormikFormComponent<z.input<S>> { 103 return function CustomFormik({ 104 children, 105 ...props 106 }: FormikConfig<z.input<S>> & {initialValues?: number}) { 107 return ( 108 <OrigFormik 109 validate={validateZodSchema(schema)} 110 {...props} 111 children={(form) => children && typeof children === "function" && children(form)} 112 /> 113 ) 114 } 115} 116function fieldFactory< 117 FormValues extends FormikValues, 118 Name extends DeepKeys<FormValues> = DeepKeys<FormValues>, 119>() { 120 return function CustomField<N extends Name>(props: FieldAttributes<FormValues, N>) { 121 return <OrigField {...props} /> 122 } 123} 124 125export type FieldComponent<FormValues extends Record<string, unknown>> = < 126 N extends DeepKeys<FormValues>, 127>( 128 props: FieldAttributes<FormValues, N>, 129) => JSX.Element 130 131export interface FormikContext<FormValues extends Record<string, unknown>> { 132 Formik: FormikFormComponent<FormValues> 133 Field: FieldComponent<FormValues> 134} 135 136export function makeForm<S extends z.ZodTypeAny>({schema}: {schema: S}): FormikContext<z.input<S>> { 137 const result = { 138 Formik: formikFactory(schema), 139 Field: fieldFactory<z.input<S>>(), 140 } 141 return result 142}

Ultimate command center for your cloud infrastructure

Ravion enhances and unifies IaC, deployments, CI/CD, and observability into a single interface for context and control. Your team can move faster with better reliability and less devops overhead.

Free to start / No credit card / Revoke access anytime