Subclassing the Function object in JavaScript
Last updated
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 allsuper()
is not present in the constructor body, since we don't accessthis
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.
- TypeScript currently requires that all constructor bodies that are in classes that extend from something contain a
super()
call (issue tracker) - TypeScript requires that all non-
declare
-tagged members are assigned tothis
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.