-
Notifications
You must be signed in to change notification settings - Fork 114
Description
Trapping decorators
Motivation
This proposal is a proposal to introduce decorators that have fewer concepts, and are hopefully easier to implement by JVM authors.
The fundamental difference in comparison is that this proposal does not attempt to process and modify property descriptors on a target. Rather, it recognises that modifying the property descriptors has always been a means to an end for 95% of the decorators: Namely a way to trap reads and writes. To transform values.
The motivation behind this proposal is that we want to trap reads and writes to properties. Very similar to the functionality that Proxies provide so successfully. The main difference is that decorators trap all interactions with the decorated instance, and that an entire 'type' is trapped, rather than specific instances. Things that cannot be done by Proxies.
The rest of the proposal is a proposal to achieve the "property trapping". Probably any other proposal would work for me personally as well. If engine or library authors want to adjust this proposal for whatever benefit: I think the wiggle room is there, as long as it can achieve the goal of trapping property reads and writes per instance, but being able to declare such traps for an entire type.
A big benefit of traps is that they often avoid the need of introducing additional properties. E.g. if there is a getter / setter to normalize input or make some assertions, currently it is needed to introduce an additional property to store the getted / setted value. With traps. this can collapsed into one property, avoiding the need of patterns like _name = "x"; get name() { return this._name }} etc.
The proposal
Property traps
A property trap is an object that provides two functions, named get and set. The goal of these traps is to be able to trap any read and write to a property. Traps can be used to achieve two effects
- Transform values on read / writes
- Introduce side effects on reads / writes
The signature of a PropertyTrap object is:
{
get(target, instance, property, storedValue) => value
set(target, instance, property, newValue) => valueToStore
}Field Decorators
A decorator syntactically is a prefix of a class or object member.
Syntactically: @<DECORATOR1> <...DECORATORN> <MEMBERDECLARATION>
Everyone of those expressions is evaluated and should resolve a function that returns a PropertyTrap. The functions get's passed in the target that will receive the property definition, and the property name. In other words, the signature of a field decorator is:
(target: object, propName: string | Symbol) => PropertyTrap | undefinedIf a field decorator does not return a trap, the decorator does not install additonal traps. However, the decorator could still be valuable as it can have side effects, such as storing meta data. (example: @serializable, @deprecated etc)
A first example
function logged() {
return {
get(target, instance, property, value) {
console.log(`GET`, target, instance, property, value)
return value
},
set(target, instance, property, value) {
console.log(`SET`, target, instance, property, value)
return value
}
}
}
class C {
@logged x = 3
}
// (1) SET C.propotype undefined 'x' 3
const c = new C()
//
c.x
// (2) GET C.prototype c 'x' 3
c.x = 4
// (3) SET c c 'x' 4
c.c
// (4) GET c c 'x' 4Semantic notes:
When construction the initial property descriptor, the traps are already applied, so that means that the @logged traps are applied during the construction of member x on class C above, and it is the transformed value that ends up in the initial property descriptor. Which is the reason we can observe side effect (1) above.
In the traps target represents the (to be) owner of the property descriptor that is being created. instance represents the this context that is used during a property access. When constructing C.x above, there is a target (C.prototype) but not a this context, hence no instance argument.
On the first read, the read is handled (or intercepted upon) the C.prototype, and the context is c, which we can observe in the output of (2).
When writing to c.x, this will result in a new property descriptor on the instance c. This is again represented in the arguments passed to the traps. Note that again the traps are applied during the computation of the new descriptor for c (the traps are inherited, similar to other properties of the original property descriptor, such as enumerable and writeable).
Note that the original decorator expression @logger is not re-evaluated again! Rather, the traps in which this originally resulted are reused and inherited by the new property descriptor (see below).
If we read from c.x again, this read is now handled by the prop descriptor on the instance, which is reflected in the arguments passed to the traps: the property owner equals the this now.
Storing traps
Traps are stored as the traps property on a PropertyDescriptor. When copying a property descriptor from one object to another, those traps are preserved. So in the above example the following would hold:
Object.getOwnPropertyDescriptor(C.prototype, "x")
// Results in
{
writable: true,
configurable: true,
enumerable: true,
value: 3,
traps: [logger]
}(Note, could also be stored somewhere else, as long as semantics are similar to here, and traps can be requested through reflection)
Reflection
As shown above traps are detectable trough reflection (this is different from Proxies).
Also, traps can be by-passed at any time, for example:
Object.getOwnPropertyDescriptor(c, "x").value // prints 3, without the side effects of loggingThis is intentional: it makes sure that developer tools such as the debugger don't accidentally trigger side effects, makes it easy to inspect the underlying data structures, and provides an escape hatch from the traps when needed (either by libraries or users). it is very well conceivable that traps themselves use this trick to bypass themselves (e.g. a set trap might use this to get the original value, but not trigger the side effect of it's corresponding get trap)
Getters and setters
Traps are just pass-through things, so they don't necessarily operate on just value based property descriptors, but work for get / set based descriptors as well. Except that the last category will not hit the traps when the property is originally declared (since there is no value to be initialized)
Trap initializer
Decorators can be parameterized by creating functions that return decorators. For example:
function logged(enabled) {
return () => {
get(target, instance, prop, value) {
return value
},
set(target, instance, prop, value) {
if (enabled)
console.log(prop, value)
return value
}
}
}
const obj = {
@logged(true) x: 4
}Note again that in the property descriptor for obj.x the resolved value of the decorator expression is stored, in this case a logger trap object where enabled=true is trapped in its closure.
Chaining
Decorator can be chained, which means that all the traps are applied from inside out (or, right to left):
const obj = {
@private @logged x: 5
}In the traps property of the descriptor this results in an array with the order of the traps as they will be applied:
Object.getOwnPropertyDescriptor(obj, "x").traps
// [trapFromLogged, trapFromPrivate]Classes and function decorators
Class and function decorators can be large kept as they are currently implemented in babel and typescript
For example @a class X is desugared to const X = a(class X) just as currently implemented already by babel-plugin-legacy-decorators and typescript's experimentalDecorators flag.
Two open questions raised by this, on which I don't have a strong opinion:
- is a class decorator a side effect, or can it potentially return something different? I prefer the second; it is a little more flexible, and it makes clear that you ought to to be doing
export @something class(see next question) - allow decorate-before-export (What would it mean to "decorate an export"? #135)?
- what is the impact on hoisting? Will decorating a class / function implicitly kill the hoisting? Should class / function decorators be only side effect full to prevent that?
(if somebody could point me to a more accurate write down of the currently implemented semantics, that would be great)
Engine impact (and summary of the proposal)
This proposal tries to keep the impact on JS engines very limited:
- Something something parsing
- Whenever a property descriptor is being created for an object literal or class member, the following happens:
- the decorator expressions are evaluated. This should result in a function
- the function is called with the object that will receive the property, and the property name. The return of that function should be a trap or nothing
- (if applicable) the
sethandlers of the traps this results in are applied to the value the property descriptor would be initialized with - (if applicable) the value this results in is stored in the
valueslot of the descriptor - the set of traps is stored in the
trapsslot of the descriptor (only if there were any)
- Whenever we write to an object: if there are traps stored in the descriptor that receives the write, the
sethandlers of those traps are applied first, and the resulting value is stored in.valueof the descriptor / passed to thesethandler of the (potentially new) descriptor - When reading from an object: if there are traps stored in the descriptor that receives the read, the value that is stored in
.valueof the descriptor (or: the value that is returned from thegethandler of the descriptor) is passed trough allgettraps of thetrapsstored in the descriptor - Profit
Reference examples
Not all examples of the current readme are here:
@defineElementdecorating entire classes is not in this proposal (see below).@metaDatadidn't work it out, but should be doable@frozen,@callableapplies to a class, so skipped@setcannot be done in this proposal. So field initializers in subclasses would need to receive the same decorators as the superclass if needed.
@defineElement
function defineElement(name, options) {
return klass => {
customElements.define(name, klass, options)
return klass
}
}
@defineElement('my-class')
class MyClass extends HTMLElement { }@metadata
const allClasses = new Map() // string -> constructor
const allMembers = new Map() // klass -> propName[]
function metaData(target, propName?) {
if (arguments.length === 1) { // used on class
allClasses.set(target.constructor.name, target)
return target
} else {
allMembers.set(target, [...(allMember.get(target) ?? []), propName])
}
}
@metaData class X {
@metaData member1
@metaData function() {
}
}@logger
const logged () => ({
set(t, i, p, f) {
const name = f.name;
function wrapped(...args) {
console.log(`starting ${name} with arguments ${args.join(", ")}`);
f.call(this, ...args);
console.log(`ending ${name}`);
}
return wrapped
}
})
class C {
@logged method(arg) {
this.#x = arg;
}
}@Frozen
function frozen(klass) {
Object.freeze(klass);
for (const key of Reflect.ownKeys(klass)) {
Object.freeze(klass[key]);
}
for (const key of Reflect.ownKeys(klass.prototype)) {
Object.freeze(klass.prototype[key]);
}
return klass
}
@frozen class ICannotBeChangedAnymore {
}@bound
function bound() {
return {
get(target, instance, property, fn) {
// happens when calling Foo.method for example in example below
if (!instance)
return fn
// remember that traps get inherited?
// if we bound before, this trap doesn't have to do anything anymore
// (we could skip storing the bound methods below, but better cache those bound methods)
if (target === instance)
return fn
// target is not the instance, we still need to bind
const bound = fn.bind(instance)
instance[property] = bound
return bound
}
}
}
class Foo {
x = 1;
@bound method() { console.log(this.x); }
queueMethod() { setTimeout(this.method, 1000); }
}
new Foo().queueMethod(); // will log 1, rather than undefined@Tracked
function tracked() {
return {
set(t, i, p, v) {
// Note that we can't render synchronously,
// as the new value would otherwise not be visible yet!
setImmediate(() => this.render())
// In practice, what would happen in MobX etc is that they
// would mark this object as 'dirty', and run render
// at the end of the current event handler, rather than awaiting a next tick
// alternatively, it would be possible to write this value
// to _another_ property / backing Map, like done in the Readme, so that the new value is externally visible before this chain of traps ends, as done below
return v
}
}
}
class Element {
@tracked counter = 0;
increment() { this.counter++; }
render() { console.log(counter); }
}
const e = new Element();
e.increment();
e.increment();
// logs 2
// logs 2@syncTracked
function syncTracked() {
return {
set(t, instance, prop, value) {
instance["_" + prop] = value // or use a Map
this.render()
return undefined // booyah what is stored in *this* prop
}
get(t, instance, prop, value) {
return instance["_" + prop]
}
}
}
class Element {
@syncTracked counter = 0;
increment() { this.counter++; }
render() { console.log(counter); }
}
const e = new Element();
e.increment();
e.increment();
// logs 2
// logs 2Non goal: modify signature
In previous decorator proposal, it is possible to significantly modify the property descriptor, or even the entire shape of the thing under construction. That is not the case with this proposal; decorators can not influence where the descriptor ends up, it's enumerability, configurability, it's type etc.
The only thing that the decorator can influence is
- The initial
valueof the property descriptor (except for getters / setters) - As side effect it could introduce other members on the
target, however, this is considered bad practice and is typically best postponed until we trap a read / write with a knowninstance.
Non goal: run code upon instance initialization
This proposal doesn't offer a way to run code guaranteed when construction a new instance, although code might run when a trap is hit during a read or write in the constructor
TypeScript
Traps don't modify the type signature at this moment. Technically, they can be used to divert the type of x.a from Object.getOwnProperty(x, "a").value, but that doesn't seem to be an interesting case to analyse statically.
More interestingly, traps can be used to normalize arguments, which means that the type being read could be narrow than the type to which is written. For example:
const asInt = () => ({
set(t, i, p, value: any): number {
if (typeof value === "number")
return value
return parseInt(value)
}
})
const person = {
@asInt age = "20"
}
typeof person.age /// numberIn the above example, the "write type" of age property would be any, while the read type would be number. However, TS can at the moment not distinguish between the readable and writeable property, and the same limitation already applies to normal getters and setters.
Edge cases
It is possible to omit set or get from a trap. The absence of such trap represents a verbatim write-trough (just like Proxies). See also the asInt example above
Instead of storing the decorators on the property descriptor, it probably suffices to be at least able to detect them trough reflection, e.g.: Reflect.getOwnDecorators(target, property): PropertyTrap[]
Optimization notes
Like proxy handers, property traps are shared as much as possible, so they are not bound to a specific instance, type or property, but rather receive those as arguments.
An assumption in this proposal is that when writing to a property that is defined in the prototype, the original descriptor is copied onto the instance, and we could just as well copy the traps with it. However, if this assumption is wrong, this proposal doesn't strictly require this copy-traps mechanism; as the trap on the prototype could take care itself of the copying the traps in cases where this is need (for example, in @tracked it is, but in @bound it isn't).
The reason that both the decorator and the traps themselve receive the target is that this makes it much easier to reuse traps among different properties and classes. Probably this will be much better optimizable, just like proxy traps are a lot more efficient as soon as the handlers are reused. So for example, it would be adviseable to hoist traps from the decorator definitions whenever possible:
const loggerTraps = {
get(target, instance, property, value) {
console.log(`GET`, target, instance, property, value)
return value
},
set(target, instance, property, value) {
console.log(`SET`, target, instance, property, value)
return value
}
}
function logger() {
return loggerTraps
}
class X {
@logger methodA() {
}
// both methods now use the same logging trap
@logger methodB() {
}
}Note that this optimization notice applies to most examples above
update 27-nov-2019:
- clarified / linked to the old class decoration proposal
- changed the signature to of decorators to be a function, to make it easier to do meta-data-only decorators
- added examples for
@frozen,@defineElement,@metaData - added note about trap hoisting optimization