typescriptmapped-types

How to generically set values of mapped type


See this following code example:

type Properties = {
  item0: { item0: string };
  item1: { item1: string };
  item2: { item2: string };
  item3: { item3: string };
  item4: { item4: string };
};

type Func<N extends keyof Properties> = ({}: Properties[N]) => number;
const Wrap = <N extends keyof Properties>(inner: Func<N>): Func<N> => {
  return (v: Properties[N]) => inner(v) + 2;
};
type FuncSet = { [Property in keyof Properties]: Func<Property> };
const WrapSet = (inner: FuncSet): FuncSet => {
  return {
    item0: Wrap(inner.item0),
    item1: Wrap(inner.item1),
    item2: Wrap(inner.item2),
    item3: Wrap(inner.item3),
    item4: Wrap(inner.item4),
  };
};

How would one refactor WrapSet such that it would iterate over the items in Properties so that they wouldn't have to be explicitly specified? I am looking for an answer that doesn't bypass the typescript type system, as this is a typescript question and not a javascript question.


Solution

  • There's no good way to do this in TypeScript without using some type-loosening mechanism like type assertions. The language doesn't quite have the expressiveness required to abstract over mapped types in general, especially without higher kinded types as requested in microsoft/TypeScript#1213.

    I would be inclined to write

    const WrapSet = (inner: FuncSet): FuncSet =>
      Object.fromEntries(Object.entries(inner).map(([k, v]) => [k, Wrap<any>(v)])) as
      unknown as FuncSet;
    

    using Object.entries() to split the object into its properties, then mapping them to the new properties, and finally using Object.fromEntries() to build an object again. This works at runtime without a problem, but I've used type assertions and the any type to avoid type errors.

    If you try to make this more type safe, you'll find that you keep hitting roadblocks around higher order generics and their constraints. You'd like to say that WrapSet is a specific form of the more general mapper mapValues():

    function mapValues<T extends object>(obj: T, map: <K extends keyof T>(v: T[K]) => T[K]) {
      return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, map(v)])) as T
    }
    

    but it's essentially impossible to convince the compiler that Wrap is of the proper type:

    const WS = (inner: FuncSet) => mapValues(inner, Wrap); // error!
    // -------------------------------------------> ~~~~
    

    The proper type looks like this:

    const Wrap = <N extends keyof Properties>(inner: FuncSet[N]): FuncSet[N] => {
      return (v: Properties[N]) => inner(v) + 2; // error!
    //~~~~~~
    };
    

    But again, the compiler isn't happy. It just can't follow the higher-order relationship here. You might be able to play some games with generics and get closer, but we're already well past the point of diminishing returns. Personally I would stick with the type assertion version until such time as higher order generics are implemented in TypeScript (this might be never).

    Playground link to code