Advanced typescript for React developers
Nadia Makarevich
Posted on December 8, 2021
Originally published at https://www.developerway.com. The website has more articles like this đ
This is the second article in the series âtypescript for React developersâ. In the first one, we figured out what Typescript generics are and how to use them to write re-usable react components: Typescript Generics for React developers. Now itâs time to dive into other advanced typescript concepts and understand how and why we need things like type guards, keyof, typeof, is, as const and indexed types.
Introduction
As we found out from the article above, Judi is an ambitious developer and wants to implement her own online shop, a competitor to Amazon: sheâs going to sell everything there! We left her when she implemented a re-usable select component with typescript generics. The component is pretty basic: it allows to pass an array of values
, assumes that those values have id
and title
for rendering select options, and have an onChange
handler to listen to the selected values.
type Base = {
id: string;
title: string;
};
type GenericSelectProps<TValue> = {
values: TValue[];
onChange: (value: TValue) => void;
};
export const GenericSelect = <TValue extends Base>({ values, onChange }: GenericSelectProps<TValue>) => {
const onSelectChange = (e) => {
const val = values.find((value) => value.id === e.target.value);
if (val) onChange(val);
};
return (
<select onChange={onSelectChange}>
{values.map((value) => (
<option key={value.id} value={value.id}>
{value.title}
</option>
))}
</select>
);
};
and then this component can be used with any data types Judi has in her application
<GenericSelect<Book> onChange={(value) => console.log(value.author)} values={books} />
<GenericSelect<Movie> onChange={(value) => console.log(value.releaseDate)} values={movies} />
Although, as the shop grew, she quickly found out that any data type is an exaggeration: we are still limited since we assume that our data will always have id
and title
there. But now Judi wants to sell laptops, and laptops have model
instead of title
in their data.
type Laptop = {
id: string;
model: string;
releaseDate: string;
}
// This will fail, since there is no "title" in the Laptop type
<GenericSelect<Laptop> onChange={(value) => console.log(value.model)} values={laptops} />
Ideally, Judi wants to avoid data normalization just for select purposes and make the select component more generic instead. What can she do?
Rendering not only titles in options
Judi decides, that just passing the desired attribute as a prop to the select component would be enough to fulfil her needs for the time being. Basically, sheâd have something like this in its API:
<GenericSelect<Laptop> titleKey="model" {...} />
and the select component would then render Laptop models instead of titles in the options.
It would work, but there is one problem with this: not type-safe đ. Ideally, we would want typescript to fail if this attribute doesnât exist in the data model that is used in the select component. This is where typescriptâs keyof operator comes in handy.
keyof
basically generates a type from an objectâs keys. If I use keyof
on Laptop
type:
type Laptop = {
id: string;
model: string;
releaseDate: string;
}
type LaptopKeys = keyof Laptop;
in LaptopKeys
Iâll find a union of its keys: "id" | "model" | "releaseDate"
.
And, most amazingly, typescript is smart enough to generate those types for generics as well! This will work perfectly:
And now I can use it with all selects and typescript will catch any typos or copy-paste errors:
<GenericSelect<Laptop> titleKey="model" {...} />
// inside GenericSelect "titleKey" will be typed to "id" | "model" | "releaseDate"
<GenericSelect<Book> titleKey="author" {...} />
// inside GenericSelect "titleKey" will be typed to "id" | "title" | "author"
and we can make the type Base
a little bit more inclusive and make the title
optional
type Base = {
id: string;
title?: string;
}
export const GenericSelect = <TValue extends Base>(props: GenericSelectProps<TValue>) => {
See full working example in codesandbox.
Important: Although this example works perfectly, I would not recommend using it in actual apps. It lacks a bit of elegance and is not generic enough yet. Read until the end of the article for a better example of a select component with customizable labels.
The list of categories - refactor select
Now, that we have lists of goods covered with our generic select, itâs time to solve other problems on Judiâs website. One of them is that she has her catalog page clattered with all the selects and additional information that she shows when a value is selected. What she needs, she decides, is to split it into categories, and only show one category at a time. She again wants to use the generic select for it (well, whoâs not lazy in this industry, right?).
The categories is just a simple array of strings: const categories = ['Books', 'Movies', 'Laptops'].
Now, our current generic select unfortunately doesnât work with string values. Letâs fix it! And interestingly enough, this seems-to-be-simple implementation will allow us to get familiar with five new advanced typescript technics: operators as const, typeof, is, type guards idea and indexed types. But letâs start with the existing code and take a closer look at where exactly we depend on the TValue
type to be an object.
After careful examination of this picture, we can extract three major changes that we need to do:
- Convert
Base
type into something that understands strings as well as objects - Get rid of reliance on
value.id
as the unique identificator of the value in the list of options - Convert
value[titleKey]
into something that understands strings as well
With this step-by-step approach to refactoring, the next moves are more or less obvious.
Step 1. Convert Base
into a union type (i.e. just a fancy âorâ operator for types) and get rid of title
there completely:
type Base = { id: string } | string;
// Now "TValue" can be either a string, or an object that has an "id" in it
export const GenericSelect = <TValue extends Base>(props: GenericSelectProps<TValue>) => {
Step 2. Get rid of direct access of value.id
. We can do that by converting all those calls to a function getStringFromValue
:
where the very basic implementation from the before-typescript era would look like this:
const getStringFromValue = (value) => value.id || value;
This is not going to fly with typescript though: remember, our value
is Generic and can be a string as well as an object, so we need to help typescript here to understand what exactly it is before accessing anything specific.
type Base = { id: string } | string;
const getStringFromValue = <TValue extends Base>(value: TValue) => {
if (typeof value === 'string') {
// here "value" will be the type of "string"
return value;
}
// here "value" will be the type of "NOT string", in our case { id: string }
return value.id;
};
The code in the function is known as type guard in typescript: an expression that narrows down type within some scope. See what is happening? First, we check whether the value
is a string by using the standard javascript typeof
operator. Now, within the âtruthyâ branch of if
expression, typescript will know for sure that value is a string, and we can do anything that weâd usually do with a string there. Outside of it, typescript will know for sure, that the value is not a string, and in our case, it means itâs an object with an id
in it. Which allows us to return value.id
safely.
Step 3. Refactor the value[titleKey]
access. Considering that a lot of our data types would want to customise their labels, and more likely than not in the future weâd want to convert it to be even more custom, with icons or special formatting, the easiest option here is just to move the responsibility of extracting required information to the consumer. This can be done by passing a function to select that converts value on the consumer side to a string (or ReactNode in the future). No typescript mysteries here, just normal React:
type GenericSelectProps<TValue> = {
formatLabel: (value: TValue) => string;
...
};
export const GenericSelect = <TValue extends Base>(props: GenericSelectProps<TValue>) => {
...
return (
<select onChange={onSelectChange}>
{values.map((value) => (
<option key={getStringFromValue(value)} value={getStringFromValue(value)}>
{formatLabel(value)}
</option>
))}
</select>
);
}
// Show movie title and release date in select label
<GenericSelect<Movie> ... formatLabel={(value) => `${value.title} (${value.releaseDate})`} />
// Show laptop model and release date in select label
<GenericSelect<Laptop> ... formatLabel={(value) => `${value.model, released in ${value.releaseDate}`} />
And now we have it! A perfect generic select, that supports all data formats that we need and allows us to fully customise labels as a nice bonus. The full code looks like this:
type Base = { id: string } | string;
type GenericSelectProps<TValue> = {
formatLabel: (value: TValue) => string;
onChange: (value: TValue) => void;
values: TValue[];
};
const getStringFromValue = <TValue extends Base>(value: TValue) => {
if (typeof value === 'string') return value;
return value.id;
};
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>
);
};
The list of categories - implementation
And now, finally, time to implement what we refactored the select component for in the first place: categories for the website. As always, letâs start simple, and improve things in the process.
const tabs = ['Books', 'Movies', 'Laptops'];
const getSelect = (tab: string) => {
switch (tab) {
case 'Books':
return <GenericSelect<Book> onChange={(value) => console.info(value)} values={books} />;
case 'Movies':
return <GenericSelect<Movie> onChange={(value) => console.info(value)} values={movies} />;
case 'Laptops':
return <GenericSelect<Laptop> onChange={(value) => console.info(value)} values={laptops} />;
}
}
const Tabs = () => {
const [tab, setTab] = useState<string>(tabs[0]);
const select = getSelect(tab);
return (
<>
<GenericSelect<string> onChange={(value) => setTab(value)} values={tabs} />
{select}
</>
);
};
Dead simple - one select component for choosing a category, based on the chosen value - render another select component.
But again, not exactly typesafe, this time for the tabs: we typed them as just simple string
. So a simple typo in the switch
statement will go unnoticed or a wrong value in setTab
will result in a non-existent category to be chosen. Not good.
And again, typescript has a handy mechanism to improve that:
const tabs = ['Books', 'Movies', 'Laptops'] as const;
This trick is known as const assertion. With this, our tabs
array, instead of an array of any random string will turn into a read-only array of those specific values and nothing else.
// an array of values type "string"
const tabs = ['Books', 'Movies', 'Laptops'];
tabs.forEach(tab => {
// typescript is fine with that, although there is no "Cats" value in the tabs
if (tab === 'Cats') console.log(tab)
})
// an array of values 'Books', 'Movies' or 'Laptops', and nothing else
const tabs = ['Books', 'Movies', 'Laptops'] as const;
tabs.forEach(tab => {
// typescript will fail here since there are no Cats in tabs
if (tab === 'Cats') console.log(tab)
})
Now, all we need to do is to extract type Tab
that we can pass to our generic select. First, we can extract the Tabs
type by using the typeof operator, which is pretty much the same as normal javascript typeof
, only it operates on types, not values. This is where the value of as const
will be more visible:
const tabs = ['Books', 'Movies', 'Laptops'];
type Tabs = typeof tabs; // Tabs will be string[];
const tabs = ['Books', 'Movies', 'Laptops'] as const;
type Tabs = typeof tabs; // Tabs will be ['Books' | 'Movies' | 'Laptops'];
Second, we need to extract Tab
type from the Tabs array. This trick is called âindexed accessâ, itâs a way to access types of properties or individual elements (if array) of another type.
type Tab = Tabs[number]; // Tab will be 'Books' | 'Movies' | 'Laptops'
Same trick will work with object types, for example we can extract Laptopâs id into its own type:
type LaptopId = Laptop['id']; // LaptopId will be string
Now, that we have a type for individual Tabs, we can use it to type our categories logic:
And now all the typos or wrong values will be caught by typescript! đ„
See full working example in the codesandbox
Bonus: type guards and âisâ operator
There is another very interesting thing you can do with type guards. Remember our getStringFromValue
function?
type Base = { id: string } | string;
const getStringFromValue = <TValue extends Base>(value: TValue) => {
if (typeof value === 'string') {
// here "value" will be the type of "string"
return value;
}
// here "value" will be the type of "NOT string", in our case { id: string }
return value.id;
};
While if (typeof value === âstring')
check is okay for this simple example, in a real-world application you'd probably want to abstract it away into isStringValue
, and refactor the code to be something like this:
type Base = { id: string } | string;
const isStringValue = <TValue>(value: TValue) => return typeof value === 'string';
const getStringFromValue = <TValue extends Base>(value: TValue) => {
if (isStringValue(value)) {
// do something with the string
}
// do something with the object
};
And again the same story as before, there is one problem with the most obvious solution: itâs not going to work. As soon as type guard condition is extracted into a function like that, it loses its type guarding capabilities. From typescript perspective, itâs now just a random function that returns a regular boolean value, it doesnât know whatâs inside. Weâll have this situation now:
const getStringFromValue = <TValue extends Base>(value: TValue) => {
if (isStringValue(value)) { // it's just a random function that returns boolean
// type here will be unrestricted, either string or object
}
// type here will be unrestricted, either string or object
// can't return "value.id" anymore, typescript will fail
};
And again, there is a way to fix it by using yet another typescript concept known as âtype predicatesâ. Basically, itâs a way to manually do for the function what typescript was able to do by itself before refactoring. Looks like this:
type T = { id: string };
// can't extend Base here, typescript doesn't handle generics here well
export const isStringValue = <TValue extends T>(value: TValue | string): value is string => {
return typeof value === 'string';
};
See the value is string
there? This is the predicate. The pattern is argName is Type
, it can be attached only to a function with a single argument that returns a boolean value. This expression can be roughly translated into "when this function returns true, assume the value within your execution scope as string
type". So with the predicate, the refactoring will be complete and fully functioning:
type T = { id: string };
type Base = T | string;
export const isStringValue = <TValue extends T>(value: TValue | string): value is string => {
return typeof value === 'string';
};
const getStringFromValue = <TValue extends Base>(value: TValue) => {
if (isStringValue(value)) {
// do something with the string
}
// do something with the object
};
A pattern like this is especially useful when you have a possibility of different types of data in the same function and you need to do distinguish between them during runtime. In our case, we could define isSomething
function for every one of our data types:
export type DataTypes = Book | Movie | Laptop | string;
export const isBook = (value: DataTypes): value is Book => {
return typeof value !== 'string' && 'id' in value && 'author' in value;
};
export const isMovie = (value: DataTypes): value is Movie => {
return typeof value !== 'string' && 'id' in value && 'releaseDate' in value && 'title' in value;
};
export const isLaptop = (value: DataTypes): value is Laptop => {
return typeof value !== 'string' && 'id' in value && 'model' in value;
};
And then implement a function that returns option labels for our selects:
const formatLabel = (value: DataTypes) => {
// value will be always Book here since isBook has predicate attached
if (isBook(value)) return value.author;
// value will be always Movie here since isMovie has predicate attached
if (isMovie(value)) return value.releaseDate;
// value will be always Laptop here since isLaptop has predicate attached
if (isLaptop(value)) return value.model;
return value;
};
// somewhere in the render
<GenericSelect<Book> ... formatLabel={formatLabel} />
<GenericSelect<Movie> ... formatLabel={formatLabel} />
<GenericSelect<Laptop> ... formatLabel={formatLabel} />
see fully working example in the codesandbox
Time for goodbye
Itâs amazing, how many advanced typescript concepts we had to use to implement something as simple as a few selects! But itâs for the better typing world, so I think itâs worth it. Letâs recap:
- âkeyofâ - use it to generate types from keys of another type
- âas constâ - use it to signal to typescript to treat an array or an object as a constant. Use it with combination with âtype ofâ to generate actual type from it.
-
âtypeofâ - same as normal javascript
âtypeofâ
, but operates on types rather than values -
Type['attr']
orType[number]
- those are indexed types, use them to access subtypes in an Object or an Array respectively -
argName is Type
- type predicate, use it to turn a function into a safeguard
And now itâs time to build a better, typesafe future, and weâre ready for it!
...
Originally published at https://www.developerway.com. The website has more articles like this đ
Subscribe to the newsletter, connect on LinkedIn or follow on Twitter to get notified as soon as the next article comes out.
Posted on December 8, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.