Advanced typescript for React developers — discriminated unions

Discriminated unions — beginning

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);
};
export const isMovie = (value: DataTypes): value is Movie => {
return (
typeof value !== "string" &&
"id" in value &&
"releaseDate" in value &&
"title" in value
);
};
export type Book = {
id: string;
title: string;
author: string;
};

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

... // all the other data types
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
type DataTypes = Book | Movie | Laptop | Phone;
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 "";
};

Discriminated unions when fetching data

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>
);
};
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>
);
};
  • 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.
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.
const defaultValue: PendingState = { status: 'pending' };
const Context = React.createContext<State>(defaultValue);
const [state, setState] = useState<State>(defaultValue);
  • 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
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>
);
};

Discriminated unions in components props

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>
);
};
type GenericSelectProps<TValue> = {
isMulti: boolean;
onChange: (value: TValue | TValue[]) => void;
..// the rest are the same
};
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}
...
/>
);
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;
}
export const GenericSelect = <TValue extends Base>(
props: SingleSelectProps<TValue> | MultiSelectProps<TValue>
) => {
const { isMulti, onChange } = props;
if (props.isMulti) {
props.onChange([...selectedValues, val]);
if (val) props.onChange(val);
}
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}
...
/>
);

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
Nadia Makarevich

Nadia Makarevich

Frontend architect, coder. Love solving problems, fixing things and writing in-depth tech articles: https://www.developerway.com