Creating Type-Safe Paths for Formik Forms

Formik is a great React form library; it takes in, validates, and submits user input in a way that’s fairly flexible and reusable. Unfortunately, Formik uses field names to tie a field into its form. These field names are typed only as strings and use a dotted path notation to dig into nested fields.

This approach presents challenges when the type of a form’s data grows in complexity, when the type of a particular field changes, or if the name of a field changes over time. These problems are less apparent in smaller applications where fields are rendered directly as the children of the forms they live in. But as an application grows, the string-based approach begins to cause headaches and bugs.

On my current project, we created a lightweight wrapper around Formik’s useField hook to provide a more type-safe way to specify which field a user input is tied to. We wanted to see type error at compile time (rather than runtime crunches) if the type of a form changed out from under a field.

A Basic Example

Let’s start with what it takes to represent a path at its most basic level. Consider the following type:

type TestType = {
  num: number;
  nestedTestObject: {
    nestedAgain: {
      nestedNumber: number;
      nestedAgainAgain: {
        nestedString: string;
      };
    };
  };
};

How could we represent a path to nestedString? How about an array of strings:

[
  "nestedTestObject",
  "nestedAgain",
  "nestedAgainAgain",
  "nestedString"
]

A simple function to create such an array could be:

function getPath(...keys: string[]): {
  return keys;
}

However, in order to get TypeScript to help us out, we need to constrain the keys passed to this function to be an ordered path through our type.

A (Slightly) Better Approach

TypeScript has the keyof keywork, which, applied to TestType, would result in the union type "num"| "nestedTestObject". This would be enough to constrain the first argument to our function.

To get one level of key type checking, we could change our function’s signature to:

function getPath<O, K extends keyof O>(k: K): string[];

This attempt still has problems, though. It’s impossible to pass correct type parameters at the call sight, and we still can’t specify what type our path needs to end in.

A Complete Solution

We can use TypeScript’s nested functions to “close over” a single type argument (the type of the field itself) and then return a set of function definitions that are based on that one argument. All of these definitions are actually implemented by our super-simple function from above, but now TypeScript will help us out so that we can only pass valid keys.

export type Path<TResult, TInput = any> = {
  _valueType: TResult;
  _inputType: TInput;
  keys: string[];
};

export function getPathBuilderFor<O>() {

function get<K extends keyof O>(k: K): Path<O[K], O>;

  function get<
    K extends keyof O,
    K2 extends keyof O[K],
    V extends O[K][K2]
  >(
    k: K,
    k2: K2,
  ): Path<O[K][K2], O>;

  function get<
    K extends keyof O,
    K2 extends keyof O[K],
    K3 extends keyof O[K][K2],
    V extends O[K][K2][K3]
  >(k: K, k2: K2, k3: K3): Path<O[K][K2][K3], O>;

  function get<
    K extends keyof O,
    K2 extends keyof O[K],
    K3 extends keyof O[K][K2],
    K4 extends keyof O[K][K2][K3],
    V extends O[K][K2][K3][K4]
  >(k: K, k2: K2, k3: K3, k4: K4): Path<O[K][K2][K3][K4], O>;

  function get(...props: string[]): Path<any> {
    return {
      keys: props,
    } as Path<any>;
  }

  return {get};
}

And here’s an example of our types in action:

function useTypedField(path: Path<string>) {
  const name = path.keys.join(".");
  // pass name into Formik's useField hook
}

const pathBuilder = getPathBuilderFor<TestType>()

const field = useTypedField(
  pathBuilder.get(
    "nestedTestObject",
    "nestedAgain",
    "nestedAgainAgain",
    "nestedString")

The function useTypedField above is an example of how we can use our path and path builder types to enforce that the caller of useTypedField actually provides a valid path from some form structure to a string. If the path through the object ever changes because a nested field name changes, or if we want to change the type of useTypedField to expect a path to a number or some other more complex type, we’ll get editor assistance in tracking those changes from the form to the field, no matter how spread out our components may be.

This function and its types demonstrate how TypeScript derives its expressive power by constraining what we’re able to do in code. If you take away all of the types, our function implements almost no real logic. But by leveraging nested functions and TypeScript’s keyof keyword, we can layer on restrictions to our otherwise unhelpful function.