-
Notifications
You must be signed in to change notification settings - Fork 126
Description
TL;DR;
Allow client code to intercept the decoration process by defining a well-known Symbol.decoratorCall. When the runtime detects a property on a decorator with this symbol as it's name and a function as it's value, the runtime will invoke that function i.s.o. the decorator function itself to perform the decoration.
Background
There has been a lot of discussion around the way decorators are used and how the spec allows us to write code for these scenarios. Mostly, there are two ways to use decorators that are used a lot:
@deco // <-- no parentheses
class Subject {}@deco('argument')
class SubjectSo far so good, but what happens if the decorator accepts an optional argument? We find that whether we supply parentheses or not makes a difference. In other words, these two uses of decorators are not equivalent:
@deco
class Subject
// not equivalent to
@deco()
class SubjectThe big difference is that in the first scenario deco is invoked as a decorator and is expected to return the decoration target (e.g. the class in this example), whereas in the second scenario, deco will first be called without any arguments and is expected to return a function, which will then be invoked as the decorator. We will call this a decorator factory.
Many authors have expressed the desire to have @deco and @deco() have the same outcome: The target is decorated with deco without any arguments. This can be achieved with an if inside the decorator that uses duck typing to determine whether the function is invoked as a decorator or as a decorator factory:
function deco(arg) {
if (typeof arg == 'function') {
// decorator
arg.decorated = true;
return arg;
} else {
return target => {
target.decorated = arg;
return target;
}
}
}This function will handle all scenarios described above. I will refer to functions that work this way as 'universal decorators'. They are convenient. But this code contains some problems:
- The check in the
ifis brittle. What if we want to write a class decorator that optionally accepts a function as an argument? In some cases, it may be impossible to distinguish between decorator and decorator factory invocations. - This code contains much redundancy. Basically we are writing the decorator twice: once for the decorator invocation and once for the decorator factory invocation. We can of course factor out some code into a sub function but it all makes our decorator needlessly complex.
The discussion around this topic has spawned some interesting ideas for solutions, the most extreme of which would break all existing decorators. This proposal attempts to formulate a solution to these problems that is fully backwards compatible.
Hooking into the decoration process
The if in our universal decorator example above is really what is causing us all our grief. It splits our code in two and worse, the condition it is checking is brittle and will often have to change depending on what arguments our function accepts. What if we could get rid of this if altogether?
Turns out we could. And best of all, we could do it without breaking backward compatibility. As a bonus we would be creating a generic mechanism that could support other scenarios besides writing universal decorators. We can allow authors to hook into the decoration process itself.
Symbol.decoratorCall
In Javascript, functions are objects. Which means they can have properties. We can use this to allow authors to set a property on a decorator function in such a way that they can trap the decoration process. Given a well-known symbol Symbol.decoratorCall, authors could set a property on their decorator using this symbol to set a function to be called by the runtime instead of the decorator function itself. As authors, we now have an extra tool that can help us rewrite our universal decorator to something much simpler:
function deco(arg = true) {
return target => {
target.decorated = arg;
return target;
}
}
deco[Symbol.decoratorCall] = function(...args) {
return deco()(...args);
};Notice how the function assigned to the Symbol.decoratorCall property is completely generic. We as authors can take advantage of this fact to implement our own tooling for universal decorators:
function universalDecorator(factory) {
factory[Symbol.decoratorCall] = function(...args) {
return factory()(...args);
}
return factory;
}Then, we could write only the decorator factory and let the tooling turn it into a universal decorator for us:
const deco = universalDecorator(function deco(arg = true) {
return target => {
target.decorated = arg;
return target;
}
});If we ever get decorators on functions, we can make it even nicer :)
Conclusion
Although this proposal was born out of the desire to be able to write universal decorators more easily and to avoid duck typing, I think that being able to intercept the decoration process will actually open up options to deal with other issues as well.
Advantages:
- Fully backwards compatible. This change should not break any existing decorators.
- Meshes well with how other runtime features can be hooked into using
Symbol. E.g.Symbol.iteratorwhich can make our object work with thefor ... ofloop. - Opt-in. Only people that care about universal decorators need to do something to make use of this proposal. All other authors could happily ignore it and never be the wiser.
Disadvantages:
- Opt-in. From the perspective of authors using decorators, they still need to figure out per decorator whether the use of parentheses matters for that decorator, and which form to use.
- Requires a new well-known symbol.