18
Advanced typescript for React developers
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.
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?
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>) => {
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.
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>
);
};
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! 💥
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} />
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. Check out the website for more articles like this. Subscribe to the newsletter to get notified as soon as the next article comes out.
18