Advanced typescript for React developers - part 3
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];45const 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};2122export const TabsComponent = () => {23 const [tab, setTab] = useState<Tab>(tabs[0]);2425 const select = getSelect(tab);2627 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;23const 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;23const 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;23// Just "Books" in the switch statement4const getSelect = (tab: Tab) => {5 switch (tab) {6 case "Books":7 // tab's type is Books here, it will not be available in the next cases8 return <GenericSelect<Book> ... />9 default:10 // at this point tab can be only "Movies" or "Laptops"11 // Books have been eliminated at the previous step12 }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;23const getSelect = (tab: Tab) => {4 switch (tab) {5 case "Books":6 // "Books" have been eliminated here7 case "Movies":8 // "Movies" have been eliminated here9 case "Laptops":10 // "Laptops" have been eliminated here11 default:12 // all the values have been eliminated in the previous steps13 // this state can never happen14 // tab will be `never` type here15 }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 switch2const tabs = ["Books", "Movies", "Laptops", "Phones"] as const;34// Telling typescript explicitly that we want tab to be "never" type5// 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};910const getSelect = (tab: Tab) => {11 switch (tab) {12 case "Books":13 // "Books" have been eliminated14 case "Movies":15 // "Movies" have been eliminated16 case "Laptops":17 // "Laptops" have been eliminated18 default:19 // This should be "impossible" state,20 // but we forgot to add "Phones" as one of the cases21 // and "tab" can still be the type "Phones" at this stage.2223 // Fortunately, in this function we assuming tab is always "never" type24 // 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;23export 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;78 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;23 // When this function is called the value should be only string4 const valueShouldBeString = (value: string) => value;56 const formatLabel = (value: DataTypes) => {7 // we're eliminating Book type from the union here8 if (isBook(value)) return `${value.title}: ${value.author}`;910 // here value can only be Movie, Laptop, Phone or string1112 // we're eliminating Movie type from the union here13 if (isMovie(value)) return `${value.title}: ${value.releaseDate}`;1415 // here value can only be Laptop, Phone or string1617 // we're eliminating Laptop type from the union here18 if (isLaptop(value)) return value.model;1920 // here value can only be Phone or string2122 // But we actually want it to be only string23 // And make typescript fail if it is not24 // So we just call this function, that explicitly assigns "string" to value2526 return valueShouldBeString(value);2728 // Now, if at this step not all possibilities are eliminated29 // 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?
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:

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 😉