tc39 / proposal-decorators

Decorators for ES6 classes
https://arai-a.github.io/ecma262-compare/?pr=2417
2.76k stars 106 forks source link

Idea: syntax for decorator composition. #515

Open trusktr opened 1 year ago

trusktr commented 1 year ago

Right now, composing decorators is much too cumbersome. You have to write conditional branches inside of a new function to handle all the kinds of decorators, and often you repeat logic, and can get it wrong.

Rather than show how to compose decorators by using multiple functions inside a new function, which I'm sure you're all familiar with, let me show the desired feature:

Currently:

class MyClass {
  @cool @logged foo = 123
  @cool @logged bar = 123
}

Idea (totally random syntax choice, but to show the idea):

let @cool2 = @cool @logged

class MyClass {
  @cool2 foo = 123
  @cool2 bar = 123
}

This achieves the same thing as composing both cool and logged functions together inside a new cool2 function, but in a much simpler way for the author of the composed decorator.

pabloalmunia commented 1 year ago

It is very suggestive, but I have some comments:

const Mixer = (...decorators) => 
  (element, descriptor) => 
    decorators.reverse().reduce((element, decorator) => decorator(element, descriptor) || element, element);

let cool2 = Mixer(cool, logged);
trusktr commented 1 year ago

@falentio Yeah, my syntax idea was not very good. 😄

@pabloalmunia That Mixer idea looks like it is on the right track, but here's a TS playground showing that the result of Mixer is different than with plain decorators for class fields. Looks like class field decorators are supposed to always receive undefined for their value parameter.

Here's a Babel repl showing the same issue with Mixer (make a whitespace modification to make it run the code).

Even with some adjustments to Mixer, it is definitely not as simple as @one @two @three where decorator syntax has all the semantics baked right in.


About syntax, what if it were

const composed = @cool @logged('warning')

class MyClass {
  @composed foo = 123
  @composed bar = 123
}

where composed is now a function reference?

In the future when/if function decorators come out,

// this would not compose, but would decorate the function
const someFunc = @cool @logged('warning') function() {...}

// this would compose
const composed = @cool @logged('warning')

const otherFunc = @composed function() {...}

Would it perhaps just be a decorator composition expression? Maybe when decorators are written out without decorating something, they just create a composed function.

Example 1:

// this would compose
const composed = (level) => @cool @logged(level)

const otherFunc = @composed('warning') function() {...}

Example 2:

function foo(decorator) {
  return @decorator function() {...}
}

foo(@cool @logged('warning'))

Example 3:

console.log(typeof (@one @two @three)) // "function"
trusktr commented 1 year ago

Use Case

A library wants to import decorators, and compose them into new ones to export the new ones. For example, for a custom element library, it might do this:

import {reactive} from 'some-reactive-library' // @reactive decorator makes a class field reactive

// _attribute decorator implementation
function _attribute() {
  // ... map HTML attribute to JS field ...
}

/** A decorator that does two things: maps attributes to JS properties, and make properties reactive. */
export const attribute = @_attribute @reactive

End user:

import {createEffect} from 'some-reactive-library'
import {customElement, attribute} from 'some-custom-element-library'

@customElement('my-el')
class MyEl extends HTMLElement {
  @attribute name = "Batman"
}

const el = new MyEl()
document.body.append(el)

createEffect(() => {
  // This re-runs any time `name` changes because `@attribute` is composed with `@reactive`
  console.log(el.name)
})

el.setAttribute('name', 'Superman') // triggers the effect, logs "Superman" to console