FlightControl
FlightControl Home

Add Type Safety to Formik’s Field Component

Brandon Bayer

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
}

Deploy apps 2-6x faster, 50-75% cheaper & near perfect reliability

Learn more
App screenshotApp screenshot