Exploring TypeScript Generics with Multi-Step Forms in React-Native

When it was time to introduce multi-step forms in my latest React project I decided to pause every other feature in order to get this done right once and for all. I did not succeed, but I learned a lot along the way.


Most people are familiar with the concept of a multi-step form. A sequence of atomic steps and validation procedures that bring the user to a final submission interface. The idea is to reduce UI crowdedness and avoid user drop-out. And, while opinions online seem to be conflicting (see Baymard study), I would argue it is generally a good practice in the context of mobile development.

Multi-step Forms

I have been working with multi-step forms in React for many years. However, I never reached an implementation that fully satisfied me in terms of reusability, type safety, and conciseness. While working on this, I stumbled upon many edge-cases in the TypeScript language and I believe some might find interesting to reason about language limitations while looking at a concrete example.

When it was time to introduce multi-step forms in my latest project (a React Native application) I decided to pause every other feature in order to get this done right once and for all. The context of the application does not really affect our discussion here, but it is important to note that I had some requirements for my final implementation:

  • I want the individual form steps to have independent validation on their subset of fields. This allows them to be modular and reusable, and it means that going to the next step can be prevented if the form has errors.
  • I do NOT want to rely on page navigation of any kind. The state of the form should be handled simply within a single page and/or component of the application.
  • Utilise the minimum amount of dependencies. I will rely on react-hook-form only and leave validation and localisation libraries as a bonus section at the end.
  • Type safe onSubmit function or some alternative to a root component that handles everything (note that there are many libraries out there for multi-step forms but they mostly rely on 'unsafe' typing, e.g. Formiz - form.types.ts, the reason for this is an issue with existential generics that we will discuss later). I usually build my backends with tRPC and losing end-to-end type safety due to a UI/UX concern is not an option.
  • The 'steps' are a frontend concept related to UX, the backend endpoints should NOT change nor retain any state.

This is gonna gonna be mostly an abstract discussion of my component design process, I will leave comments where platform specific details incur.

The ideal result

The original design for the form component API looked something like this:

 1<MultiStepForm
 2  steps={[
 3    {
 4      name: "Step 1",
 5      renderComponent: (formProps) => <form>{/*...*/}</form>,
 6      formOptions: {
 7        defaultValues: { someField: "" },
 8        resolver: /*...*/,
 9        /*...*/
10      },
11    },
12    ...
13    {
14      name: "Step N",
15      renderComponent: (formProps) => <form>{/*...*/}</form>,
16      formOptions: {/*...*/},
17    },
18  ]}
19  onSubmit={(values) => {/*...*/}}
20 />

Someone acquainted with the TypeScript ecosystem could have already spotted some issues with this. For example, how do we infer the type of values in the onSubmit function? But let's not get too far ahead.

First obstacles

My first naive approach was the following:

 1import { FieldValues, UseFormProps, UseFormReturn } from "react-hook-form";
 2
 3interface MultiStepFormProps<T extends FieldValues> {
 4  steps: Step<T>[];
 5  onSubmit: (values: T) => void;
 6}
 7
 8interface Step<T extends FieldValues> {
 9  name?: string;
10  renderComponent: (props: UseFormReturn<T>) => React.ReactNode;
11  formOptions?: UseFormProps<T>;
12}

Wow! We are done, aren't we? We can pass a generic step object to the MultiStepFormProps and get type inference based on the validation schema or default values in formOptions.

Not quite. What we imply when we say Step<T>[] is that all steps are going to have the same field values type. If we try and provide steps with a different formOptions.resolver or formOptions.defaultValues we get a compiler error.

What we really need is something like the following:

1interface MultiStepFormProps<any T: T extends FieldValues> {
2  // ....
3}

Most of you might have noticed this is not valid syntax. What I tried to express here with this pseudo-notation is an existential generic. Unfortunately, these are not supported in TypeScript (see this GitHub issue).

An interesting detour that brought nowhere

In order to relax the generic constraint on the component props, I stumbled upon this typing pattern which removes the generic altogether and replaces it with a curried-like function:

1type SomeStep = <R>(consumer: <T extends FieldValues>(step: Step<T>) => R) => R;

Let's decompose this for a second. SomeStep is now a non-generic type that represents a function which takes a T-generic consumer and returns some type R. The consumer also returns R but instead takes a Step<T> as an argument. This means we can finally remove the generic type annotation from MultiStepFormProps:

1interface MultiStepFormProps {
2  steps: SomeStep[];
3  ...
4}

This looks like gibberish, but definitely makes more sense when we look at the actual usage of this type. We first define a builder function for our Step type:

1const buildStep =
2  <T extends FieldValues>(step: Step<T>): SomeStep =>
3  (cb) =>
4    cb(step);

``This way we are providing users of the form a way to create new instances ofSomeStepby only usingStep objects:

1buildStep({
2  name: "step 1",
3  renderComponent: (stepProps) => <form>{/*...*/}</form>,
4  formOptions: {...}
5}), // type: SomeStep

Which allows us to internally pass them to consumer functions:

1export default function MultiStepForm(props: MultiStepFormProps) {
2  // ...state management etc.
3  return props.steps[currentStepIdx](MultiStepForm.Step);
4}
5
6MultiStepForm.Step = <T extends FieldValues>(props: Step<T>) => {
7  const renderProps = useForm<T>(props);
8  return props.step.renderComponent(renderProps);
9};

Here we have a consumer which simply renders a step of the form based on some state currentStepIdx. This is completely type safe and it means we can actually access Step properties in our sub-component MultiStepForm.Step.

This is amazing, we can pass a collection of heterogenous Step in our props, and the whole complexity is completely transparent to the user as long as they use our utility buildStep. What are we missing then?

We actually needed some form of root generic typing

Well, MultiStepFormProps also has a second crucially important field: onSubmit. This is a function that takes our final (potentially validated) form values and allows us the component user to finalise the operation as they see fit (e.g. calling some API).

However, we removed the generic annotation from our interface definition, how do we express the relationship between the steps value-schema and our onSubmit argument? In other words, how do we express this?

1interface MultiStepFormProps {
2  steps: SomeStep[];
3  onSubmit: (values: IntersectionOfStepValues) => void;
4}
5
6type IntersectionOfStepValues = // ???

Now, let's start from scratch. We can easily think of a way to express an intersection given a list of types (see below), what we lack is the ability to reference the steps field in the onSubmit definition. Some form of reflection within the type itself.

1type IntersectionOf<A extends any[]> = A extends [infer T, ...infer R]
2  ? T & IntersectionOf<R>
3  : never;

What if we introduce some generic type again? Like the following:

1interface MultiStepFormProps<TSomeSteps extends SomeStep[]> {
2  steps: TSomeSteps;
3  onSubmit: (values: IntersectionOfStepValues<TSomeSteps>) => void;
4}

This looks like progress even if very convoluted. We are now generic in terms of the SomeStep collection, which means we can pass this generic to our yet to be implemented IntersectionOfStepValues.

Let's try to build the intersection (we are using defaultValues for 'simplicity', it would be better to infer the validation schema type but it is library-dependent):

1type IntersectionOfFieldValues<A extends SomeStep[]> =
2  A extends [infer T extends SomeStep, ...infer R extends SomeStep[]]
3  ? ReturnType<Parameters<Parameters<T>[0]>[0]["formOptions"]["defaultValues"] & IntersectionOfFieldValues<R>
4  : never;

Wow this looks ugly. And there's worse, it does not work as the inferred type for every instance is always any for some reason beyond my understanding. I guess we can fairly say this overcomplicated typing mechanism cannot give us what we need.

Conclusion (pt.1)

Finally, we can now understand better why most multi-step form libraries drop the type safety on the onSubmit function.

I eventually decided to stick with an any type for the time being. I can always regain safety by (redundantly) parsing my final data with some validation library before sending it to my backend.

However, I want to go back to this problem after researching more and see what can be done. Stay tuned for part 2!