Subclassing the Function object in JavaScript

Sometimes, for ergonomic reasons, you may want to have a class whose instances behave like functions, but additionally have other methods.

To demonstrate, let's build a class that wraps a function and tracks the number of calls.

Here's a how we may want this to work:

const add = new TrackedFunction((a, b) => a + b);

// It looks & feels like a function:
console.log(add instanceof TrackedFunction); // log: true
console.log(add instanceof Function);        // log: true
console.log(typeof add);                     // log: "function"

// It has methods like a class instance:
console.log(add.getCallCount());             // log: 0

// It behaves like a function:
console.log(add(3, 5));                      // log: 8
console.log(add(2, 4));                      // log: 6

// But the added methods are useful:
console.log(add.getCallCount());             // log: 2

To do this, we need to take advantage of a few uncommon, but perfectly valid practices:

  • Returning values from class constructors
  • Object.setPrototypeOf to change a value's prototype

Here's the solution in plain JavaScript:

class TrackedFunction extends Function {
  constructor(fn) {
    // Define the function we want for our TrackedFunction instances
    const self = (...args) => {
      // Any state it needs is stored/retrieved on itself
      self.callCount += 1;
      return fn(...args);
    };

    // Initialize class state on the self object (*not* on the `this` object)
    self.callCount = 0;

    // Adjust the prototype so `instanceof` & class methods work as expected
    Object.setPrototypeOf(self, TrackedFunction.prototype);

    // Now `self` is a TrackedFunction instance *and* our wrapped function
    return self;
  }

  // Class methods work just fine, and this works as expected
  getCallCount() {
    return this.callCount;
  }
}

A few things to note:

  • this is not present in the constructor body at all
  • super() is not present in the constructor body, since we don't access this
  • this behaves normally outside of the constructor body

It's possible for class constructors to return an arbitrary object value, which becomes the result of the new TrackedFunction(...) expression. So essentially what we're doing is creating a function value that turn into a TrackedFunction instance by initializing state on it and adjusting its prototype.

Getting it working in TypeScript

Update 2024-03-17: this is a bad idea, you lose the return type inference, you can hack it with more type fun, but I'd probably suggest avoiding that. Leaving this for posterity.

This is all and good, but if you're using TypeScript, you'll run into a few problems.

  1. TypeScript currently requires that all constructor bodies that are in classes that extend from something contain a super() call (issue tracker)
  2. TypeScript requires that all non-declare-tagged members are assigned to this in the constructor body

So to get this working with TypeScript in a way that has private state and typechecks correctly, we can do something like this:

class TrackedFunction<TFunc extends Function> extends Function {
  // Mark all private / public members as `declare` to avoid assignment to `this` in the constructor body
  declare private callCount: number;

  constructor(fn: TFunc) {
    // Sadly necessary to call super (or add a ts-expect-error comment on the constructor)
    super();

    // We lie a bit, and claim that `self` is created as a TrackedFunction<TFunc>
    // This allows us to assign to its public & private members below and return it
    const self: TrackedFunction<TFunc> = ((...args: any[]): any => {
      self.callCount += 1;
      return fn(...args);
    }) as any;

    self.callCount = 0;
    Object.setPrototypeOf(self, TrackedFunction.prototype);

    return self;
  }

  getCallCount() {
    return this.callCount;
  }
}

Warning: edge cases

This approach will not work with initializers that are present within the class body.

For example if getCallCount was declared like this:

class TrackedFunction extends Function {
  constructor(fn) {
    const self = (...args) => {
      self.callCount += 1;
      return fn(...args);
    };

    self.callCount = 0;

    Object.setPrototypeOf(self, TrackedFunction.prototype);

    return self;
  }

  // DANGER: This won't work!
  getCallCount = () => {
    return this.callCount;
  }
}

var hello = new TrackedFunction(() => 'hi');
hello();
hello.getCallCount(); // throws: Uncaught TypeError: hello.getCallCount is not a function

This is because the getCallCount class initializer is only performed in the context of this within the constructor.

But even if we were to take the value of getCallCount and assign it to self, it still wouldn't work, because it's assigned as an arrow function, so the value of this is fixed to the value created by the constructor.

Why the heck would you want this?

Honestly, 99% of the time, you could get away with not having a proper class, and just assigning methods to a wrapped function, like this:

function TrackedFunction(fn) {
  let callCount = 0;

  const wrapped = (...args) => {
    callCount += 1;
    return fn(...args);
  };

  return Object.assign(wrapped, {
    getCallCount: () => callCount,
  });
}

However, assigning member values to functions is surprisingly slow in v8, especially when there are many fields needing to be copied over in the Object.assign call.

Here's a benchmark, which shows this difference: https://jsbench.me/zklcwcdmg5/1

On Chrome 109.0.5414.87 on my laptop, it is "57.04% slower" (3315966.89 ops/s ± 2.08% vs 7718716.72 ops/s ± 4.6%) to use this Object.assign mechanism instead of the above Object.setPrototypeOf subclass mechanism when there are 10 member functions which need to be copied over.

Note: this is a mirobenchmark, don't actually use subclassing as a performance optimization unless you're actually seeing Object.assign calls showing up in a real benchmark because you're constructing thousands of objects at once. To put the benchmark speed in perspective, the difference between a single "slow" assignment construction (301.571166 nanoseconds) and "fast" subclass construction (129.555214 nanoseconds) is probably not going to impact your application.