Home > other >  Mapping object with correct type narrowing in mapper function
Mapping object with correct type narrowing in mapper function

Time:11-03

I'm porting a project to typescript and everything went fine except for this simple utility function:

function mapObject(obj, mapperFn) {
  return Object.fromEntries(
    Object.entries(obj).map(([key, value]) => mapperFn(key, value))
  );
}

It is used for transforming keys and values of an object, like so:

let obj = { a: 1, b: 2 };
let mappedObj = mapObject(obj, (key, value) => [
  key.toUpperCase(),
  value   1,
]);

mappedObj; // { A: 2, B: 3 }

edit For the sake of clarity, the output object's entry values aren't necessarily identical to the ones in input object, e.g. the above example could've transformed the numbers into a string to result in { A: '2', B: '3' }, or just return null for each of them: { A: null, B: null }. Also, I only consider string keys in both input and output objects for simplicity and because that's how it is used in our project.

I'd like mapperFn to retain the object entries' value types, supporting type narrowing by key, just the same as GetEntryOf does in the example below:

type GetEntryOf<T> = {
  [K in keyof T]: [K, T[K]];
}[keyof T];

interface TestI {
  str: string;
  num: number;
}

let entry: GetEntryOf<TestI>;

if (entry[0] === 'str') {
  entry[1]; // string
} else if (entry[0] === 'num') {
  entry[1]; // number
} else {
  entry; // never
}

This is the closest I've got, unfortunately I needed to transition to using key in obj operator:

function mapObject<K>(
  obj: K,
  mapperFn: (key: keyof K, value: K[typeof key]) => [string, unknown]
): Record<string, unknown> {
  const newObj = {} as Record<string, unknown>;

  for (const key in obj) {
    if (Object.prototype.hasOwnProperty.call(obj, key)) {
      const value = obj[key];
      const [newKey, newValue] = mapperFn(key, value);

      newObj[newKey] = newValue;
    }
  }

  return newObj;
}

interface TestI {
  str: string;
  num: number;
}

let test: TestI;

mapObject(test, (key, value) => {
  if (key === 'str') {
    return [key, value]; // value should be string
  } else if (key === 'num') {
    return [key, value]; // value should be number
  } else {
    return [key, value]; // value should be never
  }
});

As you can see, value's type does not get narrowed correctly:

mapObject type narrowing issue

Considering that GetEntryOf can do it, I'd think that it should be possible to do the same inside mapperFn. Is this even possible in current typescript, and if so, what is the appropriate way to do it?

Notes: The working type narrowing example is based on this SO answer. I'm aware of and okay with the constraint mentioned in the same answer: "it is not guaranteed that the value does not also have other properties".

CodePudding user response:

I think you are looking for something like this:

type Values<T> = T[keyof T]

type MakeTuple<T> = Values<{
  [Prop in keyof T]: { key: Prop, value: T[Prop] }
}>

type Output<T> = {
  [Prop in keyof T]: [Prop, T[Prop]]
}


const mapObject = <K,>(
  obj: K,
  mapperFn: (params: MakeTuple<K>) => [MakeTuple<K>['key'], MakeTuple<K>['value']]
) => (Object.keys(obj) as Array<keyof K>)
  .reduce((acc, key) => {
    const [newKey, newValue] = mapperFn({ key, value: obj[key] });

    return {
      ...acc,
      [newKey]: newValue
    }

  }, {} as Output<K>)


interface TestI {
  str: string;
  num: number;
}


declare let test: TestI;


const result = mapObject(test, (obj) => {
  // const key = params[0]
  // const value = params[1]
  if (obj.key === 'str') {
    return [obj.key, obj.value]
  } else if (obj.key === 'num') {
    return [obj.key, obj.value];
  } else {
    throw new Error();
  }
});

result.num // ['num', number]

Playground

I'm not sure why you are using Object.prototype.hasOwnProperty.call(obj, key) but you can use :

const hasProperty = <Obj, Prop extends string>(obj: Obj, prop: Prop)
  : obj is Obj & Record<Prop, unknown> =>
  Object.prototype.hasOwnProperty.call(obj, prop);

instead. You can even overload hasOwnProperty like I did here

CodePudding user response:

Based on captain-yossarian's answer here is the end result that will be implemented:

type Values<T> = T[keyof T];

type MakeTuple<T> = Values<{
  [Prop in keyof T]: { key: Prop; value: T[Prop] };
}>;

interface MapObjectMapper<K> {
  (params: MakeTuple<K>): [string, unknown];
}

export default function mapObject<K>(
  obj: K,
  mapperFn: MapObjectMapper<K>
): Record<string, unknown> {
  return Object.fromEntries(
    (Object.keys(obj) as Array<keyof K>).map((key) =>
      mapperFn({ key, value: obj[key] })
    )
  );
}

I've extracted the mapper function signature for the sake of legibility and replaced output type with [string, unknown] to account for the edit note in the question (using only string keys) and the fact that the return value indeed can not be known.

  • Related