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!
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> = {
[k in PAGENAME]: Page<DATA, PAGENAME>;
};
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 {
data,
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.
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 (
<div>
{content}
<form>
{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.