Developer way

Advanced typescript for React developers

Nadia Makarevich

Nadia Makarevich

Dec 8, 2021

Nadia MakarevichNadia Makarevich

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.

1type Base = {
2 id: string;
3 title: string;
4};
5
6type GenericSelectProps<TValue> = {
7 values: TValue[];
8 onChange: (value: TValue) => void;
9};
10
11export const GenericSelect = <TValue extends Base>({ values, onChange }: GenericSelectProps<TValue>) => {
12 const onSelectChange = (e) => {
13 const val = values.find((value) => value.id === e.target.value);
14
15 if (val) onChange(val);
16 };
17
18 return (
19 <select onChange={onSelectChange}>
20 {values.map((value) => (
21 <option key={value.id} value={value.id}>
22 {value.title}
23 </option>
24 ))}
25 </select>
26 );
27};

and then this component can be used with any data types Judi has in her application

1<GenericSelect<Book> onChange={(value) => console.log(value.author)} values={books} />
2<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.

1type Laptop = {
2 id: string;
3 model: string;
4 releaseDate: string;
5}
6
7// This will fail, since there is no "title" in the Laptop type
8<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:

1<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:

1type Laptop = {
2 id: string;
3 model: string;
4 releaseDate: string;
5}
6
7type 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:

1<GenericSelect<Laptop> titleKey="model" {...} />
2// inside GenericSelect "titleKey" will be typed to "id" | "model" | "releaseDate"
3
4<GenericSelect<Book> titleKey="author" {...} />
5// 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

1type Base = {
2 id: string;
3 title?: string;
4}
5
6export 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:

  1. Convert Base type into something that understands strings as well as objects
  2. Get rid of reliance on value.id as the unique identificator of the value in the list of options
  3. 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:

1type Base = { id: string } | string;
2
3// Now "TValue" can be either a string, or an object that has an "id" in it
4export 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:

1const 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.

1type Base = { id: string } | string;
2
3const getStringFromValue = <TValue extends Base>(value: TValue) => {
4 if (typeof value === 'string') {
5 // here "value" will be the type of "string"
6 return value;
7 }
8
9 // here "value" will be the type of "NOT string", in our case { id: string }
10 return value.id;
11};

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:

1type GenericSelectProps<TValue> = {
2 formatLabel: (value: TValue) => string;
3 ...
4};
5
6export const GenericSelect = <TValue extends Base>(props: GenericSelectProps<TValue>) => {
7 ...
8 return (
9 <select onChange={onSelectChange}>
10 {values.map((value) => (
11 <option key={getStringFromValue(value)} value={getStringFromValue(value)}>
12 {formatLabel(value)}
13 </option>
14 ))}
15 </select>
16 );
17}
18
19// Show movie title and release date in select label
20<GenericSelect<Movie> ... formatLabel={(value) => `${value.title} (${value.releaseDate})`} />
21
22// Show laptop model and release date in select label
23<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:

1type Base = { id: string } | string;
2
3type GenericSelectProps<TValue> = {
4 formatLabel: (value: TValue) => string;
5 onChange: (value: TValue) => void;
6 values: TValue[];
7};
8
9const getStringFromValue = <TValue extends Base>(value: TValue) => {
10 if (typeof value === 'string') return value;
11
12 return value.id;
13};
14
15export const GenericSelect = <TValue extends Base>(props: GenericSelectProps<TValue>) => {
16 const { values, onChange, formatLabel } = props;
17
18 const onSelectChange = (e) => {
19 const val = values.find((value) => getStringFromValue(value) === e.target.value);
20
21 if (val) onChange(val);
22 };
23
24 return (
25 <select onChange={onSelectChange}>
26 {values.map((value) => (
27 <option key={getStringFromValue(value)} value={getStringFromValue(value)}>
28 {formatLabel(value)}
29 </option>
30 ))}
31 </select>
32 );
33};

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.

1const tabs = ['Books', 'Movies', 'Laptops'];
2
3const getSelect = (tab: string) => {
4 switch (tab) {
5 case 'Books':
6 return <GenericSelect<Book> onChange={(value) => console.info(value)} values={books} />;
7 case 'Movies':
8 return <GenericSelect<Movie> onChange={(value) => console.info(value)} values={movies} />;
9 case 'Laptops':
10 return <GenericSelect<Laptop> onChange={(value) => console.info(value)} values={laptops} />;
11 }
12}
13
14const Tabs = () => {
15 const [tab, setTab] = useState<string>(tabs[0]);
16
17 const select = getSelect(tab);
18
19
20 return (
21 <>
22 <GenericSelect<string> onChange={(value) => setTab(value)} values={tabs} />
23 {select}
24 </>
25 );
26};

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:

1const 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.

1// an array of values type "string"
2const tabs = ['Books', 'Movies', 'Laptops'];
3
4tabs.forEach(tab => {
5 // typescript is fine with that, although there is no "Cats" value in the tabs
6 if (tab === 'Cats') console.log(tab)
7})
8
9// an array of values 'Books', 'Movies' or 'Laptops', and nothing else
10const tabs = ['Books', 'Movies', 'Laptops'] as const;
11
12tabs.forEach(tab => {
13 // typescript will fail here since there are no Cats in tabs
14 if (tab === 'Cats') console.log(tab)
15})

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:

1const tabs = ['Books', 'Movies', 'Laptops'];
2type Tabs = typeof tabs; // Tabs will be string[];
3
4const tabs = ['Books', 'Movies', 'Laptops'] as const;
5type 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.

1type 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:

1type 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?

1type Base = { id: string } | string;
2
3const getStringFromValue = <TValue extends Base>(value: TValue) => {
4 if (typeof value === 'string') {
5 // here "value" will be the type of "string"
6 return value;
7 }
8
9 // here "value" will be the type of "NOT string", in our case { id: string }
10 return value.id;
11};

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:

1type Base = { id: string } | string;
2
3const isStringValue = <TValue>(value: TValue) => return typeof value === 'string';
4
5const getStringFromValue = <TValue extends Base>(value: TValue) => {
6 if (isStringValue(value)) {
7 // do something with the string
8 }
9
10 // do something with the object
11};

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:

1const getStringFromValue = <TValue extends Base>(value: TValue) => {
2 if (isStringValue(value)) { // it's just a random function that returns boolean
3 // type here will be unrestricted, either string or object
4 }
5
6 // type here will be unrestricted, either string or object
7 // can't return "value.id" anymore, typescript will fail
8};

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:

1type T = { id: string };
2// can't extend Base here, typescript doesn't handle generics here well
3export const isStringValue = <TValue extends T>(value: TValue | string): value is string => {
4 return typeof value === 'string';
5};

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:

1type T = { id: string };
2type Base = T | string;
3
4export const isStringValue = <TValue extends T>(value: TValue | string): value is string => {
5 return typeof value === 'string';
6};
7
8const getStringFromValue = <TValue extends Base>(value: TValue) => {
9 if (isStringValue(value)) {
10 // do something with the string
11 }
12
13 // do something with the object
14};

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:

1export type DataTypes = Book | Movie | Laptop | string;
2
3export const isBook = (value: DataTypes): value is Book => {
4 return typeof value !== 'string' && 'id' in value && 'author' in value;
5};
6export const isMovie = (value: DataTypes): value is Movie => {
7 return typeof value !== 'string' && 'id' in value && 'releaseDate' in value && 'title' in value;
8};
9export const isLaptop = (value: DataTypes): value is Laptop => {
10 return typeof value !== 'string' && 'id' in value && 'model' in value;
11};

And then implement a function that returns option labels for our selects:

1const formatLabel = (value: DataTypes) => {
2 // value will be always Book here since isBook has predicate attached
3 if (isBook(value)) return value.author;
4
5 // value will be always Movie here since isMovie has predicate attached
6 if (isMovie(value)) return value.releaseDate;
7
8 // value will be always Laptop here since isLaptop has predicate attached
9 if (isLaptop(value)) return value.model;
10
11 return value;
12};
13
14// somewhere in the render
15<GenericSelect<Book> ... formatLabel={formatLabel} />
16<GenericSelect<Movie> ... formatLabel={formatLabel} />
17<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'] or Type[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!

Share on:Share "Advanced typescript for React developers" on TwitterShare "Advanced typescript for React developers" on LinkedInShare "Advanced typescript for React developers" on Reddit
Get the latest content by email
    No spam or ads. Unsubscribe at any time.
    © Developer Way 2021