19
Advanced typescript for React developers - discriminated unions
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.
Remember our function that was generating text labels for different data types?
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:
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
.
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:
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.
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.
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:
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.
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.
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:
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
orerror
orloading
exist here - loading state, where the provider started the data fetching, but haven’t received anything yet. Neither
data
orerror
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:
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
const defaultValue: PendingState = { status: 'pending' };
const Context = React.createContext<State>(defaultValue);
use only one setState
instead of three independent ones
const [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 neitherdata
norerror
there - if I try to do just
setState({ status: 'success' });
, typescript will fail, since it expects to find Books in the mandatorydata
field for the success state - same story with
setState({ status: 'error' });
- typescript will fail here since it expects the mandatoryerror
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:
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>
);
};
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.
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:
type GenericSelectProps<TValue> = {
isMulti: boolean;
onChange: (value: TValue | TValue[]) => void;
..// the rest are the same
};
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.
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:
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:
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:
const { isMulti, onChange } = props;
and always use props.isMulti
and props.onChange
in the code instead. I.e. it should be something like this:
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
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}
...
/>
);
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 🎉
19