0

Is it possible to have a Lodash style "mapValues" and "mapKeys" functions, that returns a mapped object literal - instead of a (too) generic record type?

What I mean:

_.mapValues({a:1, b:2}, (v) => v === 1 ? 'aaa' : 'bbb') 

This code (Lodash library), returns a Record<'a' | 'b', 'aaa' | 'bbb'>, instead of the literal type {a: 'aaa', b: 'bbb'}

Same as Ramda/Fp-ts functions - some type information is lost.

Robert Harvey
  • 178,213
  • 47
  • 333
  • 501
Daniel
  • 1,562
  • 3
  • 22
  • 34
  • I'm pretty sure that in a simple `const a = 1, b = a + 1;` the variable `b` wouldn't get the literal type `2` either, it's just a `number`. There is no constant propagation, and no computations are applied to literal types. – Bergi Aug 25 '19 at 14:02

2 Answers2

4

I don't think the TypeScript compiler has enough support for higher order type analysis to do this for you. The problems I see:

  • There's no good way for the compiler to infer that the function const mapper = (v: 1 | 2) => v === 1 ? "aaa" : "bbb" is of a conditional generic type like <V extends 1 | 2>(v: V) => V extends 1 ? "aaa" : "bbb" or of an overloaded function type like {(v: 1): "aaa", (v: 2): "bbb"}. If you want the compiler to treat the function like that you will have to assert or annotate the type manually.

  • Even if it could infer that, there's no way to write a type function like Apply<typeof f, typeof x> where f is an overloaded or generic function of one argument, and x is an acceptable argument for it so that Apply<typeof f, typeof x> is the type of f(x). Shorter: there's no typeof f(x) in TypeScript. So while you can call mapper(1) and the compiler knows the result is of type "aaa", you can't represent that knowledge in the type system. That prevents you from doing something like overloaded function resolution or generic function resolution in the type system.


The most straightforward typing I can imagine for _.mapValues would give you the wide Record-like type, and you'd have to assert a narrower type if you want that:

declare namespace _ {
  export function mapValues<T, U>(
    obj: T,
    fn: (x: T[keyof T]) => U
  ): Record<keyof T, U>;
}
const obj = { a: 1, b: 2 } as const;
type ExpectedRet = { a: "aaa"; b: "bbb" };
_.mapValues(obj, v => (v === 1 ? "aaa" : "bbb")); // Record<"a"|"b", "aaa"|"bbb">
const ret = _.mapValues(obj, v => (v === 1 ? "aaa" : "bbb")) as ExpectedRet;

Otherwise you have to jump through many flaming hoops (manually specifying types, manually declaring functions as overloads) and you end up with something not much safer than a type assertion and a lot more complicated:

type UnionToIntersection<U> = (U extends any
  ? (k: U) => void
  : never) extends ((k: infer I) => void)
  ? I
  : never;

declare namespace _ {
  export function mapValues<T, U extends Record<keyof T, unknown>>(
    obj: T,
    fn: UnionToIntersection<{ [K in keyof T]: (x: T[K]) => U[K] }[keyof T]>
  ): U;
}

function mapper(v: 1): "aaa";
function mapper(v: 2): "bbb";
function mapper(v: 1 | 2): "aaa" | "bbb" {
  return v === 1 ? "aaa" : "bbb";
}

const obj = { a: 1, b: 2 } as const;
type ExpectedRet = { a: "aaa"; b: "bbb" };
const ret = _.mapValues<typeof obj, ExpectedRet>(obj, mapper);

Not sure if that's even worth explaining... you have to manually specify the input and expected output types in the call to _.mapValues because there's no way the compiler can infer the output type (as mentioned above). You have to manually specify that mapper is an overloaded function. The typing for _.mapValues is complicated and uses UnionToIntersection to describe the required overloaded function as the intersection of function types taking input values to output values.

So, I'd stay away from this and just use a type assertion.


Hope that helps; sorry I don't have a more satisfying answer. Good luck!

Link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360
0

I know I've seen it somewhere. But I think you wanted this.

const mapped = Object.assign({}, ...Object.keys(ori).map((key) => {return {[key]: /*...*/}}))

Example:

JS:

const x = {a: 1, b: 2};
const y = Object.assign({}, ...Object.keys(x).map((key) => ({[key]: x[key] + 1})));

// y = {a: 2, b: 3}

TS:

const x: Record<string, number> = {a: 1, b: 2};
const y = Object.assign({}, ...Object.keys(x).map((key) => ({[key]: x[key] + 1})));
Daniel Cheung
  • 4,779
  • 1
  • 30
  • 63
  • Thanks - but does not compile under TypeScript 3.5 – Daniel Aug 25 '19 at 14:08
  • Although now it compiles - "x" is a Record instead of object literal, and "y" has the type "any". Not what I was looking for. Thanks again. – Daniel Aug 25 '19 at 14:34
  • @Daniel I'm pretty sure you need to type everything in TS because everything needs to be predictable. If you want an `any` type, just use `Record` for `x`, but most linters would discourage using `any` as a type, as this conflicts with TS's philosophy. And assigning type `Record` to y would still compile, because I personally use this in my projects. – Daniel Cheung Aug 25 '19 at 15:59
  • Thanks, but I'm looking for the opposite of what you suggest (any type). – Daniel Aug 25 '19 at 16:34