When using a function as a first-class value in Typescript, is there a way to pass Generic bindings?

226 Views Asked by At

I am attempting to wrap typescript functions, returning new functions that add extra behaviour. For example, in the minimal repro below, (see playground) the function after wrapping always returns a Promise and its arguments and return value are console-logged.

However, I have found that if the original function has any Generic typing, I don't know how to bind that Generic type so it is available on the wrapped function even if it is well-known in the calling scope.

The problem is demonstrated by const delayedValue in the below script. Typescript seems to believe this can only be unknown.

Is there any way for e.g. wrappedDelay to be defined such that the generic V parameter can make it through and hence inform what return type we can expect from the wrappedDelay function.

export type AnyFn = (...args: any[]) => any;

/** If sync function - ReturnType. If async function - unwrap Promise of ReturnType */
export type Result<Fn extends AnyFn> = Fn extends () => Promise<infer Promised>
  ? Promised
  : ReturnType<Fn>;

/** Async function with same params and return as Fn, regardless if Fn is sync or async  */
export type WrappedFn<Fn extends AnyFn> = (...parameters:Parameters<Fn>) => Promise<Result<Fn>>

/** Construct wrapped function from function reference */
function wrapFn<Fn extends AnyFn>(fn:Fn) : WrappedFn<Fn>{
    return async (...parameters:Parameters<Fn>) => {
        console.log(`Parameters are ${parameters}`);
        const result = await fn(...parameters);
        console.log(`Result is ${result}`);
        return result;
    }
}

function sum(a:number, b:number){
    return a + b;
}

function delay<V>(value: V, ms:number){
    return new Promise<V>((resolve, reject) => {
        setTimeout(() => resolve(value), ms)
    })
}

const wrappedSum = wrapFn(sum)

const wrappedDelay = wrapFn(delay)

async function example() {
    const sum = await wrappedSum(3,4)
    const delayedValue = await wrappedDelay("hello", 1000)
}  
1

There are 1 best solutions below

4
On BEST ANSWER

TypeScript does not have direct support for "higher kinded types" of the sort requested in microsoft/TypeScript#1213, so there's no general way to manipulate generic functions programmatically in such a way as express a purely type-level transformation of the function type F to function type G where any generic type parameters on F get transferred to G.

Luckily, since TypeScript 3.4, there is support for higher order type inference from generic functions where you can get behavior like this for particular functions at the value-level, such as wrapFn() acting on input functions which are generic. So if f is a function of type F, and const g = wrapFn(f), it is possible to write wrapFn() so that g is of type G, where any generic type parameters of F have been transferred to G.

You can read microsoft/TypeScript#30215 for how this higher order type inference works and the rules you need to follow to get this behavior. In particular, this feature expects that you will have separate type parameters for the function arguments (e.g., A extends any[]) and for the return (e.g., R). It does not work with a generic function type like F extends (...args: any[])=>any where you use conditional utility types like Parameters<F> or ReturnType<F> to extract argument types or return types from it.

So, if you change WrappedFn and wrapFn to the folllowing:

type WrappedFn<A extends any[], R> = (...parameters: A) => Promise<R>

function wrapFn<A extends any[], R>(fn: (...args: A) => R): WrappedFn<A, R> {
    return async (...parameters: A) => {
        console.log(`Parameters are ${parameters}`);
        const result = await fn(...parameters);
        console.log(`Result is ${result}`);
        return result;
    }
}

Then things behave as you expect, at least for the example code in your question:

const wrappedDelayValue = wrapFn(delay);
// const wrappedDelayValue: <V>(value: V, ms: number) => Promise<Promise<V>>

const delayedValue = await wrappedDelayValue("hello", 1000);
// const delayedValue: string    
console.log(delayedValue.toUpperCase()) // no compiler error now

Playground link to code