To begin with, specifying the type of obj
as
Record<string, (context: MyContext, ...args: any[]) => void>
will result in the loss of detailed information about method names and arguments in the initializer. If you want
convert(context, obj)
to recognize
log
and
add
, it's better not to provide an annotation for
obj
and let the compiler infer its type:
const obj = {
log(ctx: MyContext, msg: string) {
console.log(msg);
},
add(ctx: MyContext, value: number) {
ctx.store.push(value);
}
};
If calling convert(context, obj)
doesn't trigger any errors from the compiler, then the type of obj
is appropriate.
Next, for convert()
to strongly type its output, it needs to be generic not only in T
, the type of context
, but also in a type parameter related to the mapping of method names and arguments in objectWithFunctions
. I suggest introducing a new type parameter A
, representing an object type with keys as method names and values as argument lists excluding the initial context
parameter of type T
.
For instance, for obj
, A
would be defined as:
{
log: [msg: string];
add: [value: number];
}
While you won't directly use a value of type
A</code, it can be inferred from the input provided to <code>objectWithFunctions
, making it convenient to represent both
objectWithFunctions
and the return type using
A
. The typings are as follows:
const convert = <T, A extends Record<keyof A, any[]>>(
context: T,
objectWithFunctions: { [K in keyof A]: (context: T, ...rest: A[K]) => void }
) => {
const result = {} as { [K in keyof A]: (...args: A[K]) => void };
(Object.keys(objectWithFunctions) as Array<keyof A>)
.forEach(<K extends keyof A>(functionName: K) => {
const func = objectWithFunctions[functionName];
result[functionName] = (...args) => func(context, ...args);
});
return result;
};
Therefore, the type of objectWithFunctions
is a mapped type where the array A[K]
associated with each property K</code of <code>A
is transformed into a function type comprising an argument of type
T</code followed by a rest argument of type <code>A[K]
. It happens because this type conforms to the pattern
{[K in keyof A]...}
, which is a homomorphic mapped type allowing efficient inference from such types. The return type, as indicated in the annotation of
result
, mirrors the same mapped type but without the initial
T
argument.
Some type assertions were necessary within the implementation of the function to inform the compiler about certain values' types. Since the initial value of result
is an empty object, an assertion was required to ensure that it would match the final type. Additionally, given that the return type of Object.keys()
is simply
string[]</code, another assertion ensured that it returns an array of <code>keyof A
.
Let's put this approach to the test:
const context: MyContext = { store: [] };
Annotating context
as
MyContext</code here is essential to prevent the compiler from assuming that <code>store
will always be an empty array (
never[]
). Here's how we apply this concept:
const newObj = convert(context, obj);
You can leverage Quickinfo via IntelliSense to observe that invoking convert()
infers MyContext
for
T</code and the specified <code>{ log: [msg: string]; add: [value: number];}
type for
A
:
/* const convert: <MyContext, {
log: [msg: string];
add: [value: number];
}>(context: MyContext, objectWithFunctions: {
log: (context: MyContext, msg: string) => void;
add: (context: MyContext, value: number) => void;
}) => {
...;
} */
Upon inspecting myObj
, you'll notice that it aligns perfectly with your desired type:
/* const newObj: {
log: (msg: string) => void;
add: (value: number) => void;
} */
This indicates that the operation functions as intended:
newObj.log('some message');
newObj.add(12);
newObj.add(); // error
newObj.foo(); // error
console.log(newObj, context.store); // {}, [12]
Access the link to play around with the code on TypeScript Playground.