I need to supply an adapter function that takes an object and returns a value of a given type or types. I am trying to write a generic function that will generate such an adapter function given an object property name, where the property names are restricted to properties whose value is of the given type(s).
For example, consider the simple case of numeric properties:
type Adapter<T> = (from: T) => number
I think I want to do something like:
type NumberFields<T> = {[K in keyof T]: T[K] extends number ? K : never}[keyof T]
function foo<T>(property: NumberFields<T>) {
return function(from: T) {
return from[property]
}
}
TypeScript is happy enough when the argument to NumberFields
is not generic:
interface Foo {
bar: number
}
function ok<T extends Foo, K extends NumberFields<Foo>>(property: K): Adapter<T> {
return function(from: T): number {
return from[property] // ok
}
}
However, in the generic case TypeScript doesn't want to recognise that from[property]
can only possibly be a number:
function bar<T, K extends NumberFields<T>>(property: K): Adapter<T> {
return function(from: T): number {
return from[property] // error: Type 'T[K]' is not assignable to to type 'number'
}
}
function baz<T, K extends NumberFields<T>>(property: K): Adapter<T> {
return function(from: T): T[K] { // error: Type '(from: T) => T[K]' is not assignable to type 'Adapter<T>'
return from[property]
}
}
Why does it work when the type is constrained, but not in the truly generic case?
I have looked at many SO questions, of which Narrowing TypeScript index type seemed the closest to what I want, but a type guard isn't useful here since the type should be generic.
The compiler just isn't clever enough to see through such type manipulation of generic type parameters. With concrete types, the compiler can just evaluate the final type as a bag of concrete properties, so it knows that the output will be of type Foo['bar']
which is type number
.
There are different ways to proceed here. Often the most convenient is to use a type assertion because you are more clever than the compiler. This keeps the emitted JavaScript the same, but just tells the compiler not to worry:
function bar<T, K extends NumberFields<T>>(property: K): Adapter<T> {
return function (from: T): number {
return from[property] as unknown as number; // I'm smarter than the compiler 🤓
}
}
Of course it's not type-safe, so you need to be careful not to lie to the compiler (e.g., from[property] as unknown as 12345
will also compiler but is probably not true).
Equivalent to type assertions is the single-overload function, where you give a call signature that is as precise as you want, and then loosen it up so the implementation doesn't complain:
// callers see this beautiful thing
function bar<T, K extends NumberFields<T>>(property: K): Adapter<T>;
// implementer sees this ugly thing
function bar(property: keyof any): Adapter<any>
{
return function (from: any): number {
return from[property];
}
}
The other way to go is try to let the compiler assure you of type safety, by retyping the function into a form that the compiler is able to understand. Here's a less deeply-nested way to do it:
function bar<T extends Record<K, number>, K extends keyof any>(property: K): Adapter<T> {
{
return function (from: T): number {
return from[property]
}
}
}
In this case we're representing T
in terms of K
, not the other way around. And no errors are here. The only reason not to go with this method is that maybe you want errors to appear on K
and not on T
. That is, when people call bar()
with a bad parameter, you'd like it to complain that their K
type is wrong, not that their T
type is wrong.
You can sort of get the best of both worlds by doing this:
function bar<T, K extends NumberFields<T>>(property: K): Adapter<T>;
function bar<T extends Record<K, number>, K extends keyof any>(property: K): Adapter<T> {
{
return function (from: T): number {
return from[property]
}
}
}
One last thing... I'm not sure how you're planning to call bar()
. Do you mean to specify the type parameters yourself?
bar<Foo, "bar">("bar");
Or do you want the compiler to infer them?
bar("bar")?
If it's the latter, you're probably out of luck, since there's nowhere to infer T
except from possibly some external context. So you might want to look into this more.
Okay, hope that helps. Good luck!