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:
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:
- The
useForm
hook returns the “root” locator for the form schema, calledn
for brevity. - Locator is used instead of a string for the
name
prop of theField
component. - 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.