Advanced typescript for React developers

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

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:

type Laptop = {
id: string;
model: string;
releaseDate: string;
}

type LaptopKeys = keyof Laptop;
<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"
type Base = {
id: string;
title?: string;
}

export const GenericSelect = <TValue extends Base>(props: GenericSelectProps<TValue>) => {

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?).

  1. Get rid of reliance on value.id as the unique identificator of the value in the list of options
  2. Convert value[titleKey] into something that understands strings as well
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>) => {
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;
};
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}`} />
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}
</>
);
};
// 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)
})
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'];
type Tab = Tabs[number]; // Tab will be 'Books' | 'Movies' | 'Laptops'
type LaptopId = Laptop['id']; // LaptopId will be string

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;
};
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
};
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
};
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';
};
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
};
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;
};
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} />

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:

  • “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

--

--

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

768 Followers

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