Developer way

Advanced typescript for React developers - discriminated unions

Nadia Makarevich

Nadia Makarevich

Dec 30, 2021

Nadia MakarevichNadia Makarevich

Hello, my fellow React developers who are in the process of learning advanced typescript patterns! Did you know that typescript has something that is called “discriminated unions”? That name triggers all sorts of saviour reflexes against discrimination in me, but it actually is a pretty cool and useful feature that doesn’t need to be saved. Even better, it is super useful for something that we already perfected in the previous advanced typescript article: exhaustiveness checking and narrowing of types.

Let’s jump right in, shall we? And to make it easier, we again will start from the previous code examples and improve them along the way. This time we’re going to build multi-select capabilities into our generic select component and implement a data provider to fetch the products from a REST endpoint.

But first, let’s improve some completely unrelated code, just to get a sense of what discriminated union actually is.

Discriminated unions - beginning

Remember our function that was generating text labels for different data types?

1export type DataTypes = Book | Movie | Laptop | Phone | string;
2
3const 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 if (isPhone(value)) return `${value.model}: ${value.manufacture}`;
8
9 return valueShouldBeString(value);
10};

It looks pretty enough, although in functions isBook or isMovie we have to do quite a lot of calculation to determine which type is where. isMovie, for example, looks like this:

1export const isMovie = (value: DataTypes): value is Movie => {
2 return (
3 typeof value !== "string" &&
4 "id" in value &&
5 "releaseDate" in value &&
6 "title" in value
7 );
8};

We had to do it because for our example we wrote types in a way that there is no reliable way to easily identify which is which: all the properties are strings, all of them have id, two of them have releaseDate.

1export type Book = {
2 id: string;
3 title: string;
4 author: string;
5};
6
7export type Movie = {
8 id: string;
9 title: string;
10 releaseDate: string;
11};
12
13... // all the other data types

That makes those functions quite prone to error and hard to read and extend. It doesn’t have to be that way though, this is one of the rarest things in life where we have absolute control. What we can do to improve the situation drastically is to introduce a new unique common property for every data type. Something like this:

This would be what is called a discriminant property. Those who are privileged enough to get their data from a graphql endpoint will likely have __typename already in their data. The rest would have to have some sort of normalization function that adds the correct value manually when the data is received from the external source.

1export const books: Book[] = [
2 {
3 __typename: "book", // add this to our json data here!
4 id: "1",
5 title: "Good omens",
6 author: "Terry Pratchett & Neil Gaiman"
7 },
8 ///...
9];
10// all the rest of the data with

And now, if we move string type away from DataTypes, it will turn into what is called “discriminated union” - a union of types, all of which have a common property with some unique value.

1type DataTypes = Book | Movie | Laptop | Phone;

The best part is that typescript can do narrowing of types easily when it deals with discriminated unions. And our isSomething-based implementation can be simplified into this:

1export type DataTypes = Book | Movie | Laptop | Phone;
2
3const formatLabel = (value: DataTypes | string) => {
4 if (typeof value === "string") return value;
5 if (value.__typename === "book") return `${value.title}: ${value.author}`;
6 if (value.__typename === "movie") return `${value.title}: ${value.releaseDate}`;
7 if (value.__typename === "laptop") return value.model;
8 if (value.__typename === "phone") return `${value.model}: ${value.manufacture}`;
9
10 return "";
11};

All the isSomething functions are gone, which not only simplifies the implementation but also makes it slightly more performant since we reduced the number of checks we’re doing in every formatLabel function call.

See the full example here

Discriminated unions when fetching data

One of the most useful applications of those types of unions is various mutually exclusive states and conditions. And the most typical one is the “loading/error/data” pattern that can be seen in its various forms everywhere where data from an external source needs to be fetched.

First, let's start with implementing a “data provider” for our books: a simple fetch that gets data from a REST endpoint, handlers “loading” and “error” states, and puts the data into React context for all other components to use. We can pretty much just copy the example from React documentation, with a few modifications.

1type State = {
2 loading?: boolean;
3 error?: any;
4 data?: Book[];
5};
6
7const Context = React.createContext<State | undefined>(undefined);
8
9export const BooksProvider = ({ children }: { children: ReactNode }) => {
10 const [loading, setLoading] = useState<boolean>(false);
11 const [error, setError] = useState<any>(undefined);
12 const [data, setData] = useState<Book[]>();
13
14 useEffect(() => {
15 setLoading(true);
16
17 // just some random rest endpoint
18 fetch('https://raw.githubusercontent.com/mledoze/countries/master/countries.json')
19 .then((response) => {
20 if (response.status === 200) {
21 // in real life of course it would be the json data from the response
22 // hardcoding books just to simplify the example since books are already typed
23 setData(books);
24 setLoading(false);
25 } else {
26 setLoading(false);
27 setError(response.statusText);
28 }
29 })
30 .catch((e) => {
31 setLoading(false);
32 setError(e);
33 });
34 }, []);
35
36 return (
37 <Context.Provider
38 value={{
39 error,
40 data,
41 loading,
42 }}
43 >
44 {children}
45 </Context.Provider>
46 );
47};

And now, after adding the provider somewhere at the top of the app, we can use the fetched data everywhere in the app without triggering additional re-fetching, and do something like this:

1const SomeComponent = () => {
2 const data = useBooks();
3
4 if (!data?.data) return <>No data fetched</>;
5 if (data.loading) return <>Spinner</>;
6 if (data.error !== undefined) return <>Something bad happened!</>;
7
8 return <GenericSelect<Book> values={data.data} ... />
9}
10
11export default () => {
12 return (
13 <BooksProvider>
14 <SomeComponent />
15 </BooksProvider>
16 );
17};

Although technically this example would work, it’s far from optimal, especially from the types perspective. Everything is optional and available to everything else even if it doesn’t make sense: you can access error or data property when loading is set to true for example, and the type system will not prevent it. On top of that, the state is split into three independent useState, which makes it very easy to make a mistake and forget one of the states or set it to a wrong value in the flow of the function. Imagine if I forget to do setLoading(false) or mistakenly do setLoading(true) when I receive the data: the overall state of the provider will be loading and data received at the same time , the type system will not stop it, and the customer-facing UI will be a total mess.

Luckily, both of those problems can be easily solved if we apply the knowledge of how discriminated unions and type narrowing works. First of all, we have four distinct mutually exclusive states in which our data provider can be:

  • initial state, when nothing has happened yet. Neither data or error or loading exist here
  • loading state, where the provider started the data fetching, but haven’t received anything yet. Neither data or error exist here
  • success state, when data is successfully received. Error doesn’t exist here
  • error state, when the fetch resulted in error. Data doesn’t exist here.

If we describe this in a form of types, it will be this:

1type PendingState = {
2 status: 'pending';
3};
4
5type LoadingState = {
6 status: 'loading';
7};
8
9type SuccessState = {
10 status: 'success';
11 data: Book[];
12};
13
14type ErrorState = {
15 status: 'error';
16 error: any;
17};
18
19type State = PendingState | LoadingState | SuccessState | ErrorState;

type State is our classic discriminated union, with status being the discriminant property: it exists in every type and always has a unique value.

And now we can initialize our context provider with the default state value

1const defaultValue: PendingState = { status: 'pending' };
2const Context = React.createContext<State>(defaultValue);

use only one setState instead of three independent ones

1const [state, setState] = useState<State>(defaultValue);

and refactor useEffect function to the new system

Now possibilities of mistakes are minimised:

  • when I do setState({ status: 'loading' });, typescript will not allow to set neither data nor error there
  • if I try to do just setState({ status: 'success' });, typescript will fail, since it expects to find Books in the mandatory data field for the success state
  • same story with setState({ status: 'error' }); - typescript will fail here since it expects the mandatory error field in the error state

And it gets even better, since on the consumer side typescript will also be able to distinguish between those states and prevent unintentional use of properties in the wrong places:

1const SomeComponent = () => {
2 const data = useBooks();
3
4 if (data.status === 'pending') {
5 // if I try to access data.error or data.data typescript will fail
6 // since pending state only has "status" property
7 return <>Waiting for the data to fetch</>;
8 }
9
10 if (data.status === 'loading') {
11 // if I try to access data.error or data.data typescript will fail
12 // since loading state only has "status" property
13 return <>Spinner</>;
14 }
15
16 if (data.status === 'error') {
17 // data.error will be available here since error state has it as mandatory property
18 return <>Something bad happened!</>;
19 }
20
21 // we eliminated all other statuses other than "success" at this point
22 // so here data will always be type of "success" and we'll be able to access data.data freely
23 return <GenericSelect<Book> values={data.data} ... />
24}
25
26export default () => {
27 return (
28 <BooksProvider>
29 <SomeComponent />
30 </BooksProvider>
31 );
32};
See the full example here

Discriminated unions in components props

And last but not least, example of the usefulness of discriminated unions is components props. Those are especially useful when your component has some boolean props that control some of its behaviour or appearance, although the pattern would work with any literal type. Imagine, for example, that we want to extend our GenericSelect component to support also multi-select functionality.

1type GenericSelectProps<TValue> = {
2 formatLabel: (value: TValue) => string;
3 onChange: (value: TValue) => void;
4 values: Readonly<TValue[]>;
5};
6
7export const GenericSelect = <TValue extends Base>(
8 props: GenericSelectProps<TValue>
9) => {
10 const { values, onChange, formatLabel } = props;
11
12 const onSelectChange = (e) => {
13 const val = values.find(
14 (value) => getStringFromValue(value) === e.target.value
15 );
16
17 if (val) onChange(val);
18 };
19
20 return (
21 <select onChange={onSelectChange}>
22 {values.map((value) => (
23 <option
24 key={getStringFromValue(value)}
25 value={getStringFromValue(value)}
26 >
27 {formatLabel(value)}
28 </option>
29 ))}
30 </select>
31 );
32};

Typically what people do in this situation is they introduce isMulti: boolean property and then adjust implementation accordingly. In our case, we’d need to: add isMulti to the component props, adjust onChange callback types to accept multiple values, pass multiple prop to the select itself, introduce internal state to hold selected values for the multi-select variation, adjust the onSelectChange handler to support multi-select variation, filter out selected values from the rendered options and render them on top of the select instead with onDelete handler attached.

After all those manipulations, our GenericSelect props would looks like this:

1type GenericSelectProps<TValue> = {
2 isMulti: boolean;
3 onChange: (value: TValue | TValue[]) => void;
4 ..// the rest are the same
5};

And the full working code is available in this codesandbox.

And again the same story: although from the first glance this looks like a good solution, there is one big flaw in it: when consumers of the select would want to use onChange callback, typescript would not know what exactly is in the value. There is no connection from its perspective between isMulti prop and onChange value, and value’s type will always be TValue | TValue[] regardless of isMulti property.

1const select = (
2 <GenericSelect<Book>
3 // I can't log "value.title" here, typescript will fail
4 // property "title" doesn't exist on type "Book[]""
5 // even if I know for sure that this is a single select
6 // and the type will always be just "Book"
7 onChange={(value) => console.info(value.title)}
8 isMulti={false}
9 ...
10 />
11);
12
13const multiSelect = (
14 <GenericSelect<Book>
15 // I can't iterate on the value here, typescript will fail
16 // property "map" doesn't exist on type "Book"
17 // even if I know for sure that this is a multi select
18 // and the type will always be "Book[]"
19 onChange={(value) => value.map(v => console.info(v))}
20 isMulti={true}
21 ...
22 />
23);

Luckily, this is easily fixable by turning GenericSelectProps into discriminated union with isMulti as the discriminant:

1type GenericSelectProps<TValue> = {
2 formatLabel: (value: TValue) => string;
3 values: Readonly<TValue[]>;
4};
5
6interface SingleSelectProps<TValue> extends GenericSelectProps<TValue> {
7 isMulti: false; // false, not boolean. For single select component this is always false
8 onChange: (value: TValue) => void;
9}
10
11interface MultiSelectProps<TValue> extends GenericSelectProps<TValue> {
12 isMulti: true; // true, not boolean. For multi select component this is always true
13 onChange: (value: TValue[]) => void;
14}

and passing those properties to the select component as a union:

1export const GenericSelect = <TValue extends Base>(
2 props: SingleSelectProps<TValue> | MultiSelectProps<TValue>
3) => {

In the perfect world that would be enough for everything to work. Unfortunately, in our reality there is another small adjustment needed: when we spread props, typescript loses that types link for some reason. In order for the code to actually work we basically need to get rid of this:

1const { isMulti, onChange } = props;

and always use props.isMulti and props.onChange in the code instead. I.e. it should be something like this:

1if (props.isMulti) {
2 props.onChange([...selectedValues, val]);
3 if (val) props.onChange(val);
4}

And with those modifications generic select will be perfectly usable in both of its variations and types will be perfect

1const select = (
2 <GenericSelect<Book>
3 // now it will work perfectly!
4 onChange={(value) => console.info(value.title)}
5 isMulti={false}
6 ...
7 />
8);
9
10const multiSelect = (
11 <GenericSelect<Book>
12 // now it will work perfectly!
13 onChange={(value) => value.map(v => console.info(v))}
14 isMulti={true}
15 ...
16 />
17);
See the fully working example here

That is all for today, hope you’re now able to discriminate unions like a pro and have the big urge to refactor all your code asap. I know I do 😊 😅.

Happy New year and see y’all in 2022 🎉

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