Oh No! There Is a Function on My Properties!
Some time ago, we wrote ourselves a Node.js logging module, so that we can reuse some common pino.js settings across all the projects. And an interesting problem appeared. Let me show you the interface of the module to better understand it. You first import a logger factory from the module.
import loggerFactory from 'cosmas';
Then you can create the instance of a logger
const logger = loggerFactory(); logger.info("Info message");
But the logger factory itself is a logger instance. It is there for convenience, so that you can use the logger with default settings right away.
const alsoLogger = loggerFactory; alsoLogger.warning("Warning message");
So the logger factory is both a function and an object with multiple properties. Side note: Every instance of a logger is actually a logger factory, so this concept is reused.
The problem is as follows. Given an existing object (pino.js logger instance) and a function (function to create a new logger instance) how do you merge these two things and keep everything working as it should?
Setup
First, let’s put some boilerplate code to showcase the situation we were in with the logging library
const writeSym = Symbol('write');
const timeSym = Symbol('time');
const proto = {
[writeSym]: function write() {},
};
function factory() {
const instance = {
value: 42,
info: function info() {
this[writeSym]();
this[timeSym]();
},
[timeSym]: function time() {},
};
Object.setPrototypeOf(instance, proto);
return instance;
}
const loggerFactory = factory;
const logger = factory();
This is a simplified version of a logger. It stores some internal values and functions. The functions may be stored within a Symbol field, or in a prototype, or both (e.g. write
function).
All the following examples can be seen in action here.
Nice and easy
First solution, that you might come up with, is to simply use Object.assign and do e.g. Object.assign(myFunction, myObject)LayoutItem
This looks good at first, but has one disadvantage, which is clear right from the docs. And that is, that Object.assign
copies only enumerable own properties. Therefore, it does not copy properties from the prototype chain so we lose the write
function.
Also, you will get an error, once you start overwriting read-only properties (e.g. function's name
). This will throw an error:
const assigned = Object.assign(fun, {name: 'customName'});
npm module 1 – funstance
There are some npm modules, which try to provide this functionality. First of them is funstance. Let’s see the relevant parts of source code, to see how this works
const funstance = function(obj, fn) {
var f = function() {
return fn.apply(obj, arguments);
};
function C() {}
C.prototype = Object.getPrototypeOf(obj);
f.__proto__ = new C();
Object.getOwnPropertyNames(obj).forEach(function(key) {
f[key] = obj[key];
});
return f;
};
This approach fixes the problem we had with Object.assign
, because as you can see, the code correctly sets the prototype for the object. But it is missing something else now. If you check the docs for Object.getOwnPropertyNames, it returns all properties, including non-enumerable ones, except those which use Symbol
. And unfortunately, Symbol
is what we use for the time
function.
npm module 2 – invokable
Second module which should help us is invokable. Again, let’s check the source code
function invokable(target) {
var f = function() {
return f['__call__'].apply(f, arguments);
};
Object.defineProperties(f, Object.getOwnPropertyDescriptors(target));
Object.setPrototypeOf(f, Object.getPrototypeOf(target));
return f;
}
obj['__call__'] = fun;
const res = invokable(obj);
Object.getOwnPropertyDescriptors
lists all properties, including those using Symbol. That is nice. Also, the prototype is preserved, so inherited properties work as expected.
But there is another way to achieve this, which I personally find more elegant (also, it’s less LOCs, if you are counting)
Proxy
At the time we were creating the cosmas module, the invokable module contained a subtle bug. Due to that, it was not working properly for objects with custom prototypes (like pino.js logger). It was later fixed in this commit. But before that happened, we came up with our own solution, utilizing a Proxy feature.
const res = new Proxy(fun, {
get: (_target, key) => {
return (key in obj ? obj : fun)[key];
},
});
This works exactly as a fun
function, but we added a get handler, which “redirects” property accesses to the original object obj
. But only those, for which obj
has the key. Thanks to this, we keep the function properties still accessible, e.g. name
.
Conclusion
As you can see, using npm modules might be really tricky, when most of the cases are OK, but there are hidden edge-cases. Always be careful when using external code. For example, we almost missed the subtle bug in the old version of invokable.
Also, this proved itself to be a good use-case for the Proxy mechanism.