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