Advanced typescript for React developers - part 3

Nadia Makarevich - Dec 20 '21 - - Dev Community

Image description

This is a third article in the series of “Advanced typescript for React developers”. In the previous chapters we together with ambitious developer Judi figured out how and why Typescript generics are useful for creating reusable React components, and understood such typescript concepts as type guards, keyof, typeof, is, as const and indexed types. We did it while implementing with Judi a competitor to Amazon: an online website that has different categories of goods and the ability to select them via a select component. Now it’s time to improve the system once again, and to learn in the process what is the purpose of exhaustiveness checking, how the narrowing of types works and when typescript enums could be useful.

You can see the code of the example we’re starting with in this codesandbox.

Exhaustiveness checking with never

Let’s remember how we implemented our Tabs with categories. We have an array of strings, a switch case that for every tab returns a select component, and a select component for categories themselves.

const tabs = ["Books", "Movies", "Laptops"] as const;
type Tabs = typeof tabs;
type Tab = Tabs[number];

const getSelect = (tab: Tab) => {
  switch (tab) {
    case "Books":
      return (
        <GenericSelect<Book> ... />
      );
    case "Movies":
      return (
        <GenericSelect<Movie> ... />
      );
    case "Laptops":
      return (
        <GenericSelect<Laptop> ... />
      );
  }
};

export const TabsComponent = () => {
  const [tab, setTab] = useState<Tab>(tabs[0]);

  const select = getSelect(tab);

  return (
    <>
      Select category:
      <GenericSelect<Tab>
        onChange={(value) => setTab(value)}
        values={tabs}
        formatLabel={formatLabel}
      />
      {select}
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

Everything is perfectly typed, so if a typo happens anywhere it will be picked up by Typescript. But is it perfectly typed though? What will happen if I want to add a new category to the list: Phones? Seems easy enough: I just add it to the array and to the switch statement.

const tabs = ["Books", "Movies", "Laptops", "Phones"] as const;

const getSelect = (tab: Tab) => {
  switch (tab) {
    // ...
    case "Phones":
      return (
        <GenericSelect<Phone> ... />
      );
  }
};
Enter fullscreen mode Exit fullscreen mode

And in a simple implementation like this, it wouldn’t bring much trouble. But in real life more likely than not this code will be separated, abstracted away, and hidden behind layers of implementation. What will happen then if I just add Phones to the array, but forget about the switch case?

const tabs = ["Books", "Movies", "Laptops", "Phones"] as const;

const getSelect = (tab: Tab) => {
  switch (tab) {
    case "Books":
      // ...
    case "Movies":
      // ...
    case "Laptops":
      // ...
  }
};
Enter fullscreen mode Exit fullscreen mode

With this implementation - nothing good, unfortunately. Typescript will be totally fine with it, the bug might be missed during manual testing, it will go to production, and when customers select “Phones” in the menu, they won’t see anything on the screen.

It doesn’t have to be like this though. When we use operators like if or switch typescript performs what is known as “narrowing”, i.e. it reduces the available options for the union types with every statement. If, for example, we have a switch case with only “Books”, the “Books” type will be eliminated at the first case statement, but the rest of them will be available later on:

const tabs = ["Books", "Movies", "Laptops"] as const;

// Just "Books" in the switch statement
const getSelect = (tab: Tab) => {
  switch (tab) {
    case "Books":
      // tab's type is Books here, it will not be available in the next cases
      return <GenericSelect<Book> ... />
    default:
      // at this point tab can be only "Movies" or "Laptops"
      // Books have been eliminated at the previous step
  }
};
Enter fullscreen mode Exit fullscreen mode

If we use all the possible values, typescript will represent the state that will never exist as never type.

const tabs = ["Books", "Movies", "Laptops"] as const;

const getSelect = (tab: Tab) => {
  switch (tab) {
    case "Books":
      // "Books" have been eliminated here
    case "Movies":
      // "Movies" have been eliminated here
    case "Laptops":
      // "Laptops" have been eliminated here
    default:
      // all the values have been eliminated in the previous steps
      // this state can never happen
      // tab will be `never` type here
  }
};
Enter fullscreen mode Exit fullscreen mode

And watch the hands very carefully for this trick: in this “impossible” state you can explicitly state that tab should be never type. And if for some reason it’s not actually impossible (i.e. we added “Phones” to the array, but not the switch - typescript will fail!

// Added "Phones" here, but not in the switch
const tabs = ["Books", "Movies", "Laptops", "Phones"] as const;

// Telling typescript explicitly that we want tab to be "never" type
// When this function is called, it should be "never" and only "never"
const confirmImpossibleState = (tab: never) => {
  throw new Error(`Reacing an impossible state because of ${tab}`);
};

const getSelect = (tab: Tab) => {
  switch (tab) {
    case "Books":
      // "Books" have been eliminated
    case "Movies":
      // "Movies" have been eliminated
    case "Laptops":
      // "Laptops" have been eliminated
    default:
      // This should be "impossible" state,
      // but we forgot to add "Phones" as one of the cases
      // and "tab" can still be the type "Phones" at this stage.

      // Fortunately, in this function we assuming tab is always "never" type
      // But since we forgot to eliminate Phones, typescript now will fail!
      confirmImpossibleState(tab);
  }
};
Enter fullscreen mode Exit fullscreen mode

Now the implementation is perfect! Any typos will be picked up by typescript, non-existing categories will be picked up, and missed categories will be picked up as well! This trick is called Exhaustiveness checking by the way.

Exhaustiveness checking without never

Interestingly enough, for the exhaustiveness trick to work, you don’t actually need never type and the “impossible” state. All you need is just to understand this process of narrowing and elimination, and how to “lock” the desired type at the last step.

Remember, we had our formatLabel function that we pass to the select component, that returns the desired string for the select options based on the value type?

export type DataTypes = Book | Movie | Laptop | string;

export const formatLabel = (value: DataTypes) => {
  if (isBook(value)) return `${value.title}: ${value.author}`;
  if (isMovie(value)) return `${value.title}: ${value.releaseDate}`;
  if (isLaptop(value)) return value.model;

  return value;
};
Enter fullscreen mode Exit fullscreen mode

Another perfect candidate for exactly the same bug - what will happen when we add Phone as one of the data types, but forget the actual check? With the current implementation - nothing good again, the Phone select options will be broken. But, if we apply the exhaustiveness knowledge to the function, we can do this:

export type DataTypes = Book | Movie | Laptop | Phone | string;

 // When this function is called the value should be only string
 const valueShouldBeString = (value: string) => value;

 const formatLabel = (value: DataTypes) => {
  // we're eliminating Book type from the union here
  if (isBook(value)) return `${value.title}: ${value.author}`;

  // here value can only be Movie, Laptop, Phone or string

  // we're eliminating Movie type from the union here
  if (isMovie(value)) return `${value.title}: ${value.releaseDate}`;

  // here value can only be Laptop, Phone or string

  // we're eliminating Laptop type from the union here
  if (isLaptop(value)) return value.model;

  // here value can only be Phone or string

  // But we actually want it to be only string
  // And make typescript fail if it is not
  // So we just call this function, that explicitly assigns "string" to value

  return valueShouldBeString(value);

  // Now, if at this step not all possibilities are eliminated
  // and value can be something else other than string (like Phone in our case)
  // typescript will pick it up and fail!
};
Enter fullscreen mode Exit fullscreen mode

We have eliminated all the possible union types except string, and “locked” string in the final step. Pretty neat, huh?

See fully working example in this codesandbox.

Improving code readability with Enums

Now it’s the time for the final polish of this beautiful piece of typescript art that is our categories implementation. I don’t know about you, but this part worries me a bit:

const tabs = ["Books", "Movies", "Laptops"] as const;
type Tabs = typeof tabs;
type Tab = Tabs[number];
Enter fullscreen mode Exit fullscreen mode

There is nothing wrong with it per se, it just slightly breaks my brain every time I’m looking at the constructs like that. It always takes one-two additional seconds to understand what exactly is going on here. Fortunately, there is a way to improve it for those who suffer from the same issue. Did you know that Typescript supports enums? They allow defining a set of named constants. And the best part of it - those are strongly typed from the get-go, and you can literally use the same enum as type and as value at the same time. 🤯

Basically this:

const tabs = ["Books", "Movies", "Laptops"] as const;
type Tabs = typeof tabs;
type Tab = Tabs[number];
Enter fullscreen mode Exit fullscreen mode

Could be replaced with this, which is arguably much easier and more intuitive to read:

enum Tabs {
  'MOVIES' = 'Movies',
  'BOOKS' = 'Books',
  'LAPTOPS' = 'Laptops',
}
Enter fullscreen mode Exit fullscreen mode

And then, when you need to access a specific value, you’d use dot notation, just like an object:

const movieTab = Tabs.MOVIES; // movieTab will be `Movies`
const bookTab = Tabs.BOOKS; // bookTab will be `Books`
Enter fullscreen mode Exit fullscreen mode

And just use Tabs when you want to reference the enum as a type!

If we look at our tabs code, we can just replace all the Tab types with enum Tabs and all the tabs strings with enum’s values:

Image description

And, in the actual implementation of the Tabs component the same: replace the type, replace values, and pass to select component enum’s values in the form of an array:

Image description

See the full code example in this codesandbox.

Perfection! 😍 😎

That is all for today, hope you enjoyed the reading and now feel a little bit more confident with typescript’s narrowing, exhaustiveness checking and enums. See ya next time 😉

...

Originally published at https://www.developerway.com. The website has more articles like this 😉

Subscribe to the newsletter, connect on LinkedIn or follow on Twitter to get notified as soon as the next article comes out.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .