The big problem you're running into here is that TypeScript does not currently support partial type parameter inference as requested in microsoft/TypeScript#26242. With a generic function like useSortBy<T, K>({/*...*/})
you can either manually specify both T
and K
type parameters, or the compiler can try to infer both type T
and K
type parameters. There's no direct way to define it so that T
must be manually specified but that K
should be inferred.
The workaround I tend to use in these cases is currying; make useSortBy
a function generic in only T
, and have it return another function generic in only K
. This changes the call from the desired useSortBy<T>({/*...*/})
to the slightly weird useSortBy<T>()({/*...*/})
, but it works.
The second problem you're running into is that the compiler is using the defaultSortOption
parameter to infer K
instead of the sortOptions
parameter. So you only get "nameDesc"
instead of "nameAsc" | "nameDesc"
. What you want to tell the compiler is that the K
in the type of defaultSortOption
should be a non-inferential use of the type parameter; it should infer K
from sortOptions
alone, and then just check defaultSortOption
with it. Such non-inferential type parameter usage is requested in microsoft/TypeScript#14829, but there is no direct support for this. Again, though, there are workarounds.
One workaround you can use is to introduce a second type parameter (let's call it L
) which is constrained to the first one. You can use this new type parameter instead of K
anywhere you'd like to be non-inferential. Then the compiler will infer K
only from the places you want (because those are the only places mentioning K
at all), and whatever it infers for L
will be checked against K
.
Let's use both of these workarounds in your useSortBy
example. For clarity I'm introducing the following types
interface Model { name: string, age: number }
type SortOption<T> = {
fn: (list: T[]) => T[];
label: string;
}
Now here is the curried and extra-type-parametered useSortBy()
:
const useSortBy = <T,>() => <K extends string, L extends K>(opts: {
sortOptions: Record<K, SortOption<T>>;
defaultSortOption: L;
}) => { }
Let's use it:
useSortBy<Model>()({
sortOptions: {
nameAsc: {
fn: (list) => list.slice().sort((a, b) => a.name.localeCompare(b.name)),
label: "Name A-Z",
},
nameDesc: {
fn: (list) => list.slice().sort((a, b) => b.name.localeCompare(a.name)),
label: "Name Z-A",
}
},
defaultSortOption: "nameDesc",
});
That works fine and note that the list
callback parameters are contextually types to be Model[]
without needing explicit type annotations. And if you put a value for defaultSortOption
that does not exist as a key of the sortOptions
property, you see the desired error in the desired place:
useSortBy<Model>()({
sortOptions: {
nameAsc: {
fn: (list) => list.slice().sort((a, b) => a.name.localeCompare(b.name)),
label: "Name A-Z",
},
nameDesc: {
fn: (list) => list.slice().sort((a, b) => b.name.localeCompare(a.name)),
label: "Name Z-A",
}
},
defaultSortOption: "oopsieDoodle", // error!
//~~~~~~~~~~~~~~~ <-- Type '"oopsieDoodle"' is
// not assignable to type '"nameAsc" | "nameDesc"'
});
Playground link to code