'B' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint 'A'.
Wes Souza
Posted on January 15, 2023
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.
Posted on January 15, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
June 9, 2021
March 8, 2020