Search code examples
typescriptfunctionclassgenerics

Wrap generic function


Is there a way to wrap a generic function inside a class and preserve the generics?

Here is my idea:

const func = <G extends string>(p: G) => p;

class Func<A extends any[], R> {
  constructor(private readonly func: (...args: A) => R) {}

  public call(...args: A): R {
    return this.func(...args);
  }

  public catch(cb: (error: unknown) => void) {}

  // more methods ...
}

Now if I use it the generics are obviously lost:

const use = new Func(func); // =>  Func<[p: any], any>

I have tried this, which preserves the functions type as a whole:

class Func<F extends (...args: A) => R, A extends any[], R> {
  constructor(private readonly func: F) {}

  public call(...args: A): R {
    return this.func(...args);
  }

  public catch(cb: (error: unknown) => void) {}
}

But even then, if I try to use the call method, generics (and even parameter types) are lost:

use.call('str'); // => any

To summarize: I need a way to wrap a generic function in a class and preserve its generics for the class methods. I am open for any creative workaround.

Playground: link


Solution

  • Unfortunately TypeScript doesn't have much support for the sort of higher order manipulation of generic function types you're doing. The support it does have, as implemented in microsoft/TypeScript#30215 is quite limited, and doesn't apply when you are returning an object with the intended generic function as a method. So we can't leverage that, especially in the form where the function parameters and return type have separate type parameters (since that would break the generics).

    Seeing as all you're trying to do is say that new Func(f).call is the same exact type as f, we can work around it by making Func generic in the type of f, and declaring call to be that type. You can't do that with method syntax (there's an open feature request at microsoft/TypeScript#22063 to allow annotating a function statement and, presumably, a method, so it's a missing feature). You can use declaration merging to declare it as if it were a property, and then manually add call to Func.prototype, with some workaround to allow you to access a TypeScript-private property inside of it (see microsoft/TypeScript#19335):

    // class is generic in type of `func`:
    class Func<F extends (...args: any) => any> {
      constructor(private readonly func: F) { }
      public catch(cb: (error: unknown) => void) { }
    }
    
    // merge in `call` as same type
    interface Func<F> {
      call: F;
    }
    // implement `call`
    Func.prototype.call = function (this: Func<any>, ...args: any) {
      return this["func"](...args); 
      //         ^^^^^^^^ brackets let you access private member
    }
    

    And now if we try it we see the desired behavior:

    const func = <G extends string>(p: G) => p;
    const use = new Func(func);
    const s = use.call('str');
    //    ^? const s: 'str'
    

    So it's possible to do what you asked for in the question... but the solution is fragile. Only because call is exactly the same type as this.func does it work; if you needed something even slightly different that would need the type to be transformed in some way (e.g., the function returns something like {prop: this.func(...args)}, or accepts an extra or fewer argument compared to this.func, etc) then you'd end up with broken generics again.

    Playground link to code