Search code examples
javascripttypescriptfunctiontypesjsdoc

How to create a `Callable` object in JavaScript, without inheriting from `Function` and with no type errors?


As stated in this blog post and in this stack overflow post, it is possible to make an object callable as follows:

class Callable extends Function {
    constructor() {
        super();
        var closure = function (...args) { return closure._call(...args) }
        return Object.setPrototypeOf(closure, new.target.prototype)
    }

    _call(...args) {
        console.log(this, args)
    }
}

However, this causes problems with CSP (Content-Security-Policy), like when running in a Chrome extension. It is possible to remove the extends Function and super() calls, but this results in type errors. For my use-case, I unfortunately can't ignore this. If there's a way to override the type errors (e.g., using JSDoc), that could also work.

Example usage should look something like this:

class Pipeline extends Callable {
    _call(...args) {
        // Override _call method here
    }
}

// Create new pipeline
const pipe = new Pipeline();

// Call the pipeline
// NOTE: This is where the type error happens if the class is not inherited from `Function`. Something along the lines of:
// This expression is not callable. Type 'Pipeline' has no call signatures.
const result = pipe("data");

Any help will be greatly appreciated.


Solution

  • TypeScript doesn't currently model what happens when a class's constructor method returns a value. There is a feature request at microsoft/TypeScript#27594 to support this, but right now TypeScript will only recognize class instance types as having its declared members, and JavaScript doesn't let you declare call signatures for class instances. So even though at runtime the instances of your Callable subclasses will be callable due to return-overriding and prototype-juggling, TypeScript won't be able to see it.


    If you were using TypeScript files instead of JSDoc, I might suggest merging the desired call signature into the Callable interface, as follows:

    class Callable {
        constructor() {
            var closure: any = function (...args: any) { return closure._call(...args) }
            return Object.setPrototypeOf(closure, new.target.prototype)
        }
        _call(...args: any) {
            console.log(this, args)
        }
    };
    
    interface Callable {
        (...arg: any): void;
    }
    

    Which you can see at work:

    const x = new Callable();
    x("a", "b", "c"); // okay
    class Pipeline extends Callable {
        _call(...args: any) { console.log("overridden"); super._call(...args) }
    }
    const pipe = new Pipeline();
    const result = pipe("data"); // okay
    // "overridden"
    // [object Function],  ["data"] 
    

    Playground link to TS code

    Unfortunately you can't define interfaces in JSDoc (see feature request at microsoft/TypeScript#33207) and while this might be possible with multiple files or different formats, I'll just forget this approach.


    Instead you can use type assertions or the JSDoc equivalent to just tell the compiler that Callable is of the type

    new () => {
      (...args: any[]): void, 
      _call(...args: any[]): void
    }
    

    meaning it has a construct signature that returns a callable object with a _call method. It looks like this:

    /** @type {new () => {(...args: any[]): void, _call(...args: any[]): void}}*/
    const Callable = /** @type {any} */ (class {
        constructor() {
            /** @type {any} */
            var closure = function (/** @type {any[]} */...args) { return closure._call(...args) }
            return Object.setPrototypeOf(closure, new.target.prototype)
        }
        _call(/** @type {any[]} */...args) {
            console.log(this, args)
        }
    });
    

    And you can verify that it works:

    const x = new Callable();
    x("a", "b", "c"); // okay
    class Pipeline extends Callable {
        _call(/** @type {any[]} */ ...args) {
            console.log("overridden"); super._call(...args)
        }
    }
    const pipe = new Pipeline();
    const result = pipe("data"); // okay 
    // "overridden"
    // [object Function], ["data"]
    

    Playground link to JSDoc-annotated JS code


    So that's one way to do it that works for JSDoc. I'm not going to comment much on whether or not the general goal of making TypeScript support callable class instances is worth pursuing, other than to say that such things can lead to surprising edge-case behavior that most JavaScript/TypeScript users will not expect. Obviously use cases will drive such a decision, but it should be made carefully nonetheless.