Developer way

Advanced typescript for React developers - part 3

Nadia Makarevich

Nadia Makarevich

Dec 20, 2021

Nadia MakarevichNadia Makarevich

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.

1const tabs = ["Books", "Movies", "Laptops"] as const;
2type Tabs = typeof tabs;
3type Tab = Tabs[number];
4
5const getSelect = (tab: Tab) => {
6 switch (tab) {
7 case "Books":
8 return (
9 <GenericSelect<Book> ... />
10 );
11 case "Movies":
12 return (
13 <GenericSelect<Movie> ... />
14 );
15 case "Laptops":
16 return (
17 <GenericSelect<Laptop> ... />
18 );
19 }
20};
21
22export const TabsComponent = () => {
23 const [tab, setTab] = useState<Tab>(tabs[0]);
24
25 const select = getSelect(tab);
26
27 return (
28 <>
29 Select category:
30 <GenericSelect<Tab>
31 onChange={(value) => setTab(value)}
32 values={tabs}
33 formatLabel={formatLabel}
34 />
35 {select}
36 </>
37 );
38};

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.

1const tabs = ["Books", "Movies", "Laptops", "Phones"] as const;
2
3const getSelect = (tab: Tab) => {
4 switch (tab) {
5 // ...
6 case "Phones":
7 return (
8 <GenericSelect<Phone> ... />
9 );
10 }
11};

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?

1const tabs = ["Books", "Movies", "Laptops", "Phones"] as const;
2
3const getSelect = (tab: Tab) => {
4 switch (tab) {
5 case "Books":
6 // ...
7 case "Movies":
8 // ...
9 case "Laptops":
10 // ...
11 }
12};

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:

1const tabs = ["Books", "Movies", "Laptops"] as const;
2
3// Just "Books" in the switch statement
4const getSelect = (tab: Tab) => {
5 switch (tab) {
6 case "Books":
7 // tab's type is Books here, it will not be available in the next cases
8 return <GenericSelect<Book> ... />
9 default:
10 // at this point tab can be only "Movies" or "Laptops"
11 // Books have been eliminated at the previous step
12 }
13};

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

1const tabs = ["Books", "Movies", "Laptops"] as const;
2
3const getSelect = (tab: Tab) => {
4 switch (tab) {
5 case "Books":
6 // "Books" have been eliminated here
7 case "Movies":
8 // "Movies" have been eliminated here
9 case "Laptops":
10 // "Laptops" have been eliminated here
11 default:
12 // all the values have been eliminated in the previous steps
13 // this state can never happen
14 // tab will be `never` type here
15 }
16};

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!

1// Added "Phones" here, but not in the switch
2const tabs = ["Books", "Movies", "Laptops", "Phones"] as const;
3
4// Telling typescript explicitly that we want tab to be "never" type
5// When this function is called, it should be "never" and only "never"
6const confirmImpossibleState = (tab: never) => {
7 throw new Error(`Reacing an impossible state because of ${tab}`);
8};
9
10const getSelect = (tab: Tab) => {
11 switch (tab) {
12 case "Books":
13 // "Books" have been eliminated
14 case "Movies":
15 // "Movies" have been eliminated
16 case "Laptops":
17 // "Laptops" have been eliminated
18 default:
19 // This should be "impossible" state,
20 // but we forgot to add "Phones" as one of the cases
21 // and "tab" can still be the type "Phones" at this stage.
22
23 // Fortunately, in this function we assuming tab is always "never" type
24 // But since we forgot to eliminate Phones, typescript now will fail!
25 confirmImpossibleState(tab);
26 }
27};

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?

1export type DataTypes = Book | Movie | Laptop | string;
2
3export const formatLabel = (value: DataTypes) => {
4 if (isBook(value)) return `${value.title}: ${value.author}`;
5 if (isMovie(value)) return `${value.title}: ${value.releaseDate}`;
6 if (isLaptop(value)) return value.model;
7
8 return value;
9};

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:

1export type DataTypes = Book | Movie | Laptop | Phone | string;
2
3 // When this function is called the value should be only string
4 const valueShouldBeString = (value: string) => value;
5
6 const formatLabel = (value: DataTypes) => {
7 // we're eliminating Book type from the union here
8 if (isBook(value)) return `${value.title}: ${value.author}`;
9
10 // here value can only be Movie, Laptop, Phone or string
11
12 // we're eliminating Movie type from the union here
13 if (isMovie(value)) return `${value.title}: ${value.releaseDate}`;
14
15 // here value can only be Laptop, Phone or string
16
17 // we're eliminating Laptop type from the union here
18 if (isLaptop(value)) return value.model;
19
20 // here value can only be Phone or string
21
22 // But we actually want it to be only string
23 // And make typescript fail if it is not
24 // So we just call this function, that explicitly assigns "string" to value
25
26 return valueShouldBeString(value);
27
28 // Now, if at this step not all possibilities are eliminated
29 // and value can be something else other than string (like Phone in our case)
30 // typescript will pick it up and fail!
31};

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:

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

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:

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

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

1enum Tabs {
2 'MOVIES' = 'Movies',
3 'BOOKS' = 'Books',
4 'LAPTOPS' = 'Laptops',
5}

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

1const movieTab = Tabs.MOVIES; // movieTab will be `Movies`
2const bookTab = Tabs.BOOKS; // bookTab will be `Books`

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:

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:

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 😉

Share on:Share "Advanced typescript for React developers - part 3" on TwitterShare "Advanced typescript for React developers - part 3" on LinkedInShare "Advanced typescript for React developers - part 3" on Reddit
Get the latest content by email
    No spam or ads. Unsubscribe at any time.
    © Developer Way 2021