27
loading...
This website collects cookies to deliver better user experience
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} />
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} />
<GenericSelect<Laptop> titleKey="model" {...} />
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;
LaptopKeys
I’ll find a union of its keys: "id" | "model" | "releaseDate"
.<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"
Base
a little bit more inclusive and make the title
optionaltype Base = {
id: string;
title?: string;
}
export const GenericSelect = <TValue extends Base>(props: GenericSelectProps<TValue>) => {
const categories = ['Books', 'Movies', 'Laptops'].
TValue
type to be an object.Base
type into something that understands strings as well as objectsvalue.id
as the unique identificator of the value in the list of optionsvalue[titleKey]
into something that understands strings as wellBase
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>) => {
value.id
. We can do that by converting all those calls to a function getStringFromValue
:const getStringFromValue = (value) => value.id || value;
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;
};
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.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}`} />
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>
);
};
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}
</>
);
};
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.const tabs = ['Books', 'Movies', 'Laptops'] as const;
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)
})
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'];
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'
type LaptopId = Laptop['id']; // LaptopId will be string
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;
};
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
};
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';
};
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
};
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;
};
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} />
“typeof”
, but operates on types rather than valuesType['attr']
or Type[number]
- those are indexed types, use them to access subtypes in an Object or an Array respectivelyargName is Type
- type predicate, use it to turn a function into a safeguard