Reputation: 1544
This is a contrived, but normally I can pass an object literal to a function and capture the values of the literal in a generic, i.e.:
type Values<V> = {
a: V;
b: V;
};
function mapValues<V>(v: Values<V>): V {
return v as any; // ignore
}
const vn = mapValues({ a: 1, b: 2 }); // inferred number
const vs = mapValues({ a: '1', b: '2' }); // inferred string
Both vn
and vs
are correctly inferred to number
or string
depending on what I passed to mapValues
.
This even works for indexed types:
function mapValues2<V>(v: { [key: string]: V }): V {
return v as any;
}
const v2n = mapValues2({ a: 1, b: 2 }); // inferred number
const v2s = mapValues2({ a: '1', b: '2' }); // inferred string
The v
object literal knows it's values are string
/number
(respectively), and I'm able to capture that inferred V
and use it in the return type.
However, once I use a mapped type, i.e.:
enum Foo {
a,
b,
}
function mapValues3<K, V>(o: K, v: { [key in keyof K]: V }): V {
return v as any;
}
const v3n = mapValues3(Foo, { a: 1, b: 2 }); // inferred unknown
const v3s = mapValues3(Foo, { a: '1', b: '2' }); // inferred unknown
It is like V
has forgotten whether it was string
/number
(respectively), and I get unknown
inferred.
Note that if I explicitly type const v3n: number
then it works, but I want to rely on type inference to figure V
out for me.
I'm confused why change the [key: string]
from the 2nd snippet to [key in keyof K]
in the 3rd would affect the inference of the : V
side of the object literal's type inference.
Any ideas?
Upvotes: 2
Views: 749
Reputation: 328433
I don't have a canonical answer for why this fails. My intuition is that you are trying to make mapValue3(o, v)
infer both the keys and values of the v
argument in a way that's dependent on the keys of the o
argument, deferring the inference of V
until it's apparently too late and the compiler gives up with its general unknown
inference.
Often you have a situation where you have a value val
of type U
and are trying to infer a related type T
from it. That is, you are thinking of U
as F<T>
for some type function F
, and you want the compiler to infer T
from F<T>
. Assuming such inference is even possible for anyone (a lossy function like type F<T> = T extends object ? true : false
throws away information so you can't infer much about T
from F<T>
), it's not always possible for the compiler. If it works, great.
Otherwise, my rule of thumb is this: instead of inferring T
from a value of type U
, infer U
directly. Then, represent T
as G<U>
for some type function G
. That is, the G
type function should be the inverse of the F
function. (So G<F<T>>
is T
for all T
). The inverse type function G
might be more complicated to write than F
, but if so, a human is probably going to be better at this than the compiler. Also note that you might need to constrain your U
types to be F<any>
to make the mapping work.
The above also works for multiple type variables (e.g., values v1
, v2
, v3
of types U1 = F1<T1, T2, T3>
, U2 = F2<T1, T2, T3>
, U3 = F3<T1, T2, T3>
and you want to infer T1
, T2
, and T3
from it: instead, find G1
, G2
, and G3
such that T1 = G1<U1, U2, U3>
, T2 = G2<U1, U2, U3>
and T3 = G3<U1, U2, U3>
and calculate them directly).
So let's do that for your function, rewritten like this:
function mapValues3<O, T>(o: O, v: F<O, T>): T {
return null!
}
type F<O, T> = Record<keyof O, T>; // same as { [key in keyof O]: T };
We want to transform it to this:
function mapValues3<O, U extends F<O, any>>(o: O, v: U): G<O, U> {
return null!
}
type G<O, U> = ???;
Note that since the first parameter is already just "infer the value of this parameter", there's nothing we need to do for O
. The question is: what's G
? Given a value of type U
equal to Record<keyof O, T>
, how do we get T
out? The answer is to use a lookup type:
type G<O, U> = U[keyof O];
Let's just make sure: G<O, Record<keyof O, T>
is Record<keyof O, T>[keyof O]
, which is {[K in keyof O]: T}[keyof O]
, which evaluates to T
. We can eliminate F
and G
now and give you this:
function mapValues3<O, U extends Record<keyof O, any>>(o: O, v: U): U[keyof O] {
return null!
}
And let's test it out:
const v3n = mapValues3(Foo, { a: 1, b: 2 }); // number
const v3s = mapValues3(Foo, { a: "1", b: "2" }); // string
Those work the way you want. Let's also see some possible edge cases:
const constraintViolation = mapValues3(Foo, { a: "hey" }); // error! "b" is missing
That looks right, since you want the second parameter to have all the keys from the first parameter. And this:
const excessProp = mapValues3(Foo, { a: 1, b: 2, c: false }); // number, no error
This may or may not be fine. It doesn't violate the constraint; it just has extra properties, which are allowed in TypeScript in general (but forbidden in some situations). The inference is number
and not number | boolean
since the extra properties are not consulted when figuring out the return type. If it's a problem there are ways to make mapValues3
reject such things, but they're more complicated and this wasn't part of the question.
Okay, hope that helps. Good luck!
Upvotes: 2