Nadia Makarevich
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.
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}</>);};
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> ... />);}};
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":// ...}};
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 statementconst getSelect = (tab: Tab) => {switch (tab) {case "Books":// tab's type is Books here, it will not be available in the next casesreturn <GenericSelect<Book> ... />default:// at this point tab can be only "Movies" or "Laptops"// Books have been eliminated at the previous step}};
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 herecase "Movies":// "Movies" have been eliminated herecase "Laptops":// "Laptops" have been eliminated heredefault:// all the values have been eliminated in the previous steps// this state can never happen// tab will be `never` type here}};
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 switchconst 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 eliminatedcase "Movies":// "Movies" have been eliminatedcase "Laptops":// "Laptops" have been eliminateddefault:// 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);}};
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;};
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 stringconst valueShouldBeString = (value: string) => value;const formatLabel = (value: DataTypes) => {// we're eliminating Book type from the union hereif (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 hereif (isMovie(value)) return `${value.title}: ${value.releaseDate}`;// here value can only be Laptop, Phone or string// we're eliminating Laptop type from the union hereif (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 valuereturn 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!};
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];
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];
Could be replaced with this, which is arguably much easier and more intuitive to read:
enum Tabs {'MOVIES' = 'Movies','BOOKS' = 'Books','LAPTOPS' = 'Laptops',}
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`
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 😉