I have a class, whose instance methods are handlers, each of which represents an operation, taking a reference as the input and assign the output to the second parameter. A proxy object is generated by a third party library so that the handlers can be called directly.
type InputRef<T> = {
current: T,
};
type OutputRef<T> = {
current?: T,
};
class Original {
increment(input: InputRef<number>, output: OutputRef<number>) {
const { current: inValue } = input;
output.current = inValue + 1;
}
}
type Mapper<Fn> = Fn extends (input: InputRef<infer U>, output: OutputRef<infer V>) => unknown ? (input: U) => V : never;
type MyProxyGeneratedByThirdPartyJs = { [FnName in keyof Original]: Mapper<Original[FnName]> };
declare const proxy: MyProxyGeneratedByThirdPartyJs;
const result = proxy.increment(3); // 4
However, the mapper does not work when the handler involves generic types, e.g.,
class Original {
toBox<T>(input: InputRef<T>, output: OutputRef<{ boxed: T }>) {
const { current: inValue } = input;
output.current = { boxed: inValue };
}
}
Using the same way above, the type of proxy only involves unknown, and the generic information T is lost.
Ideally, I want proxy to be of type
{
toBox<T>(input: T): { boxed: T },
}
instead of
{
toBox(input: unknown): { boxed: unknown },
}
Is there any way achieving this?
This is not currently possible in TypeScript. In order to express what you're doing to generic functions at the type level, you'd need higher kinded types as requested in microsoft/TypeScript#1213, but these are not directly supported. The conditional type definition of Mapper<Fn> infers U as the input type and V as the output type, but there is no capacity for the compiler to see or represent any higher-order relationship between them when Fn is generic.
There is some support for transforming generic function types into other generic function types, but this only happens in very specific circumstances. The transformation needs to happen at least partially at the value level (there must be a function value which transforms one generic function type into another when called, not just the type of that function) so there will be JS code emitted. And the transformation only works for a single function at a time, so an object of functions cannot be mapped at once without losing the generics.
Still, to show that there is some ability to do this, here is how one might approach it:
function proxySingleFunction<U, V>(
f: (input: InputRef<U>, output: OutputRef<V>) => any
): (input: U) => V {
return function (input: U) {
const o: OutputRef<V> = {};
f({ current: input }, o);
const ret = o.current;
if (ret === undefined) throw new Error("OH NO");
return ret;
}
}
The type of proxySingleFunction() is
// function proxySingleFunction<U, V>(
// f: (input: InputRef<U>, output: OutputRef<V>) => any
// ): (input: U) => V
which looks similar to what you're doing with Mapper<Fn>. Then, if you call proxySingleFunction(), it will produce outputs of the relevant type:
const increment = proxySingleFunction(Original.prototype.increment);
// const increment: (input: number) => number
const toBox = proxySingleFunction(Original.prototype.toBox);
// const toBox: <T>(input: T) => { boxed: T; }
You can see that toBox is generic, as desired. Then you could pacakge these output functions in a single proxy object and use it:
const proxy = {
increment, toBox
}
console.log(proxy.increment(1).toFixed(1)) // "2.0"
console.log(proxy.toBox("a").boxed.toUpperCase()) // "A"
So that's great and it works. But it requires that you emit JavaScript for each method you want to transform. If you already have such a transformed object from a third party and just want to represent the typings, the closest you can get is to lie to the compiler via type assertions so that it thinks you're doing the transformations when you're actually not:
// pretend this function exists
declare const psf: <U, V>(
f: (input: InputRef<U>, output: OutputRef<V>) => any
) => (input: U) => V;
// pretend you're using it to create a proxy object
const myProxyType = (true as false) || {
increment: psf(Original.prototype.increment),
toBox: psf(Original.prototype.toBox)
}
// get the pretend type of that object
type MyProxyGeneratedByThirdPartyJs = typeof myProxyType;
/* type MyProxyGeneratedByThirdPartyJs = {
increment: (input: number) => number;
toBox: <T>(input: T) => { boxed: T; };
} */
I don't see this as a big win over just writing out these types manually in the first place, so I don't know that I'd recommend it. It's just the closest I can imagine getting to what you want with the language as it currently is.
Playground link to code
The below code works it just doesn't have as much type inference as I would wish (so in that regard at least it doesn't work completely as expected). Also, the above may be a weird and slightly dense title for the question, but basically I want two things that I'm not even sure if TypeScript can accomplish.
First, I want the final return type of the below chain to be a function whose return type we'll call T as what I'm expecting as the parameter to the first occurrence of modifyReturn in the given chain. As an example the type below of returnValue1 below should be T.
Secondly, I want a similar effect with the first call in the chain to modifyArgs in that I want its params to be the expected params of what we'll call the type FinalResolver which should be the function that is returned from resolve (which then ends up terminating the chain) to match the params/arguments of the function first in the chain for modifyArgs. For example the below a,b,c,d in the first modifyArgs should be basically of type Parameters<FinalResolver> which ends up being {}, {}, {db: any, response: any, request: any}, {}.
In other words (and much more briefly) first, I would like for the left hand side of the equal signs IResolver in the below code to determine the validity of the types of the first modifyArgs arguments, so a,b,c,d should be {}, {}, {db: any, response: any, request: any}, {} respectively. Second, I would like the return value of the resolve function to determine the type of the first modifyReturn's param.
This is how the type is used (really this shouldn't have to be changed [just here as an example usage]):
const temp: IResolver<{}, {}, {db: any, response: any, request: any}, {}, "baz"> = ResolveChain().modifyArgs((a,b,c,d /* should be (I'll use 'should be' to show that it's currently not working) {},{},{db: any, response: any, request: any}, {}, but is currently */) => {
return [1,2,3,4] as const;
}).modifyArgs(async (e,f,g,h /*is type (I'll use 'is type' to show it does work as intended) 1,2,3,4*/) => {
return [2,3,4,5] as const;
}).modifyReturn((returnValue1 /* should be 'foo' (T as explained above), but is currently any */) => {
return 'bar' as const;
}).modifyReturn((returnValue2 /* is type 'bar'*/) => {
return 'baz' as const;
}).resolve((foo, bar, baz, qux /* is type 2,3,4,5 */) => {
return 'foo'; // this return value is T
}); /* is type (root: {}, args: {}, context: {db: any, response: any, request: any}, {}) => 'baz' */
My utility types (this probably doesn't need to change either):
type IResolver<Root, Args, Context, Info, Return = any> = (root: Root, args: Args, context: Context, info: Info) => Return;
type If<Condition,TrueConditionType,FalseConditionType> = Condition extends true ? TrueConditionType : FalseConditionType;
The main type in question that I would like to fix to give me the two above properties is this one:
/** Allows one to chain and modify arguments and return values for a resolver. Arguments are modified before the resolver is called and return values are modified after. */
type ResolverChain<T, FinalResolver extends IResolver<any, any, any, any, T> = IResolver<any, any, any, any>, ArgsResolver extends IResolver<any, any, any, any> = IResolver<any, any, any, any>, IsFirst extends boolean = true> = FinalResolver extends IResolver<infer FinalRoot, infer FinalArgs, infer FinalContext, infer FinalInfo, infer FinalReturn> ? If<IsFirst, IResolver<FinalRoot, FinalArgs, FinalContext, FinalInfo>, ArgsResolver> extends IResolver<infer Root, infer Args, infer Context, infer Info, infer Return> ? {
/** This allows you to modify and change the type of the arguments being passed into the resolver. You can also throw an error if an argument is not
* provided or if given the arguments you can tell the User is not authorized.
*/
modifyArgs<NewRoot, NewArgs, NewContext, NewInfo>(resolveArgs: IResolver<Root, Args, Context, Info, readonly [NewRoot, NewArgs, NewContext, NewInfo] | Promise<readonly [NewRoot, NewArgs, NewContext, NewInfo]>>): ResolverChain<T, FinalResolver, IResolver<NewRoot, NewArgs, NewContext, NewInfo>, false>;
/** This allows you to change the return value of the resolve function after the resolver has been called (each subsequent call to modify return will be run in the order it is added).
* You can always throw to prevent the user from seeing something unauthorized or return null to prevent only the single field from being accessed.
*/
modifyReturn<NewReturn extends T>(resolveArgs: (resolverReturn: FinalReturn) => NewReturn): ResolverChain<T, IResolver<FinalRoot, FinalArgs, FinalContext, FinalInfo, NewReturn>, ArgsResolver, false>;
/** This will resolve the chain into a compatible resolver function. */
resolve(resolver: IResolver<Root, Args, Context, Info, T>): FinalResolver;
} : never : never;
Finally here's the implementation if you want to dig into how this function works better though it should most likely not need to be changed to answer the question:
Edited from this comment
/** */
function ResolveChain <T>(argResolvers: IResolver<any, any, any, any>[] = [], returnResolvers: ((arg:T) => T)[] = []): ResolverChain<T> {
return {
modifyArgs (fn) {
return ResolveChain([...argResolvers, fn], returnResolvers);
},
modifyReturn (fn) {
return ResolveChain(argResolvers, [...returnResolvers, fn]);
},
resolve (resolveFn) {
return async (...args) => {
for(const fn of argResolvers) {
args = await fn(...args);
}
let returnValue = await resolveFn(...args);
for(const fn of returnResolvers) {
returnValue = await fn(returnValue);
}
return returnValue;
};
}
}
}
If you noticed I tried and failed to fix the above two problems with the IsFirst generic used as a flag in combination with the If type (If<IsFirst, IResolver<FinalRoot, FinalArgs, FinalContext, FinalInfo>, ArgsResolver>) I made in order to set whether the function should get it's type from the previous chained resolver (ArgsResolver) or the FinalResolver. (I believe I will end up needing two flags and likely to Ifs if the flag idea ends up working)
Link edited to reflect this comment:
Playground Link
You and I had a long discussion in the comments about some of the hurdles and limitations of the current approach, but I'm happy to say that I have some types for you!
On the execution side of things, the applying of the resolvers, we are not able to have strict types. This is a limitation of the current implementation. We are storing arrays of resolvers and that doesn't allow us to enforce the necessary requirement that each element's return type becomes one of the input arguments for the next element.
But we can make major progress on the building side of things, where we add new resolvers and change our knowledge of the chain's types accordingly. You were inspired by a particular challenge problem to create a chainable type that updates the return type for its final get() call after each option() method call. My solution to that challenge is this:
interface Chainable<T = {}> {
option<K extends string, V>(key: K, value: V): Chainable<T & {[Key in K]: V}>
get(): T
}
With each call to option we infer generic values K and V based on the values of the function arguments. We return an object which still has the same interface Chainable but has an updated generic value reflecting the changes.
We will apply that same principle to your case but with more generics -- six of them! We have a variable Args that changes with each call to modifyArgs and a variable Value that changes with each call to modifyReturn. But when we finally resolve to a result by calling resolve() at the end of the chain, we are going to provide it with a value and args from the types that we started with, so we need to store those types. I have called them InitialValue and InitialArgs.
function ResolveChain <Context, Info, InitialValue, InitialArgs = {}, Value = InitialValue, Args = InitialArgs>(...
The values for the mutable generics, Value and Args, need to go last in the list so that we can make them optional and have them start with their initial values. The other four types are expected to be declared when you call the function to create a chain.
Were you to structure your code differently so that the arguments for the "final return" were passed in at the start rather than at the end, we could infer types rather than having to declare them explicitly.
We call this function and create a ResolverChain. I gave it the same types in the same order:
interface ResolverChain<Context, Info, InitialValue, InitialArgs = {}, Value = InitialValue, Args = InitialArgs> {
Our resolver chain interface needs to define three methods. Both modifyArgs and modifyReturn will take a function and return a new ResolverChain. I kept your definition for the function mostly the same. I've stated that the return type can be either a Promise or a value, which cleans things up a lot.
type Resolver<Value, Args, Context, Info, Return> = (
value: Value, args: Args, context: Context, info: Info
) => Return | Promise<Return>;
The function that we pass to modifyArgs takes the four standard arguments and returns a new type for args: NewArgs. The only generic that we need to infer here is the return type NewArgs. This function should act on the current args value Args, but because you call all of your argResolvers before any of your returnResolvers it receives InitialValue rather than Value.
The ResolverChain that we return has one of its generics changed. We replace Args with NewArgs and keep all the rest.
modifyArgs<NewArgs>(
resolveArgs: Resolver<InitialValue, Args, Context, Info, NewArgs>
): ResolverChain<Context, Info, InitialValue, InitialArgs, Value, NewArgs>;
modifyReturn does the same thing, but for Value. Our resolver return type gets inferred as NewValue and we return a chain with NewValue in place of Value.
There is one potential bug here which is a limitation of the current design. We know that all returnResolvers will be called with the final value of Args but when cannot possibly know what that type will be when we are still able to change it by calling modifyArgs. When creating your chain, you should make all calls to modifyArgs before modifyReturn in order to get the correct Args type.
How about we force the methods to be called in the proper order? When we return a chain from modifyReturn, we simply Omit the modifyArgs method. It is still present at runtime, but typescript doesn't know about it so we will get errors if we call modifyArgs after modifyReturn.
modifyReturn<NewValue>(
resolveArgs: Resolver<Value, Args, Context, Info, NewValue>
): Omit<ResolverChain<Context, Info, InitialValue, InitialArgs, NewValue, Args>, 'modifyArgs'>;
For the final resolve(), it doesn't make sense to me that we would pass it a function. If we want to mofify it, we can do that by calling modifyReturn right before. I wrote what makes sense to me, which is that we give it four values, the InitialValue and InitialArgs along with the immutable Context and Info, and expect it to return the current type of Value.
resolve: Resolver<InitialValue, InitialArgs, Context, Info, Value>;
The implementation cannot be perfectly typed, as I explained earlier. But here's the best we can do.
argResolvers needs any for Args and Return since these vary, while returnResolvers needs any for Value, Args, and Return.
(
argResolvers: Resolver<InitialValue, any, Context, Info, any>[] = [],
returnResolvers: Resolver<any, any, Context, Info, any>[] = []
)
Our resolve is now an aysnc method which returns a Promise that resolves to the final Value. Its args are four of the values from our generic, as explained previously.
async resolve (...args: [InitialValue, InitialArgs, Context, Info]): Promise<Value> {
async resolve (initialValue: InitialValue, initialArgs: InitialArgs, context: Context, info: Info): Promise<Value> {
For the values that we modify, those variables are going to start with their initial type. Typescript does not expect or allow that type to change, so in order to have the correct final type, we have to tell typescript to shut up. I told you the implementation was not ideal!
let args = initialArgs as unknown as Args;
let value = initialValue as unknown as Value;
Putting it all together we get:
type Resolver<Value, Args, Context, Info, Return> = (
value: Value, args: Args, context: Context, info: Info
) => Return | Promise<Return>;
interface ResolverChain<Context, Info, InitialValue, InitialArgs = {}, Value = InitialValue, Args = InitialArgs> {
modifyArgs<NewArgs>(
resolveArgs: Resolver<InitialValue, Args, Context, Info, NewArgs>
): ResolverChain<Context, Info, InitialValue, InitialArgs, Value, NewArgs>;
modifyReturn<NewValue>(
resolveArgs: Resolver<Value, Args, Context, Info, NewValue>
): Omit<ResolverChain<Context, Info, InitialValue, InitialArgs, NewValue, Args>, 'modifyArgs'>;
resolve: Resolver<InitialValue, InitialArgs, Context, Info, Value>;
};
function ResolveChain<Context, Info, InitialValue, InitialArgs = {}, Value = InitialValue, Args = InitialArgs>(
argResolvers: Resolver<InitialValue, any, Context, Info, any>[] = [],
returnResolvers: Resolver<any, any, Context, Info, any>[] = []
): ResolverChain<Context, Info, InitialValue, InitialArgs, Value, Args> {
return {
modifyArgs(fn) {
return ResolveChain([...argResolvers, fn], returnResolvers);
},
modifyReturn(fn) {
return ResolveChain(argResolvers, [...returnResolvers, fn]);
},
async resolve(initialValue: InitialValue, initialArgs: InitialArgs, context: Context, info: Info): Promise<Value> {
let args = initialArgs as unknown as Args;
for (const fn of argResolvers) {
args = await fn(initialValue, args, context, info);
}
let value = initialValue as unknown as Value;
for (const fn of returnResolvers) {
value = await fn(value, args, context, info);
}
return value;
}
}
}
Play around with the various callbacks on the Typescript Playground:
const temp2 = ResolveChain<{ db: any, response: any, request: any }, {}, 'baz', {initialArgument: number}>()
.modifyArgs((root, args) => ({
...args,
hello: "world",
}))
.modifyReturn((ret) => ({ key: ret }))
.modifyReturn((ret, args) => ({
...ret,
fromArgs: args.hello + args.initialArgument,
}))
.resolve('baz', {initialArgument: 5}, { db: "", response: "", request: "" }, {});
// resolves to { fromArgs: string; key: "baz"; }
note:
This might not be exactly what you intended. I didn't see your change from returnValue = await fn(...args) to returnValue = await fn(returnValue) until after I started writing this. I remain highly confused in the section on argResolvers whether args refers to the second of four arguments or to the array of all four, since you seem to be using it as both simultaneously. So modify this code as you need to.