In English
October 25, 2023

Stop Using String Field Names in Your Forms (and Use Locators Instead)

You can also read this article in Russian.

As Leo Tolstoy said, all React form libraries are alike; each one makes me unhappy in its own way. Or was it something to do with families?

Anyway, here’s a typical form made with Formik:

<Formik
  initialValues={{ email: "" }}
  onSubmit={(values) => {
    /* ... */
  }}
>
  <Form>
    <Field name="email" as={EmailInput} />
  </Form>
</Formik>

It’s trivially easy to misspell a field name. In this case, it would be reasonable to expect a type error during compilation, or at least a runtime error:

      // typo! 👇
<Field name="notmail" as={EmailInput} />

Unfortunately, Formik does nothing to help us find the typo. What we get instead are unexpected form values in the onSubmit callback.

Another source of unexpected behavior is incompatible components. Let us say we’re using a custom component for our email input that looks something like this:

function EmailInput(props: {
  value: string;
  onChange: (value: string) => void;
         // that's 👆 not compatible with Formik!
}) {
  /* ... */
}

It’s difficult to spot, but the signature for the onChange callback of this component is incompatible with Formik. Again, we won’t get compilation or runtime errors. Instead, the form just won’t work.

The reason that Formik cannot catch these types of errors is that the Field component doesn’t know anything about our form schema. Without the information about the form schema, the Field component cannot check if a field name is valid or if a component has compatible prop types for that field.

Let’s take a look at the more modern React Hook Form:

const { handleSubmit, control } = useForm({
  defaultValues: { email: "" },
});

return (
  <form
    onSubmit={handleSubmit((values) => {
      /* ... */
    })}
  >
    <Controller
      name="email"
      control={control}
      render={({ field }) => <EmailInput {...field} />}
    />
  </form>
);

Right out of the box, the situation is much better. A typo in a field name is immediately caught by the type system. That’s thanks to a neat FieldPath type defined within the library:

FieldPath<{ users: { email: string }[] }>
// "users" | `users.${number}` | `users.${number}.email`

React Hook Form also checks for some potential component compatibility issues:

function EmailInput(props: {
  value: number;
      // 👆 that's the wrong type for email!
  onChange: (value: string) => void;
}) {
  /* ... */
}

<Controller
  name="email"
  control={control}
  render={({ field }) => <EmailInput {...field} />}
/>;
// 🔴 Error:
// Types of property 'value' are incompatible.
// Type 'string' is not assignable to type 'number'.

However, it’s not without its own drawbacks. Yes, it requires a component’s value prop type to be compatible with the field. But at the same time React Hook Form doesn’t check for the onChange callback compatibility. Still, this can be fixed in a future release.

More importantly, all these type safety features are based on the form schema information inferred from the type of the control object. This means no type safety is guaranteed when the control object is passed implicitly via the FormProvider context:

const methods = useForm({
  defaultValues: { email: "" },
});

return (
  <FormProvider methods={methods}>
    <form>
      {/* that's a typo, but there won't be any errors! */}
      <Controller
        name="notmail"
        render={({ field }) => <EmailInput {...field} />}
      />
    </form>
  </FormProvider>
);

And finally, even if the control object is passed explicitly, sometimes these type safety features just aren’t very handy.

Let’s say we want to implement a reusable component for editing an address within a form. An address consists of several fields, and since we want our component to be reusable, we want to pass a prefix for those fields as a prop:

function AddressFieldset<T extends FieldValues>(props: {
  prefix: FieldPath<T>;
  control: Control<T>;
}) {
  return (
    <>
      <Controller
        control={props.control}
        name={`${props.prefix}.city`}
        render={() => {
          /* ... */
        }}
      />
      {/* ... */}
    </>
  );
}
// 🔴 Error:
// Type '`${Path<T>}.city`' is not assignable to type 'Path<T>'.

Unsurprisingly, the type system doesn’t allow us to append ".city" to a random field name since we cannot even be sure that the prefix points to an address. In this case, our only option is to opt out of strict typing and just use string instead of FieldPath:

function AddressFieldset(props: { prefix: string; control: Control }) {
  /* ... */
}

And that’s just unsportsmanlike!


Next, I’ll describe an interesting type of objects I’ll call “locators”. Using locators instead of strings for field names makes implementing type-safe forms just so much easier.

Introducing Locators

I think it’s best to start with an example. Take a look at the form schema:

type FormValues = {
  email: string;
  address: {
    city: string;
    street: string;
  };
};

This is a locator for this form:

const pathTag = Symbol("path");

const locator = {
  [pathTag]: "",
  email: {
    [pathTag]: "email",
  },
  address: {
    [pathTag]: "address",
    city: { [pathTag]: "address.city" },
    street: { [pathTag]: "address.street" },
  },
};

You’ll notice two things about this object:

  1. it has the same shape as the form values object, and
  2. instead of field values, it stores field paths.

You can say that a locator is an object that stores a path for each of its properties: locator.email[pathTag] equals "email", and locator.address.city[pathTag] equals "address.city".

To add a type annotation to this locator, we could simply write something like this:

const pathTag = Symbol("path");

interface Locator {
  [pathTag]: string;
  email: {
    [pathTag]: string;
  };
  address: {
    [pathTag]: string;
    city: {
      [pathTag]: string;
    };
    street: {
      [pathTag]: string;
    };
  };
}

But let’s go a step further. Along with a path for each form field stored in the locator’s value, we’ll actually have a type for each form field stored in the locator’s type:

const typeTag = Symbol("type");
const pathTag = Symbol("path");

interface Locator {
  [typeTag]?: FormValues;
  [pathTag]: string;
  email: {
    [typeTag]?: FormValues["email"]; // 👈
    [pathTag]: string;
  };
  address: {
    [typeTag]?: FormValues["address"]; // 👈
    [pathTag]: string;
    city: {
      [typeTag]?: FormValues["address"]["city"]; // 👈
      [pathTag]: string;
    };
    street: {
      [typeTag]?: FormValues["address"]["street"]; // 👈
      [pathTag]: string;
    };
  };
}

The neat thing is, we don’t have to do it manually. We can construct locator type for any form schema with a recursive generic type:

const typeTag = Symbol("type");
const pathTag = Symbol("path");

type Locator<T> = {
  [typeTag]?: T;
  [pathTag]: string;
} & (T extends Record<string, unknown>
  ? { [K in keyof T]: Locator<T[K]> }
  : T extends (infer R)[]
  ? Locator<R>[]
  : {});

But can we actually implement an object that matches this type? Sure we can! A locator is basically a string builder that appends a property name to a string each time that property is accessed. We can implement it using a proxy:

function getLocator<T>(prefix: string = ""): Locator<T> {
  return new Proxy(
    {},
    {
      get(target, key) {
        if (key === pathTag) {
          return prefix;
        }
        
        if (typeof key === "symbol") {
          throw new Error("Symbol path segments are not allowed in locator");
        }
        
        const nextPrefix = prefix ? `${prefix}.${key}` : key;
        return getLocator(nextPrefix);
      },
    }
  ) as Locator<T>;
}

This is a reference implementation. There’s some room for optimazation, e.g. we can reuse the same get handler for all proxies instead of creating a new closure each time.

So, how do locators help us implement type-safe forms? Here’s what an API for our hypothetical locator-based form library might look like:

function useForm<T>(options: { initialValues: T }): { n: Locator<T> } {
  return {
    n: getLocator<T>(), // 👈1️⃣
    /* ... */
  };
}

function Field<V>(props: {
  name: Locator<V>; // 👈2️⃣
  render: (field: {
    name: string;
    value: V; // 👈3️⃣
    onChange: (value: V) => void;
  }) => ReactElement;
}) {
  return props.render({
    name: props.name[pathTag],
    /* ... */
  });
}

There are three things to note here:

  1. The useForm hook returns the “root” locator for the form schema, called n for brevity.
  2. Locator is used instead of a string for the name prop of the Field component.
  3. The Field component can infer the value type of the field from the locator and type arguments for its render prop accordingly.

The user of our library gets the root locator from the useForm hook and must use it to construct field names:

const { n } = useForm({
  initialValues: {
    users: [
      {
        email: "",
        address: {
          city: "",
          street: "",
        },
      },
    ],
  },
});

<Field
  name={n.users[0].email}
  render={(field) => <EmailInput {...field} />}
/>;

Since the locator’s type has the same shape as the form values, there’s no way to build a locator that points to a non-existent field. And the types for our field’s render prop ensure that whatever component is used for the field, it is compatible with the field’s value type.

It’s also very easy to create a reusable set of fields:

function AddressFieldset(props: {
  prefix: Locator<{ city: string; street: string }>;
}) {
  return (
    <>
      <Field
        name={props.prefix.city}
        render={() => {
          /* ... */
        }}
      />
      {/* ... */}
    </>
  );
}

<AddressFieldset prefix={n.users[0].address} />

This component only accepts a locator pointing to an address in its prefix prop. We can be sure that the value pointed to by this locator is indeed an address, and has all the properties an address should have.

Even better, locator types are covariant, so instead of passing an address, we can pass a locator that points to any value which is assignable to an address!

And as a cherry on top, you can totally rename a property on the FormValues type, and your IDE will rename that property in every locator where it’s used. That’s super useful for refactoring!


There’s just one more thing: it’s still possible to accidentally pass a locator from one form to a field on another form:

const { form, n } = useForm({
  initialValues: { email: '' }
});

const { form: anotherForm, n: anotherN } = useForm({
  initialValues: { notemail: '' }
});

<FormProvider form={anotherForm}>
  {/* that's bad 👇 there's no "email" in "anotherForm" */}
  <Field name={n.email} render={() => {
    /* ... */
  }} />
</FormProvider>

We can’t catch that at build time. But at least we can handle this error at runtime by “branding” our locators.

A brand is just a unique value that we’ll add to a form’s root locator. Each locator constructed from that root locator should have the same brand. And in the Field component, we’ll check that the brand of the locator matches the brand of the form:

function useForm<T>(options: { initialValues: T }) {
  const [brand] = useState(() => Symbol('formBrand'));

  return {
    // 1️⃣ the root locator "n" and all other locators constructed
    // from it will be branded by the same unique value
    n: getLocator<T>(brand),

    // 2️⃣ the same unique value is accessible via context as well
    form: { brand, /* ... */ },
  };
}

function Field<V>(props: {
  name: Locator<V>;
  /* ... */
}) {
  const { brand } = useContext(FormContext);

  // 3️⃣ matching the form brand with the locator brand
  if (brand !== name[brandTag]) {
    throw new Error('Foreign locator used to address form field');
  }

  /* ... */
}

It’s a shame we can’t express the same guarantee in the type system. But unfortunately, React contexts are hard to type properly.


I’ve worked on a project with dozens of huge web forms, some with more than a hundred fields. (As you’ve probably guessed it, this job was in the B2B division of a large bank.) Ensuring a smooth user experience was key, but we couldn’t have done it without great developer experience. Taking the approach described in this article was a huge help. It gave us all the tools we needed to catch bugs early and ship with confidence.

I hope it helps you too!