Stephen Haberman
Stephen Haberman

Reputation: 1544

Why is this mapped type losing the type inference of the object literal?

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

Answers (1)

jcalz
jcalz

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!

Link to code

Upvotes: 2

Related Questions