Ashley Reid
Ashley Reid

Reputation: 488

TypeScript function signature identical to passed in function's signature

I'm working on a class to which the user passes in a method that takes a user-defined argument, or no argument. The class exposes a "call" method that must be called with the argument requested in the user's method, performs various operations, then calls the user's method and returns the result.

This works fine if the user's function has an argument, but it breaks if the user's function doesn't use an argument.

Here's my current code (I keep trying different variations but haven't figured out the correct one yet):

class Test<T, TResult> {
  methodToRun: (data?: T) => TResult;

  constructor(methodToRun: (data?: T)=>TResult){
    this.methodToRun = methodToRun;
  }

  call(data: T) {
    // do some stuff, then...
    return data === undefined ? this.methodToRun(data) : this.methodToRun();
  }
}

const test = new Test(function(data: string){
  return 1;
});

test.call("");
test.call(); // should be a signature error (currently is)

const test2 = new Test(function(data: number){
  return 1;
});

test2.call(1);

const test3 = new Test(function(data: {foo: string, bar: number}){
  return 1;
});

test3.call({foo: "", bar: 1});


const test4 = new Test(function(){
 return 1;
});

test4.call(); // should be allowed (currently errors "Supplied Paramaters do not match any signature of call target")
test4.call({}); // should not be allowed (currently is)

One of my attempted variations was to adjust the signature of call:

call()
call(data: T) 
call(data?: T) {

However that caused both test.call() and test4.call() to be allowed. How do I get typescript to ensure that the signature of call always matches the signature of the user's function?

Upvotes: 0

Views: 699

Answers (2)

y2bd
y2bd

Reputation: 6456

Here's a way to do it with new ():

interface OneOrNoArgFunc<T, TResult> {
    (arg?: T): TResult;
}

class Test<T, TResult, TF extends OneOrNoArgFunc<T, TResult>> {
    methodToRun: TF;

    constructor(methodToRun: TF) {
        this.methodToRun = methodToRun;
    }

    call = ((arg?: T): TResult => {
        return this.methodToRun(arg);
    }) as TF;
}

let a = new Test((foo: string) => foo + "hello");
let b = new Test(() => 33);
let c = new Test((foo: string, bar: number) => foo + bar); // ERR, cannot have more than one argument

a.call("argument"); // OK
a.call(33); // ERR, wrong type
a.call(); // ERR, need argument

b.call();
b.call("argument"); // ERR, no arguments

This does require the explicit as TF cast, and the generics don't type very helpfully: Typescript marks a as a: Test<{}, {}, (foo: string) => string>, seems that the inferred generics aren't passed through the function.


Edit

You can keep the parameter and return type generic parameters by using an intersection type:

constructor(methodToRun: ((arg?: T) => TResult) & TF) {
    this.methodToRun = methodToRun;
}

/* ... */

// a: Test<string, string, (foo: string) => string>
let a = new Test((foo: string) => foo + "hello");

// b: Test<{}, number, () => number>
let b = new Test(() => 33);

Upvotes: 1

Nitzan Tomer
Nitzan Tomer

Reputation: 164129

This is a bit verbose but it works with your requierments:

interface TestWithParams<T, TResult> {
    call(data: T): TResult;
}

interface TestWithoutParams<T, TResult> {
    call(): TResult;
}

interface TestConstructor {
    new<T, TResult>(methodToRun: () => TResult): TestWithoutParams<T, TResult>;
    new<T, TResult>(methodToRun: (data: T) => TResult): TestWithParams<T, TResult>;
}

class Test<T, TResult> {
    methodToRun: (data?: T) => TResult;

    constructor(methodToRun: (data?: T) => TResult) {
        this.methodToRun = methodToRun;
    }

    call(data?: T) {
        // do some stuff, then...
        return data === undefined ? this.methodToRun(data) : this.methodToRun();
    }
}

const test = new (Test as TestConstructor)(function (data: string) {
    return 1;
});

test.call("");
test.call(); // error

const test2 = new (Test as TestConstructor)(function (data: number) {
    return 1;
});

test2.call(1);

const test3 = new (Test as TestConstructor)(function (data: { foo: string, bar: number }) {
    return 1;
});

test3.call({foo: "", bar: 1});

const test4 = new (Test as TestConstructor)(function () {
    return 1;
});

test4.call(); // ok
test4.call({}); // error

(code in playground)


Edit

The only way that I can think of of doing it without (Test as TestConstructor) is to use a "factory method":

class Test<T, TResult> {
    methodToRun: (data?: T) => TResult;

    static create<T, TResult>(methodToRun: () => TResult): TestWithoutParams<T, TResult>;
    static create<T, TResult>(methodToRun: (data: T) => TResult): TestWithParams<T, TResult>;
    static create<T, TResult>(methodToRun: (data?: T) => TResult): Test<T, TResult> {
        return new Test(methodToRun);
    }

    private constructor(methodToRun: (data?: T) => TResult) {
        this.methodToRun = methodToRun;
    }

    call(data?: T) {
        // do some stuff, then...
        return data === undefined ? this.methodToRun(data) : this.methodToRun();
    }
}

And then:

const test = Test.create(function (data: string) {
    return 1;
});

...

(code in playground)

That seems very elegant, but this introduces an additional function to Test which will also exist in the compiled js.
The previous way is strictly typescript and doesn't result in more "bloated" js.

Upvotes: 0

Related Questions