1

I'm in ES7/ES8, I want to know how to map an object's values, regardless of its keys (like Array.map).

I found this post here

But :

  • I am forced to map also the whole object, not the just value
  • I also want it to give a strongly typed type inference when assigning to a variable.
  • (If possible in a functional coding style)
// === TO MODIFY ===
type AnyObjectKeys = string | number;
type AnyObject<
  Values,
  Keys extends AnyObjectKeys = AnyObjectKeys,
  > = Record<Keys, Values>;

type MapperFunc<T> = (
  value: [AnyObjectKeys, T],
  index: number,
  array: [AnyObjectKeys, T][],
) => unknown

/**
 * map an object's values, like Array<T>.map
 * but can also access its key with another signature
 */
const objMapper =
  <AnyObjectValues>(object: AnyObject<AnyObjectValues>) =>
    (func: MapperFunc<AnyObjectValues>) =>
      Object.assign({}, ...Object.entries(object).map(func));
// === TO MODIFY ===

const getAverage = (arr: number[]) =>
  arr.reduce((a, b) => a + b, 0) / arr.length

// BASE OBJECT
const curvesPointsByDynamicKeys = {
  ['a']: {
    x: [3, 4, 1],
    y: [6, 1, 4],
    z: [3, 9, 0],
  },
  ['b']: {
    x: [2, 1, 0],
    y: [9, 3, 3],
    z: [4, 6, 0],
  },
  // ...
};

// EXPECTED USAGE 1 (with key access)
const curvesAveragePointsByDynamicKeys = objMapper(curvesPointsByDynamicKeys)
  (
    ([key, { x, y, z }]) => {
      return {
        id: `curve-${key}-${x.length}-${y.length}-${z.length}`,
        averageX: getAverage(x),
        averageY: getAverage(y),
        averageZ: getAverage(z),
      }
    }
  );

// EXPECTED USAGE 2 (without key access)
const curvesAveragePointsByDynamicKeys2 = objMapper(curvesPointsByDynamicKeys)
  (
    ({ x, y, z }) => {
      return {
        averageX: getAverage(x),
        averageY: getAverage(y),
        averageZ: getAverage(z),
      }
    }
  );

// EXPECTED RESULT 1 :
// {
//   ['a']: {
//     id: '...',
//     averageX: 2.6666666666666665,
//     averageY: 3.6666666666666665,
//     averageZ: 4,
//   },
//   ['b']: {
//     id: '...',
//     averageX: 1,
//     averageY: 5,
//     averageZ: 3.3333333333333335,
//   },
//   // ...
// }
console.log(curvesAveragePointsByDynamicKeys)

// EXPECTED RESULT 2 :
// {
//   ['a']: {
//     averageX: 2.6666666666666665,
//     averageY: 3.6666666666666665,
//     averageZ: 4,
//   },
//   ['b']: {
//     averageX: 1,
//     averageY: 5,
//     averageZ: 3.3333333333333335,
//   },
//   // ...
// }
console.log(curvesAveragePointsByDynamicKeys2)

// NOT EXPECTED RESULT :
// {
//   averageX: 1,
//   averageY: 5,
//   averageZ: 3.3333333333333335,
// }
E-jarod
  • 275
  • 2
  • 13
  • 1
    I think [this approach](//tsplay.dev/m3XPjW) is the closest to what you're looking for. First, I assume `x` instead of `averageX` in the result is just a typo. Second, there's no way to have your mapper function *decide* whether its first argument will be a tuple of key-value pairs or just the value. The *caller* decides what to pass, and it can't probe the callback function to figure out what it expects (function parameter types are just part of the TS static type system and do not exist at runtime); you'll have a much better time passing a consistent set of parameters always. ... – jcalz Aug 22 '22 at 19:10
  • ... If that approach meets your needs I can write up an answer explaining it. If not, what am I missing? – jcalz Aug 22 '22 at 19:10
  • I think that's the closest approach that I want, and I would not refuse some explanations ! – E-jarod Aug 23 '22 at 17:52
  • Okay I will write up an answer when I get a chance. – jcalz Aug 23 '22 at 18:22

1 Answers1

1

Your original version where the callback passed to the result of objMapper() either accepts a single parameter of a key-value tuple type, or a single parameter of just the value type, is not going to work. At runtime, it's essentially impossible to inspect a function and understand what type of parameter it accepts. Such information doesn't really exist at runtime. So the implementation of objMapper() would never know whether to pass [key, value] or just value to the callback.

Instead, we can write objMapper() so that it always passes both value and key as two separate arguments to the callback. If the callback cares about both value and key, it should use them, like (value, key) => {}. Otherwise, if it only wants value then it should only look at the first argument, like (value) => {}. This is acceptable, since functions with fewer parameters are assignable to functions that take more parameters.


Here's what that looks like:

const objMapper =
    <K extends PropertyKey, VI>(obj: { [P in K]: VI }) =>
        <VO,>(func: (v: VI, k: K) => VO) =>
            Object.fromEntries(
                Object.entries(obj).map(
                    ([k, v]) => [k, func(v as any, k as any)])
            ) as { [P in K]: VO };

The call signature is essentially that you pass objMapper an obj of a type whose keys are of the generic type K, and whose values are of the generic type VI (the "input value" type). This returns another generic function which takes a callback func of type (v: VI, k: K) => VO, meaning it accepts both the input value v and the key k, and returns a value of the generic type VO, the "output value" type. And the return type of this function is something whose keys are of generic type K, and whose values are of the generic type VO.

The implementation is using the Object.entries() method to turn obj into an array of entries, the map() array method to transform the value with the func callback, and the Object.fromEntries() method to turn the entry array back into an object. (The compiler can't verify that this actually satisfies the call signature, so I used a bunch of type assertions to suppress errors. I presume you care more about the compiler verifying type safety for callers of objMapper than you do for the implementer.)


Let's test it out on your example code, noting that the callback parameters either look like ({x, y, z}, key) => {} or like ({x, y, z}) => {}:

const curvesAveragePointsByDynamicKeys = objMapper(curvesPointsByDynamicKeys)(
    ({ x, y, z }, key) => {
        return {
            id: `curve-${key}-${x.length}-${y.length}-${z.length}`,
            averageX: getAverage(x),
            averageY: getAverage(y),
            averageZ: getAverage(z),
        }
    }
);
console.log(curvesAveragePointsByDynamicKeys);
/* {
  "a": {
    "id": "curve-a-3-3-3",
    "averageX": 2.6666666666666665,
    "averageY": 3.6666666666666665,
    "averageZ": 4
  },
  "b": {
    "id": "curve-b-3-3-3",
    "averageX": 1,
    "averageY": 5,
    "averageZ": 3.3333333333333335
  }
}  */

and

const curvesAveragePointsByDynamicKeys2 = objMapper(curvesPointsByDynamicKeys)
    (
        ({ x, y, z }) => {
            return {
                averageX: getAverage(x),
                averageY: getAverage(y),
                averageZ: getAverage(z),
            }
        }
    );

console.log(curvesAveragePointsByDynamicKeys2)
/* {
  "a": {
    "averageX": 2.6666666666666665,
    "averageY": 3.6666666666666665,
    "averageZ": 4
  },
  "b": {
    "averageX": 1,
    "averageY": 5,
    "averageZ": 3.3333333333333335
  }
} 
*/

Looks good.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360