This error has plagued me for years now. And thanks to Inigo I finally understood the problem.
TL;DR: When you use T extends A
in a generic declaration, you require T
to be at least A
, but it can have more properties (by being a different subtype).
The problem arises when you want to create an object that conforms to T
thinking it can just conform to A
. You can't, because T
can require additional or more specific properties than A
.
This happens a lot when we create React Hooks that handle generic objects and arrays.
One solution is to pass a callback function to the generic that knows how to translate something like A
to T
, given what your generic function expects to handle.
The Code
Suppose we are creating a hook to abstract filtering options for a combobox, having a disabled "No results found" option if there is no result for the search:
type SelectOption = {
name: string;
value: string | null;
icon?: string;
description?: string;
disabled?: boolean;
};
function useComboboxFilter<T extends SelectOption>(props: {
options: T[];
filterValue: string;
}): { filteredOptions: T[] } {
let filteredOptions = props.filterValue
? props.options.filter((option) => option.name.includes(props.filterValue))
: props.options;
if (props.filterValue && !filteredOptions.length) {
filteredOptions = [
{ name: "No results found", value: null, disabled: true },
];
}
return { filteredOptions };
}
Note for React devs
Because this is an example, I didn't properly optimize the code. A proper React Hook should wrap filteredOptions
and the return object in a useMemo
:
function useComboboxFilter<T extends SelectOption>(props: {
options: T[];
filterValue: string;
}): { filteredOptions: T[] } {
const filteredOptions = useMemo(() => {
let filteredOptions = props.filterValue
? props.options.filter((option) =>
option.name.includes(props.filterValue)
)
: props.options;
if (props.filterValue && !filteredOptions.length) {
filteredOptions = [
{ name: "No results found", value: null, disabled: true },
];
}
return filteredOptions;
}, [props.filterValue, props.options]);
return useMemo(() => ({ filteredOptions }), [filteredOptions]);
}
The Problem
If you check this code with the TypeScript compiler, you will get this error:
Type '{ name: string; value: null; disabled: true; }'
is not assignable to type 'T'.
'{ name: string; value: null; disabled: true; }' is
assignable to the constraint of type 'T', but 'T' could
be instantiated with a different subtype of constraint
'SelectOption'. ts(2322)
This is a cryptic error message: it properly describes the problem without helping the reader understand how this happened or what they actually did wrong.
The problem here is quite simple: { name: string; value: null; disabled: true; }
satisfies SelectOption
, but because T
could be a different subtype of SelectOption
, that is, a type with slightly different requirements, your generic function can't know what it is to create a new version of it.
A clear problem for our example is: imagine the generic type T
we created requires an id
property:
type SelectOptionWithId = {
id: string;
} & SelectOption;
const myFilter = useComboboxFilter<SelectOptionWithId>({
options: [],
filterValue: "bla",
});
The result myFilter
in this case will have an object without id
, because the generic hook doesn't know it needs to add that property.
Because the generic code doesn't know T
, you can only create a new version of SelectOption
. To fix this, you need a way to transform a SelectOption
object into T
, which only your non-generic code can provide.
One Solution
Because inside useComboboxFilter
you only know about SelectOption
, and you only care about the name
, value
and disabled
properties of it, you can create a required function parameter that translate a partial SelectOption
into any T:
function useComboboxFilter<T extends SelectOption>(props: {
options: T[];
createOption: (
option: Pick<SelectOption, "name" | "value" | "disabled">
) => T;
filterValue: string;
}): { filteredOptions: T[] } {
let filteredOptions = props.filterValue
? props.options.filter((option) => option.name.includes(props.filterValue))
: props.options;
if (props.filterValue && !filteredOptions.length) {
filteredOptions = [
props.createOption({
name: "No results found",
value: null,
disabled: true,
}),
];
}
return { filteredOptions };
}
When you then use your generic useComboboxFilter
with a subtype of SelectOption
, you must make sure the generic function can create a new object that satisfies that type:
type SelectOptionWithId = {
id: string;
} & SelectOption;
const createOption = (option: SelectOption): SelectOptionWithId => ({
id: Math.random().toString(),
...option,
});
const myFilter = useComboboxFilter({
options: [],
createOption,
filterValue: "bla",
});
Note that with the createOption
property, TypeScript infers SelectOptionWithId
for myFilter
.
If you want to learn more about generics and other neat TypeScript features, be sure to check @mattpocockuk's YouTube videos and his Total TypeScript online course.
Top comments (1)
Wes, just found your lg-tv-ip-control project on GitHub. Nice. Any change you could point me to the documentation you used to develop the project? I would like to implement something similar to lg-tv-ip-control in PowerShell. Any assistance would be greatly appreciated.