Home > OS >  Enforcing types after mapping variable keys
Enforcing types after mapping variable keys

Time:03-15

I want to map an object and preserve types. I know how to do this with a single object, as such:

type MapTo<T, U> = {
  [P in keyof T]: U
}

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

export const mapObject = <ObjectType extends object, MapType>(
  value: ObjectType,
  map: (v: ValueOf<ObjectType>) => MapType,
): MapTo<ObjectType, MapType> => {
  const mappedValue = {} as MapTo<ObjectType, MapType>;

  // Can't use `Object.keys` because it returns string[]
  for (const i in value) {
    if (value.hasOwnProperty(i)) {
      mappedValue[i] = map(value[i]);
    }
  }

  return mappedValue;
};

a can have a varying number of keys, but the values are the same type. Then, if I do the following:

const a = { x: 1, y: 2, z: 3 };
const b = mapObject(a, (i) => i * 2);

b still has the key completion (i.e., typing b. will autosuggest the keys of the object, x and y).


Now, say I have the following code (apologies for the length!):

type MapTo<T, U> = {
  [P in keyof T]: U;
}
type Map<T> = {
  [P in keyof T]: T[P];
}

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

export const mapObjectTo = <ObjectType extends object, MapType>(
  value: ObjectType,
  map: (v: ValueOf<ObjectType>) => MapType,
): MapTo<ObjectType, MapType> => {
  const mappedValue = {} as MapTo<ObjectType, MapType>;

  // Can't use `Object.keys` because it returns string[]
  for (const i in value) {
    if (value.hasOwnProperty(i)) {
      mappedValue[i] = map(value[i]);
    }
  }

  return mappedValue;
};

export const mapObject = <ObjectType extends object>(
  value: ObjectType,
  map: (v: ValueOf<ObjectType>) => ObjectType[Extract<keyof ObjectType, string>],
): Map<ObjectType> => {
  const mappedValue = {} as Map<ObjectType>;

  // Can't use `Object.keys` because it returns string[]
  for (const i in value) {
    if (value.hasOwnProperty(i)) {
      mappedValue[i] = map(value[i]);
    }
  }

  return mappedValue;
};

const groups = {
  a: {
    x: 1,
  },
  b: {
    y: 2
  },
};
const mapGroupedObject = <ObjectType extends object, ReturnType>(
  groupedObject: ObjectType,
  map: (i: ValueOf<ObjectType[Extract<keyof ObjectType, string>]>) => ReturnType
) => {
  const mapGroups = <T extends object>(item: T) => {
    return mapObjectTo(item, map);
  };
  const mappedGroups = mapObject(groups, mapGroups);

  return mappedGroups;
};

const mappedGroups = mapGroupedObject(groups, () => 2);
mappedGroups.a.x;

Essentially, mapGroupedObject should be made up of n keys, which are all objects. For each of those objects, I want to map the values with a callback, and preserve the autocompletion

Again, mappedGroups has autocompletion, so types seem to be resolving the structure, but the types don't seem to work here for mapGroups argument in const mappedGroups = mapObject(groups, mapGroups); in mapGroupedItems. Also, I can't use a callback with a parameter for mapping because the types are unhappy when I do so

In fact, most of the types I've inferred from the previous functions mapObject and mapObjectTo. I'm really not sure what the hell ObjectType[Extract<keyof ObjectType, string>] really means, it was presented by static code analysis in VSCode.

What do I need to do to satisfy the type compiler? Also, is there a way to generalise mapObject and mapObjectTo into a single function, if there's only one type in the generic?


As per request to the comment, for the groups object in the second code block:

const groups = {
  a: {
    x: 1,
  },
  b: {
    y: 2
  },
};;

const mappedGroups = mapGroupedObject(groups, (item) => item * 2);
/* Outputs:
{
  a: {
    x: 2,
  },
  b: {
    y: 4
  },
}

And very importantly, preserves the structure in the type - 
that is: 
- typing `mappedGroups.` should suggest `mappedGroups.a` and `mappedGroups.b`, 
- typing `mappedGroups.a.` should suggest `mappedGroups.a.x`
*/

CodePudding user response:

If we define a nested record type:

type GroupType = Record<string, Record<string, unknown>>;

a type to infer the unknown types of the "leaf" values from a GroupType:

type ValOf<Groups extends GroupType> =
  Groups extends Record<string, Record<string, infer V>> ? V : never;

and a type to map these "leaf" values to a different type:

type MapRet<Groups extends GroupType, Ret> =
  { [KG in keyof Groups]: { [KGV in keyof Groups[KG]]: Ret } } & {}

(here we use & {} to expand the type in intellisense... it can be omitted if that doesn't bother you)

Now we can define a function to implement your needs:

function map<G extends GroupType, Ret>(g: G, m: (v: ValOf<G>) => Ret): 
  MapRet<G, Ret> {
   return Object.fromEntries(
     Object.entries(g).map(
       ([k, v]) => [
         k,
         Object.fromEntries(
           Object.entries(v).map(([ki, vi]) => [ki, m(vi as ValOf<G>)])
         )
       ])
   ) as MapRet<G, Ret>
}

and call it:

const groups = {
  a: {
    x: 1,
  },
  b: {
    y: 2
  },
};
const test = map(groups, v => `hello${v}`);
/*
typeof test:
{
    a: {
        x: string;
    };
    b: {
        y: string;
    };
}
*/
console.log(test);

for output of

{
  "a": {
    "x": "hello1"
  },
  "b": {
    "y": "hello2"
  }
}

Playground Link

  • Related