proposal-decorators icon indicating copy to clipboard operation
proposal-decorators copied to clipboard

ability to patch/observe initializers

Open trusktr opened this issue 3 months ago • 6 comments

The problem

It is currently not possible to patch initializers (f.e. for class fields).

If it were, we could easily do the following:

class MyClass {
  @signal a = 1
  @signal b = 2

  @computed sum = this.a + this.b
}

// we want the following to work (using Solid.js effects,
// but imagine any other signals/effects library):
const o = new MyClass()
createEffect(() => {
  console.log(o.sum)
})
// "3" is logged (1+2)

o.a = 2 // "4" is logged (2+2)

// in various cases we also want to override the
// computed value manually (more on that below):
o.sum = 456  // "456" is logged (the last value is explicit instead of a+b)

But because we cannot patch, or wrap, the initializer, we have no way to execute it to detect dependencies (f.e. if using TC39 Signals).

Even with auto-accessor, we still can't:

class MyClass {
  @signal a = 1
  @signal b = 2

  @computed accessor sum = this.a + this.b
}

The only thing we can work with is the initialized value (3), but by that point, it's too late.

The only thing we can do, is the following:

class MyClass {
  @signal a = 1
  @signal b = 2

  @computed sum = () => this.a + this.b
}

We can then run the intialized value (a function) to detect dependencies, then return the result as the new initialized value, from our decorator initializer.

But this is problematic because now TypeScript will infer the type of sum to be a function, not a number!

In plain JS, without type checking, this may be ok. The end user of the class would read and write numbers to the field, only the class defintion would have a function for initial value that.

It just falls apart badly with type checking systems, and from that PoV is very non-ideal.

Can we allow the initializer itself to be accessed/patched?

workarounds

We can only make readonly memos. In majority of cases this is desirable. The following is easy to implement:

class MyClass {
  @signal a = 1
  @signal b = 2

  @computed get sum() { return this.a + this.b }
}

That's a tiny bit more verbose, and prettier will make it 3 lines instead of 1:

class MyClass {
  @signal a = 1
  @signal b = 2

  @computed get sum() {
    return this.a + this.b
  }
}

And alternatively:

class MyClass {
  @signal a = 1
  @signal b = 2

  @computed sum() { // method, usage sites require being called()
    return this.a + this.b
  }
}
// or, when future grouped accessors are out:
class MyClass {
  @signal a = 1
  @signal b = 2

  @computed accessor sum {
    get() { return this.a + this.b }
  }
}

These are all clearly readonly, which is great for that use case.

There's also this possibility, but the ergonomics aren't as nice (requires a method call()):

class MyClass {
  @signal a = 1
  @signal b = 2

  @computed sum(value) { // method, usage sites require being called()
    return this.a + this.b
  }
}

const o = new MyClass()

createEffect(() => {
  o.sum() // read the value
})

o.sum(123) // provide an explicit value

The @computed impl in that case needs to handl input values, and then always overwrite when deps change. Doable!

Sometimes we want writable memos for simplicity and maintainability, and we also want to write concise class definitions:

A little more background:

In Solid.js and other signals libs, there are concepts of writable memos, or writable computeds, or just computed writable signals (memoization implied). Basically, the computed is updated any time its dependencies change, or if it is set to a value explicitly.

This is useful in scenarios when initial or external inputs should be in full control, but when internal state can change the value over time, for optimistic UI updates, etc.

Writable memos/computeds make certain types of code shorter, more concise, and easier to maintain, where otherwise it would require maintaining twice as many variables and manually implementing the coordination logic.

In fact we have primitives for that logic coordination in the Solid.js community, for example createLatest which creates a memo/computed that returns the latest value from a tuple of signals. In fact, createLatest is the primitive used to define createWritableMemo, which returns the latest value from a tuple consisting of a [writable signal, and a memo (i.e. computed)].

the ideal most concise code

For the most concise ideal code in writable memo use cases, the previous writable method example with class fields would look like this:

class MyClass {
  @signal a = 1
  @signal b = 2

  @computed sum = this.a + this.b
}

const o = new MyClass()

createEffect(() => {
  o.sum // read the value
})

o.sum = 123 // provide an explicit value

At any time, o.a or o.b could be set and trigger the effect, or o.sum could be set and trigger the effect, while keep the code as concise as possible.

concise TypeScript

The previous example, in TypeScript, can also be both readonly and concise:

class MyClass {
  @signal a = 1
  @signal b = 2

  @computed readonly sum = this.a + this.b
}

We all love concise (still readable) code.

trusktr avatar Nov 21 '25 21:11 trusktr