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
andMappedFunctions
- 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
}
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