Advanced typescript for React developers - discriminated unions
Tags: javascript, webdev, react, typescript
Published: 2021-12-30 17:32:26


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ryfut2mrsz4broie0etj.png)


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

---

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: <a href="https://www.developerway.com/posts/advanced-typescript-for-react-developers-part-3">exhaustiveness checking and narrowing of types.</a>

Let’s jump right in, shall we? And to make it easier, we again will start from the <a href="https://codesandbox.io/s/distracted-noether-lxere?file=/src/tabs.tsx">previous code examples</a> 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?

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

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;
  if (isPhone(value)) return `${value.model}: ${value.manufacture}`;

  return valueShouldBeString(value);
};
```

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:

```ts
export const isMovie = (value: DataTypes): value is Movie => {
  return (
    typeof value !== "string" &&
    "id" in value &&
    "releaseDate" in value &&
    "title" in value
  );
};
```

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`.

```ts
export type Book = {
  id: string;
  title: string;
  author: string;
};

export type Movie = {
  id: string;
  title: string;
  releaseDate: string;
};

... // 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:

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/1abd77hqpmhzq6p4bp7c.png)

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.

```ts
export const books: Book[] = [
  {
    __typename: "book", // add this to our json data here!
    id: "1",
    title: "Good omens",
    author: "Terry Pratchett & Neil Gaiman"
  },
  ///...
];
// 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.

```ts
type 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:

```ts
export type DataTypes = Book | Movie | Laptop | Phone;

const formatLabel = (value: DataTypes | string) => {
  if (typeof value === "string") return value;
  if (value.__typename === "book") return `${value.title}: ${value.author}`;
  if (value.__typename === "movie") return `${value.title}: ${value.releaseDate}`;
  if (value.__typename === "laptop") return value.model;
  if (value.__typename === "phone") return `${value.model}: ${value.manufacture}`;

  return "";
};
```

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.

<a href="https://codesandbox.io/s/discriminated-unions-example-1-nlcyr?file=/src/tabs.tsx">See the full example here</a>

## 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 <a href="https://reactjs.org/docs/faq-ajax.html">React documentation</a>, with a few modifications.

```ts
type State = {
  loading?: boolean;
  error?: any;
  data?: Book[];
};

const Context = React.createContext<State | undefined>(undefined);

export const BooksProvider = ({ children }: { children: ReactNode }) => {
  const [loading, setLoading] = useState<boolean>(false);
  const [error, setError] = useState<any>(undefined);
  const [data, setData] = useState<Book[]>();

  useEffect(() => {
    setLoading(true);

    // just some random rest endpoint
    fetch('https://raw.githubusercontent.com/mledoze/countries/master/countries.json')
      .then((response) => {
        if (response.status === 200) {
          // in real life of course it would be the json data from the response
          // hardcoding books just to simplify the example since books are already typed
          setData(books);
          setLoading(false);
        } else {
          setLoading(false);
          setError(response.statusText);
        }
      })
      .catch((e) => {
        setLoading(false);
        setError(e);
      });
  }, []);

  return (
    <Context.Provider
      value={{
        error,
        data,
        loading,
      }}
    >
      {children}
    </Context.Provider>
  );
};
```

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:

```ts
const SomeComponent = () => {
  const data = useBooks();

  if (!data?.data) return <>No data fetched</>;
  if (data.loading) return <>Spinner</>;
  if (data.error !== undefined) return <>Something bad happened!</>;

  return <GenericSelect<Book> values={data.data} ... />
}

export default () => {
  return (
    <BooksProvider>
      <SomeComponent />
    </BooksProvider>
  );
};
```

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:

```ts
type PendingState = {
  status: 'pending';
};

type LoadingState = {
  status: 'loading';
};

type SuccessState = {
  status: 'success';
  data: Book[];
};

type ErrorState = {
  status: 'error';
  error: any;
};

type 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

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

use only one `setState` instead of three independent ones

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

and refactor `useEffect` function to the new system

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/gdfon0fqolux75uab1ir.png)

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:

```ts
const SomeComponent = () => {
  const data = useBooks();

  if (data.status === 'pending') {
    // if I try to access data.error or data.data typescript will fail
    // since pending state only has "status" property
    return <>Waiting for the data to fetch</>;
  }

  if (data.status === 'loading') {
    // if I try to access data.error or data.data typescript will fail
    // since loading state only has "status" property
    return <>Spinner</>;
  }

  if (data.status === 'error') {
    // data.error will be available here since error state has it as mandatory property
    return <>Something bad happened!</>;
  }

  // we eliminated all other statuses other than "success" at this point
  // so here data will always be type of "success" and we'll be able to access data.data freely
  return <GenericSelect<Book> values={data.data} ... />
}

export default () => {
  return (
    <BooksProvider>
      <SomeComponent />
    </BooksProvider>
  );
};
```

<a href="https://codesandbox.io/s/discriminated-unions-example-2-4bfqj?file=/src/books.tsx">See the full example here</a>

## 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.

```ts
type GenericSelectProps<TValue> = {
  formatLabel: (value: TValue) => string;
  onChange: (value: TValue) => void;
  values: Readonly<TValue[]>;
};

export const GenericSelect = <TValue extends Base>(
  props: GenericSelectProps<TValue>
) => {
  const { values, onChange, formatLabel } = props;

  const onSelectChange = (e) => {
    const val = values.find(
      (value) => getStringFromValue(value) === e.target.value
    );

    if (val) onChange(val);
  };

  return (
    <select onChange={onSelectChange}>
      {values.map((value) => (
        <option
          key={getStringFromValue(value)}
          value={getStringFromValue(value)}
        >
          {formatLabel(value)}
        </option>
      ))}
    </select>
  );
};
```

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:

```ts
type GenericSelectProps<TValue> = {
  isMulti: boolean;
  onChange: (value: TValue | TValue[]) => void;
  ..// the rest are the same
};
```

And the full working code is <a href="https://codesandbox.io/s/discriminated-unions-example-3-2wrkd?file=/src/select.tsx">available in this codesandbox.</a>

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.

```ts
const select = (
  <GenericSelect<Book>
    // I can't log "value.title" here, typescript will fail
    // property "title" doesn't exist on type "Book[]""
    // even if I know for sure that this is a single select
    // and the type will always be just "Book"
    onChange={(value) => console.info(value.title)}
    isMulti={false}
    ...
  />
);

const multiSelect = (
  <GenericSelect<Book>
    // I can't iterate on the value here, typescript will fail
    // property "map" doesn't exist on type "Book"
    // even if I know for sure that this is a multi select
    // and the type will always be "Book[]"
    onChange={(value) => value.map(v => console.info(v))}
    isMulti={true}
    ...
  />
);
```

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

```ts
type GenericSelectProps<TValue> = {
  formatLabel: (value: TValue) => string;
  values: Readonly<TValue[]>;
};

interface SingleSelectProps<TValue> extends GenericSelectProps<TValue> {
  isMulti: false; // false, not boolean. For single select component this is always false
  onChange: (value: TValue) => void;
}

interface MultiSelectProps<TValue> extends GenericSelectProps<TValue> {
  isMulti: true; // true, not boolean. For multi select component this is always true
  onChange: (value: TValue[]) => void;
}
```

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

```ts
export const GenericSelect = <TValue extends Base>(
  props: SingleSelectProps<TValue> | MultiSelectProps<TValue>
) => {
```

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:

```ts
const { isMulti, onChange } = props;
```

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

```ts
if (props.isMulti) {
    props.onChange([...selectedValues, val]);
  if (val) props.onChange(val);
}
```

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

```ts
const select = (
  <GenericSelect<Book>
    // now it will work perfectly!
    onChange={(value) => console.info(value.title)}
    isMulti={false}
    ...
  />
);

const multiSelect = (
  <GenericSelect<Book>
    // now it will work perfectly!
    onChange={(value) => value.map(v => console.info(v))}
    isMulti={true}
    ...
  />
);
```

<a href="https://codesandbox.io/s/discriminated-unions-example-4-hkk1e?file=/src/select.tsx">See the fully working example here</a>

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 🎉

...

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

[Subscribe to the newsletter](https://www.developerway.com), [connect on LinkedIn](https://www.linkedin.com/in/adevnadia/) or [follow on Twitter](https://twitter.com/adevnadia) to get notified as soon as the next article comes out.