Home > Back-end >  Map an object and retain type inference
Map an object and retain type inference

Time:04-07

I'm trying to create a function that maps an object via a map function.

interface Dictionary<T> {
  [key: string]: T;
}

function objectMap<TValue, TResult>(
  obj: Dictionary<TValue>,
  valSelector: (val: TValue) => TResult
) {
  const ret = {} as Dictionary<TResult>;

  for (const key of Object.keys(obj)) {
    ret[key] = valSelector.call(null, obj[key]);
  }
  
  return ret;
}

And then use it like this:

const myObj = {
  withString: {
    api: (id: string) => Promise.resolve(id),
  },
  withNumber: {
    api: (id: number) => Promise.resolve(id),
  },
}

const mapToAPI = objectMap(myObj, (val => val.api));

mapToAPI.withString('some string');

There is an error produced on the last line:

Argument of type 'string' is not assignable to parameter of type 'never'.

How can I map a generic object and retain type inference?

CodePudding user response:

As noted by jcalz, this sort of arbitrary transformation isn't possible in TS.

However if you are simply utilizing the function as a means to navigate through the tree, there are much better ways to go about it.

If you want to maintain navigability as a tree, instead of as a flat object, you can use some help from lodash as well as a utility type from type-fest to type the return value.

import _ from "lodash"
import { Get } from "type-fest"

function objectMap<
  Dict extends Record<string, any>,
  Path extends string
>(
  obj: Dict,
  path: Path
): {
    [Key in keyof Dict]: Get<Dict, `${Key}.${Path}`>
} {
  const ret = {} as Record<string, any>

  for (const key of Object.keys(obj)) {
    ret[key] = _.get(obj[key], path)
  }

  return ret as any
}

Then this will work, with no explicit typing necessary.

const myObj = {
  withString: {
    api: (id: string) => Promise.resolve(id),
    wrong_api: (id: number) => Promise.resolve(id), //This is the same as withNumber.api
    similiar_api: (some_id: string) => Promise.resolve(some_id),
    remove_api: (some_id: boolean) => Promise.resolve(some_id),
    helpers: {
      help: (id: number) => {}
    }
  },
  withNumber: {
    api: (id: number) => Promise.resolve(id),
    helpers: {
      help: (id: number) => {}
    },
    not_an_api: false,
  },
}

const mapToAPI = objectMap(myObj, 'api');
const mapToAPI = objectMap(myObj, 'helpers.help');

And if you want type inferencing on the path to ensure only valid paths, you can use another utility function, a recursive one to convert the object nodes to a union of all strings, although this only built to work on objects, and will probably explode on arrays. It will also explode and throw Type instantiation is excessively deep and possibly infinite if you have a large enough object, hence why it wasn't included originally.

import { UnionToIntersection } from "type-fest";

type UnionForAny<T> = T extends never ? 'A' : 'B';

type IsStrictlyAny<T> = UnionToIntersection<UnionForAny<T>> extends never
  ? true
  : false;

export type AcceptablePaths<Node, Stack extends string | number = ''> = 
  IsStrictlyAny<Node> extends true ? `${Stack}` :
  Node extends Function ? `${Stack}` :
  Node extends Record<infer Key, infer Item> 
    ? Key extends number | string 
      ? Stack extends ''
        ? `${AcceptablePaths<Item, Key>}`
        : `${Stack}.${AcceptablePaths<Item, Key>}`
      : ''
    : ''

For something finally, like this

function objectMap<
  Dict extends Record<string, any>,
  Path extends AcceptablePaths<Dict[keyof Dict]>,
>(
  obj: Dict,
  path: Path
): 
{
    [Key in keyof Dict]: Get<Dict, `${Key}.${Path}`>
} {/** Implementation from above **/}

View this on Code Sandbox

CodePudding user response:

The following is mostly based on @kellys' comment and requires the type K to be explicit if other properties are present, but it seems to work:

function objectMap<T, K extends keyof T[keyof T]>(
  obj: T,
  valSelector: (o: T[keyof T]) => T[keyof T][K],
): {
    [P in keyof T]: T[P][K];
} {
  const ret = {} as {
      [P in keyof T]: T[P][K];
  };

  for (const key of Object.keys(obj) as (keyof T)[]) {
    ret[key] = valSelector(obj[key]);
  }
  
  return ret;
}

const myObj = {
  withString: {
    api: (id: string) => Promise.resolve(id),
    bea: "ciao",
  },
  withNumber: {
    api: (id: number) => Promise.resolve(id),
    bea: 123
  },
}

const mapToAPI = objectMap<typeof myObj, "api">(myObj, val => val.api);

mapToAPI.withString('some string');
mapToAPI.withNumber(123)

Playground

Maybe someone is able to further improve it.

  • Related