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}The easiest way to use AWS.
For agents and humans.
Get a Vercel-like experience in your own AWS account. You and your agents get one place to manage and monitor Terraform resources, builds, and deploys.
Free to start / No credit card / Revoke access anytime