tc39/proposal-decorators

Suggestion: Field decorator value as initialize function

senocular opened this issue · 6 comments

Basic idea

Instead of providing the initial value assigned to a field as an argument to an initializer, the original initializer would be provided as a function given to the decorator as its value argument and to be called (optionally) in the initializer to retrieve its value.

Benefits:

  • More consistency between decorator Input and Output
  • Allows the decorator to control when the original initializer code runs, if at all
  • Provides an opportunity to change the context/this of the original initializer code
  • Allows the decorator to call the original initializer prior to instance creation (but of course would not have access to instance)

Drawbacks:

  • Additional complexity; obtaining initial value becomes a little more complicated
  • Can cause confusion having access to an initializer prior to normal use (prior to instance instantiation/field creation)
  • Performance impacts

API

Current field decorator signature:

type ClassFieldDecorator = (value: undefined, context: {
  ...
}) => (initialValue: unknown) => unknown | void;

Proposed:

type ClassFieldDecorator = (value: () => unknown, context: {
  ...
}) => () => unknown | void;

Similarly for auto accessor decorators, before:

type ClassAutoAccessorDecorator = (
  value: {
    get: () => unknown;
    set(value: unknown) => void;
  },
  context: { ... }
) => {
  get?: () => unknown;
  set?: (value: unknown) => void;
  initialize?: (initialValue: unknown) => unknown;
} | void;

After:

type ClassAutoAccessorDecorator = (
  value: {
    get: () => unknown;
    set(value: unknown) => void;
    initialize() => unknown;
  },
  context: { ... }
) => {
  get?: () => unknown;
  set?: (value: unknown) => void;
  initialize?: () => unknown;
} | void;

Usage

Old:

function dogYears (value, context) {
  return function (initialValue) {
    return initialValue * 7
  }
}

class Dog {
  @dogYears age = 1
}

new Dog().age // 7

New:

function dogYears (value, context) {
  return function () {
    return value.call(this) * 7
  }
}

class Dog {
  @dogYears age = 1
}

new Dog().age // 7

Use cases

@lazy: A @lazy decorator that doesn't calculate its value until first access. This would become easy with auto-accessor, not having to manually write out the individual components (get, set, backing) due to the fact that you'd need to include the lazy initialization in the getter. Instead it would be a single auto-accessor.

const EMPTY = Symbol()
function lazy ({ get, set, initialize }, { name, access }) {
  return {
    get() {
      Object.defineProperty(this, name, { get, set })
      const value = access.get() // if set called before first get
      return value === EMPTY ? initialize() : value
    },
    set,
    initialize: () => EMPTY
  }
}

function expensiveCalculation() {
  console.log('calculated')
  // ...do lots of work...
  return 42
}

class Container {
  @lazy accessor value = expensiveCalculation()
}

const container = new Container
// ...
container.value // (logs: calculated) 42
container.value // 42

@save/@restore: Saving the initializer as a function makes it easy to re-call the initializer again later.

const SAVED = Symbol()
function save (initializer, context) {
  context.defineMetadata(SAVED, initializer)
}

function restore (method, context) {
  return function restoreMethodWrapper (...args) {
    for (let [name, initializer] of this[Symbol.metadata][SAVED].magicalMadeUpIterator()) {
      this[name] = initializer()
    }
    return method.call(this, ...args)
  }
}

class Counter {
  @save startTime = Date.now()
  @restore reset() {}
}

const counter = new Counter
counter.startTime // 1618777000000
// ...
counter.startTime // 1618777000000
counter.reset()
counter.startTime // 1618888000000

@mockable: We can allow the replacement of a field initializer by offering an alternative which would then allow us to skip calling the original, preventing any of its side effects.

function mockable (initializer, { name }) {
  return function () {
    // looking for mocks in prototype
    const proto = Object.getPrototypeOf(this)
    if (proto.hasOwnProperty(name)) {
      return proto[name].call(this)
    }
    return initializer.call(this)
  }
}

class Ship {
  static count = 0
  @mockable id = ++Ship.count
}

test('Ship id', () => {
  Ship.prototype.id = () => 42 // mock initializer
  const game = new Game() // instantiates ships
  expect(game.playerOne.ship.id).to.be(42)
  expect(Ship.count).to.be(0)
})

This avenue has been considered and was the original idea for this proposal in earlier drafts, but was passed over due to performance constraints. I’m not entirely sure what the context was for that decision, but my understanding is it would not be possible to do.

Personally I can definitely think of cases where it would be useful as well, but there weren’t any compelling use cases in the ecosystem audit and I think that the proposal would work and still be valuable without this capability.

Maybe I am missing something, but I do not understand the need for this amendment for the lazy decorator. It's completely unnecessary. The normal behaviour without @lazy is the same as you get with the @lazy decorator:

function expensiveCalculation() {
  console.log('calculated')
  // ...do lots of work...
  return 42
}

class Container {
  value = expensiveCalculation()
}

const container = new Container
// ...
console.log(container.value) // (logs: calculated) 42
console.log(container.value) // 42

The function expensiveCalculation() is call when a new instance is created, but only once.

@pabloalmunia the difference is lazy doesn't run expensiveCalculation() when the instance is created. It only runs it when the property is first accessed. If you create a new Container and never check it's value, expensiveCalculation() would never run.

It might be hard to see looking at the example, but what this suggestion is proposing is that the value field would basically be represented as a function, like:

class Container {
  value = () => expensiveCalculation()
}

And then that function is called when initializing the value field. But decorators can intercept that and replace, prevent, or in the case of lazy, delay that call so the field will remain uninitialized (undefined) until the getter is called.

Thanks @senocular for the explanation, it is very clear that they are different behaviours.

@pabloalmunia @lazy could solve certain issues too. For example here: #347

As noted above, this is not possible in this proposal due to performance constraints, so I'm going to close this issue.