Typesafe forms in TypeScript

Have you ever written a form with multiple steps in JavaScript and thought "this isn't very elegant"? I have. Let's take a look at how we can make our forms better with type safety and TypeScript!

4 min read


By Vegard Veiset


December 7, 2021

Types and more types!

It's time, time for some types. We'll be making a typesafe form in TypeScript and the first thing we need for that is to figure out what a form page is. The minimum we need for a functional form with multiple steps is: a way to go to the next page, a way to go back and some way to display content for each of the pages in the form.

Let's take a look at the definition of a page.

interface Page<DATA, PAGENAME> {
  content(data: DATA): React.ReactNode;
  next?(data: DATA): PAGENAME;
  back?(data: DATA): PAGENAME;

We are using two generic types. One for the data (DATA) and one for the page name (PAGENAME). We'll be using the DATA type to share data between the pages in the form and the PAGENAME type to uniquely identify each of our pages.

We also have three functions. One for displaying the content (here by returning a ReactNode, but you can use any framework or no framework). Next we have two optional functions, one for going to the next page (next?) and one for going to the previous page (back?).

At the moment everything is very generic and doesn't do us much good in making our form typesafe. But let's start fixing that with defining the structure of our form!

interface FormStructure<DATA, PAGENAME extends string> {
  data: DATA;
  pages: { 
    pageName: PAGENAME,
    page: Page<DATA, PAGENAME>,

So what is a form? It's a collection of pages and data. Again, the data is some generic data we pass around in our form, and the pages is a collection of all pages the form contains.

With our page and form structure defined we now need to find a way for typescript to actually check the types when we'll be creating the form.

type Structure<DATA, PAGENAME extends string> = {

Creating a type with PAGENAME as keys allows us to use a union type of strings that will enforce pages to be both unique and exhaustive.

Up until now we have only defined types which aren't really related to each other. They use the same generic variable names, but so far they aren't connected. The next step is to make a function to connect all our types. We do this by creating a function that takes in Structure and returns the FormStructure.

export function createForm<DATA, PAGENAME extends string>(
  data: DATA,
  structure: Structure<DATA, PAGENAME>
): FormStructure<DATA, PAGENAME> {
  return {
    pages: Object.entries(structure).map(([pageName, page]) => ({
      pageName: pageName as PAGENAME, 
      page: page as Page<DATA, PAGENAME>

This part is a little bit hacky, but the TypeScript compiler needs a little bit of help to figure out that PAGENAME and PAGE are the same thing in both Structure and FormStructure when we use the Object.entities function to map the data. To fix this we simply cast the types.

And that's it for setting up the types we need for a typesafe form in TypeScript!

Let's try it out

Finally done with all setup and types, we are ready to create a form with some steps and some content.

type Pages = "Start" | "Replacement" | "NoReplacement";
const data = { replace: false, product: "The Game" }

const myForm = (): FormStructure<typeof data, Pages> => {
    return createForm(data, {
        Start: {
            content: (d) => <div>Your product: {d.product}</div>,
            next: (d) => (d.replace ? "Replacement" : "NoReplacement"),
        Replacement: {
            content: () => <div>Your order will be replaced</div>,
            back: () => "Start",
        NoReplacement: {
            content: () => <div>No replacement needed</div>,
            back: () => "Start",

An easy to read form structure with type safety! So which part of the form is typesafe? All of it of course! All pages defined in the Pages-type are required to be unique and passed to the createForm function. If we misspell a page name or forget to include it in the form the compiler will be mad at us and remind us not to do something stupid.

Remember our optional next and back functions? They are now typed and the return value must be a valid page name (again from the Pages-type). This means that each page with functionality to go to another page must be linked to a valid existing page. Great success!

And there we have it. A typesafe (branching) form with multiple steps and shared data, with types that will help us avoid silly mistakes.

type safety in action
Type safety in action

Using the structured form in action

Now that we have created a form based on the typesafe FormStructure we can start using it for navigation and displaying content. To display the content of a page we pass some data to the content function we defined in our Page type. To display navigation buttons we need to check if the page has a next or back function.

With our three functions content, next and back we have everything we need to create to create a form with navigation. And if you need a more advanced form we can simply add more things to the PAGE type, such as a submit button, a header or a way to display the current step.

// passed arguments
//   structure: FormStructure<DATA, PAGENAME>;
//   setPage: Dispatch<SetStateAction<PAGENAME>>;
//   selectedPage: PAGENAME;
//   data: DATA;

const pageData = structure.pages.find((e) => e.pageName === selectedPage);
const {pageName, page} = pageData;
const nextPage = page.next && page.next(data);
const prevPage = page.back && page.back(data);
const content = page.content(data);

return (
    {nextPage && 
      (<button type="submit" onClick={() => setPage(nextPage)}>{nextPage}</button>)
    {previousPage &&
      (<button type="submit" onClick={() => setPage(prevPage)}>{prevPage}</button>)

The full example code can be found over at github. Click the link below to check it out.