loading...

How to Forms with React and Typescript

steida profile image Daniel Steigerwald ・3 min read

Forms are hard because they tend to be complex. Email validation is easy, but configurable form with several sync and async validations is hard. That's why Facebook created React by the way. To handle all that logic somehow. And yet still after many years, here I am writing yet another article about form validation. But not only about it, because validation is just one piece from the bigger picture.

Domain Model

Everything starts with data and their shape - a domain model. A form is a domain model too. Is this ok or not?

interface SignUpForm {
  email: string;
  password: string;
}
const signUpForm: SignUpForm = { email: '', password: '' };

Is any string email? No. A string must be validated to be an email. The same for a password. SignUpForm with strings is suboptimal. In classical Java etc. world, we would have to use classes. With TypeScript, we can use branded types.

import * as t from 'io-ts';

interface EmailBrand {
  readonly Email: unique symbol;
}

// This will create runtime type.
const Email = t.brand(
  t.string,
  // isEmail from validator.
  (s): s is t.Branded<string, EmailBrand> => isEmail(s),
  'Email',
);
// This will create TypeScript type from io-ts runtime type.
type Email = t.TypeOf<typeof Email>

interface SignUpForm {
  email: Email;
  password: Password;
}

// No, we can't just assign a string. TypeScript compiler will tell us.
const signUpForm: SignUpForm = { email: '', password: '' };

// Email has a decode method, which return either Error or Email.
const either: Either<Error, Email> = Email.decode(whatEver);

It seems to be a lot of code, but once it's written, it's reusable forever. We created some basic runtime types for you. Functional programming is all about composition. Sure we can compose runtime types too. Check a full-fledged example of a sign-up form.

import { String64, Email, UniqueEmail, Password, Phone } from 'typescript-fun';

const SignUpForm = t.type({
  company: String64,
  email: t.union([Email, UniqueEmail]),
  password: Password,
  phone: option(Phone),
  sendNewsletter: t.boolean,
});

There are some interesting things. The email field is a union of Email and UniqueEmail. Every UniqueEmail is Email but not every Email is UniqueEmail. Take a look at User type. We can not pass any Email there, only UniqueEmail. The typeScript compiler ensures it.

import { String64, Email, UniqueEmail, Password, Phone } from 'typescript-fun';

const User = t.type({
  email: UniqueEmail,
});

Email type itself is the intersection of String64 and EmailString. And String64 is the intersection of NonEmptyTrimmedString and Max64String.

Note option(Phone), the option is a generic type. It makes any type optional. Traditionally, we would use a null or undefined or empty string, which is a very suboptimal and not generic approach. We need to tell "This type is an optional type of another type.". We use the Option monad.

Every branded type has a string name, which we can use for validation error messages. If some email is Email, we can check whether it's also unique on the server, and if not, we can show error "This email is already used." The beauty of this approach is endless scalability and perfect type correctness from day 0.

Forms

So far we have seen a domain model that can be validated in any context, but how to make a form from that? We made a little yet powerful React Hook for that.

const form = useForm(
  SignUpForm,
  {
    company: '',
    email: '',
    password: '',
    // O.none, because it's explicit. Null, undefined, empty string are not.
    phone: O.none,
    sendNewsletter: false,
  },
  {
    handleSubmit(form) {
      pipe(
        form.validated,
        E.fold(constVoid, data => {
          form.disable();
          // Simulate async sign up.
          setTimeout(() => {
            form.enable();
            if (data.email === 'a@a.com') {
              form.setAsyncErrors({ email: ['UniqueEmail'] });
            } else {
              alert(JSON.stringify(data, null, 2));
              form.reset();
            }
          }, 1000);
        }),
      );
    },
  },
);

React Hook useForm provides a lot of helpers for any forms written with any UI library. Check typescript.fun/examples.

We plan to write more articles about typed functional programming in TypeScript and more helpers like useForm.

Follow us twitter.com/estejs or check our typescript.fun web.

Discussion

pic
Editor guide
Collapse
southpaw profile image
Alex Gabites

Rather than reinventing the wheel, why not just use tried and tested libraries like Formik for creation and state management + Yup for validation?

Once you've used them, you'll never go back.

Collapse
steida profile image
Daniel Steigerwald Author

Because it’s much better. Check what io-ts does, it generates types, so everything is typed. The same for useForm, it generates fields from models. As result, we write much less much powerfull code.

io-ts is battle tested and useForm is so simple there is almost no room for errors.

Collapse
southpaw profile image
Alex Gabites

Assuming something is 'much better' without having tried the other things out there is a recipe for disaster and a pitfall that many developers fall into - its why people stagnate in the industry! 😂

Using Formik and Yup you also get a fully typed interface for each field along with extra bits you'll find you use in every form such as per field touch management, error handling, submission states and more.

I don't see how you can make the claim that it is any less powerful than this useForm hook.

Thread Thread
steida profile image
Daniel Steigerwald Author

Don't get me wrong, I know both Formik and Yup. Yup does not support branded types nor option nor any other fp-ts types. But thank you for a comment, I should highlight it.

Anyway, please check the first five minutes of this talk youtube.com/watch?v=PLFl95c-IiU to understand my motivation. It's about F#, but with io-ts, we can model a much stricter schema in a similar way. For example, string which is only the email, not any string, or an array with the first item of type A and second optional type B. We can express a lot of business rules. Email and Verified email (both strings) with type safety. Hopefully, soon you realize that plain string or number types are close to any. I recommend you try both examples.

As for useForm. Once we use io-ts, for the reasons I mentioned, we can not use Formik, and honestly, that hook is so simple yet powerful, that we don't have to. There are also other design decisions why I did not make Formik clone, but that's for another blog post.

Collapse
regisfoucault profile image
Régis Foucault

Do you have the full code of this example ?

Collapse
steida profile image