Pete's Log: Formik initialValues and TypeScript

Entry #1844, (Coding, Hacking, & CS stuff)
(posted when I was 42 years old.)

I have a love/hate relationship with Formik. When I first came across it, it was love at first sight, given how much boiler plate form code it saved writing. I successfully put it to use in several projects.

But I kept encountering scenarios where Formik and my workflow/style in TypeScript conflicted. Which always seemed odd given Formik is written in TypeScript.

But anyway, during the brief window between the release of React hooks and the release of Formik 2 (which is built on hooks), I wrote my own hooks-based form library that worked relatively well.

After Formik 2 came out, I eventually gave in and went back to Formik, because my solo project couldn't compete for feature parity with Formik. Yet Formik still finds opportunities to irk me.

My biggest gripe is the initial values that Formik wants. And this isn't really Formik's fault because of the way React works. And it's not even React's fault, because of the way HTML works, going back to the beginning.

I happily use Yup for validation with Formik. Which allows me to describe the form of my object. But if I want Formik to display my form validation errors for the fields that weren't touched directly by the user when I click submit, I have to supply an initialValues object that has a value for each field that I want a validation error shown for.

Now is when TypeScript and HTML and Formik start clashing. Let's say my form is backed by an object that looks something like this:

interface PersonInfo {
    name: string;
    age: number;
    companyId: number;
}

And my form looks something like

Name:
Age:
Company:

Well, in an ideal world, I'd like to just pass {} as my initialValues and let my Yup schema dictate what validation errors are shown. But Formik won't mark my fields as touched when the submit button is clicked if the fields aren't part of the initialValues object. Fine then. Since this is TypeScript, and my form might actually have real initialValues loaded via an API, we're looking at something that resembles this in simplified form.

let initialValues: PersonInfo;
if (api.itemExists()) {
  initialValues = api.getItem();
} else {
  initialValues = {
    name: '',
    age: 0,
    companyId: 0
  };
}

This works as expected, but now my form starts with a 0 in my age field, which I don't like:

Name:
Age:
Company:

If this was plain old JavaScript, I could just set age to '' because what is a type anyway. But TypeScript is my jam, so how do we make this work? Let's try some of our fancy TypeScript type transformations:

let initialValues: Partial<PersonInfo>;
if (api.itemExists()) {
  initialValues = api.getItem();
} else {
  initialValues = {
    name: '',
    age: undefined,
    companyId: undefined
  };
}

Sadly, this takes us almost back to where we started, and only name will get marked as touched when we hit submit, because

{
    name: '',
    age: undefined,
    companyId: undefined
}

is really just

{
    name: ''
}

as far as anyone is concerned. Well, when the going gets tough, the tough look at the definitions of fancy language builtins. And here's what Partial looks like:

type Partial<T> = {
    [P in keyof T]?: T[P];
};

Well, we can work with that. What we want is instead of making each key optional, let's make it so each key can be a value of its original type or an empty string. Easy enough:

type FormData<T> = {
    [P in keyof T]: T[P] | '';
};

Now this allows us to write

let initialValues: FormData<PersonInfo>;
if (api.itemExists()) {
  initialValues = api.getItem();
} else {
  initialValues = {
    name: '',
    age: '',
    companyId: ''
  };
}

This solves all my problems but one (see below). All my fields get marked as touched when I hit submit. Number fields don't show a 0 when I want them to really show no value. And better yet, since I'm not using Partial, if I add a new field to PersonInfo, TypeScript will yell at me if I don't add it to my initialValues.

The only thing that still bugs me is that I have to describe my objects three separate times. Once in the TypeScript type definition. Once in the initial values. And once in the Yup validation schema. Even if I switched to JavaScript I think I'd at best save one of those three definitions.

The struggle continues.