Home > Net >  How to use Typescript generics to correctly map between keys and values with indirection?
How to use Typescript generics to correctly map between keys and values with indirection?

Time:10-21

I've been trying to wrap my head around how to write a strongly typed mapper that will map indirectly using one object's values to pick methods matching another object's keys.

The actual mapper in the code below is not mine, it's external and javascript based. Since I cannot modify this, I have implemented a typed wrapper called mapTyped in the code below.

This is the best attempt I've managed to do, based on other sources found on the topic.

It performs well on the following goals

  • ensures matching function definitions between Repository and MappedFunctions
  • prevents writing the wrong name as lookup, for example mapTyped<MappedFunctions>()({baz: "bazIntrnal"}) will give a compiler error due to spelling
  • works perfectly if only one item is provided in the map, the function arguments and return type will be correctly inferred

However, it does not work if I provide more than one item in the map.

Then all the mapped functions will have a union of all the mapped functions' definitions.

How do I modify mapTyped so that it will return unique values per "row" in the Record matching the key? (and not a union of values as it is now)

See the comments in the minimal repro code below, and here's a playground that demonstrates my issue.

// https://github.com/sindresorhus/type-fest/blob/main/source/value-of.d.ts
type ValueOf<ObjectType, ValueType extends keyof ObjectType = keyof ObjectType> = ObjectType[ValueType];

type MethodType = (...args: any[]) => any;
type Repository = Record<string, MethodType>;

// all available functions
const functions: Repository & MappedFunctions = {
  fooInternal: (fooArg: number) => 1   fooArg,
  barInternal: (barArg: string) => "barArg: "   barArg,
  bazInternal: (bazArg: boolean) => ["some", "array", "with", bazArg]
}

// our mapped functions
type MappedFunctions = {
  fooInternal: (fooArg: number) => number,
  bazInternal: (bazArg: boolean) => (string|boolean)[]
}

// mapper for array or object 
// (actual implementation is external to my project and I simply want to write a typed wrapper for it)
const mapper = (map: Record<string, string>|string[]) => {
  const ret: Repository = {};
  if (Array.isArray(map)) {
    map.forEach(m => ret[m] = functions[m]);
  } else {
    Object.entries(map).forEach(m => ret[m[0]] = functions[m[1]]);
  }
  return ret;
}

// my typed mapper wrapper
function mapTyped<S extends Record<SK, SM>, SK extends string = keyof S & string, SM extends MethodType = MethodType>():
  <M extends Record<MK, SK>, MK extends string = string>(
  map: M
) => {
  [K in ValueOf<M> as keyof M]: S[K];
} { return (map: any) => mapper(map) as any; }

// ACTUAL:
// const methods: {
//     otherMethod: () => number;
//     baz: ((fooArg: number) => number) | ((bazArg: boolean) => (string | boolean)[]);
//     foo: ((fooArg: number) => number) | ((bazArg: boolean) => (string | boolean)[]);
// }
// EXPECTED:
// const methods: {
//     otherMethod: () => number;
//     baz: ((bazArg: boolean) => (string | boolean)[]);
//     foo: ((fooArg: number) => number);
// }
const methods =
{
  // THIS WORKS
  //...mapTyped<MappedFunctions>()({baz: "bazInternal"}),
  // THIS FAILS
  ...mapTyped<MappedFunctions>()({baz: "bazInternal", foo: "fooInternal"}),
  otherMethod: () => 42
};

// outputs: ["baz", "foo", "otherMethod"] 
console.log(Object.keys(methods));

// outputs: ["some", "array", "with", true] 
// but gives ts error: (property) baz: (arg0: never) => number | (string | boolean)[]
// Argument of type 'boolean' is not assignable to parameter of type 'never'.(2345)
console.log(methods.baz(true));

EDIT: SOLUTION thanks to the answer by @captain-yossarian this is the final code I ended up using for the typed mapper

const mapTyped = <
  S extends Record<keyof S & string, MethodType>,
  Keys extends keyof S & string = keyof S & string,
>() => {
  function anonymous<Prop extends string, Mp extends Record<Prop, Keys>>(map: Mp): {
    [P in Keys as GetKeyByValue<Mp, P>]: S[P]
  }
  function anonymous<Prop extends string, Mp extends Record<Prop, Keys>>(map: Mp) {
    return mapper(map)
  }
  return anonymous
}

.. and an updated playground

BONUS: This is a gist of the code that uses this typed wrapper for Vuex 4 and Vue 3.

CodePudding user response:

Please see related answers: here and here

Now, hover the mouse over methods.baz. You will see that baz method is a union of two functions: ((fooArg: number) => number) | ((bazArg: boolean) => (string | boolean)[]).

If you read related answers, you know that calling a function which is a union of several functions causes arguments intersection.

In your case, it is number & boolean === never.

Let's start from mapper.

I think it is worth splitting mapper into two strategies mapperArray and mapperObject:

const functions = {
  fooInternal: (fooArg: number) => 1   fooArg,
  barInternal: (barArg: string) => "barArg: "   barArg,
  bazInternal: (bazArg: boolean) => ["some", "array", "with", bazArg]
} as const

type FunctionsKeys = keyof typeof functions;

type MappedFunctions = {
  fooInternal: (fooArg: number) => number,
  bazInternal: (bazArg: boolean) => (string | boolean)[]
}

type Values<T> = T[keyof T]

type MethodType = (...args: any[]) => any;
type Repository = Record<string, MethodType>;

const mapperArray = <Elem extends FunctionsKeys, Elems extends Elem[]>(map: [...Elems]) =>
  map.reduce<Repository>((acc, elem) => ({
    ...acc,
    [elem]: functions[elem]
  }), {})

const mapperObject = <
  Key extends PropertyKey,
  Value extends FunctionsKeys
>(map: Record<Key, Value>) =>
  (Object.entries(map) as Array<[Key, Value]>)
    .reduce<Repository>((acc, elem) => ({
      ...acc,
      [elem[0]]: functions[elem[1]]
    }), {})

const mapper = <Key extends FunctionsKeys>(map: Record<string, Key> | Key[]) =>
  Array.isArray(map)
    ? mapperArray(map)
    : mapperObject(map)

I know, that mapper comes from third party library, feel free to use just final version. I just not sure where is the edge of imported functions and your own, hence I wrote/typed everything.

Since we have our main utils setup, we can type our main function:


type GetKeyBaValue<Obj, Value> = {
  [Prop in keyof Obj]: Obj[Prop] extends Value ? Prop : never
}[keyof Obj]

type Test = GetKeyBaValue<{ age: 42, name: 'Serhii' }, 42> // age

const mapTyped = <
  Keys extends keyof MappedFunctions,
  Mapper extends <Arg extends Record<string, Keys> | Array<Keys>>(arg: Arg) => Repository
>(mpr: Mapper) => {
  function anonymous<Prop extends string, Mp extends Record<Prop, Keys>>(map: Mp): {
    [P in Keys as GetKeyBaValue<Mp, P>]: MappedFunctions[P]
  }
  function anonymous<Prop extends string, Mp extends Record<Prop, Keys>>(map: Mp) {
    return mpr(map)
  }
  return anonymous
}

GetKeyBaValue - returns key by value.

As you might have noticed, mapTypes is curried function. Furthermore, it returns overloaded function anonymous. It is possible to make it arrow but then you need to use type assertion as. I think that overloading is safer, however it has own drawback because it behaves bivariantly. So it is up to you.

WHole code:

const functions = {
  fooInternal: (fooArg: number) => 1   fooArg,
  barInternal: (barArg: string) => "barArg: "   barArg,
  bazInternal: (bazArg: boolean) => ["some", "array", "with", bazArg]
} as const

type FunctionsKeys = keyof typeof functions;

type MappedFunctions = {
  fooInternal: (fooArg: number) => number,
  bazInternal: (bazArg: boolean) => (string | boolean)[]
}

type Values<T> = T[keyof T]

type MethodType = (...args: any[]) => any;
type Repository = Record<string, MethodType>;

const mapperArray = <Elem extends FunctionsKeys, Elems extends Elem[]>(map: [...Elems]) =>
  map.reduce<Repository>((acc, elem) => ({
    ...acc,
    [elem]: functions[elem]
  }), {})

const mapperObject = <
  Key extends PropertyKey,
  Value extends FunctionsKeys
>(map: Record<Key, Value>) =>
  (Object.entries(map) as Array<[Key, Value]>)
    .reduce<Repository>((acc, elem) => ({
      ...acc,
      [elem[0]]: functions[elem[1]]
    }), {})

const mapper = <Key extends FunctionsKeys>(map: Record<string, Key> | Key[]) =>
  Array.isArray(map)
    ? mapperArray(map)
    : mapperObject(map)

type GetKeyBaValue<Obj, Value> = {
  [Prop in keyof Obj]: Obj[Prop] extends Value ? Prop : never
}[keyof Obj]

type Test = GetKeyBaValue<{ age: 42, name: 'Serhii' }, 42> // age

const mapTyped = <
  Keys extends keyof MappedFunctions,
  Mapper extends <Arg extends Record<string, Keys> | Array<Keys>>(arg: Arg) => Repository
>(mpr: Mapper) => {
  function anonymous<Prop extends string, Mp extends Record<Prop, Keys>>(map: Mp): {
    [P in Keys as GetKeyBaValue<Mp, P>]: MappedFunctions[P]
  }
  function anonymous<Prop extends string, Mp extends Record<Prop, Keys>>(map: Mp) {
    return mpr(map)
  }
  return anonymous
}


const methods = {
  ...mapTyped(mapper)({ baz: "bazInternal", foo: "fooInternal" }),
  otherMethod: () => 42
};

methods.baz(true) // ok
methods.foo(42) // ok

methods.otherMethod() // number

Playground

  • Related