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