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

Idea: extensible dereference operator / reference protocol

Open Monkatraz opened this issue 1 year ago • 1 comments

I know this proposal is quite old at this point and seemingly unlikely to be adopted, but I had this idea and I thought it would be interesting enough to share.

So, in this proposal (if I'm understanding correctly) you dereference by basically omitting the ref keyword from the identifier for a reference declaration:

let x = 1
let ref y = ref x
// if I want to actually directly pass the reference object for y
console.log(ref y)
// and if I just want to read y
console.log(y)

Now, let's say in an alternative proposal that we had a more explicit dereference operator, something like * in another language:

let x = 1
let y = ref x
// it's flipped now, y directly refers to the reference object
console.log(y)
// to read the value, I need to use an operator
console.log(*y)

I'm positive this would have issues with parsing, but let's ignore that for now. The operator could be anything, it could even be the keyword deref.

Having this operator available lets us potentially make it useful to things that are "reference-like". An example is something like an observable value, something with a interface like this:

interface ObservableValue<T> {
  current: T
  subscribe(fn: (current: T) => void): () => void
}

You can subscribe to watch for changes to the value, but otherwise you can directly read or set the value using the current prop.

If we had a symbol like Symbol.referenceValue, we could allow using reference semantics. Let's implement this as a class (with a stub for subscribing) and use our symbol instead of needing a current prop:

class ObservableValue<T> {
  #value: T
  
  constructor(value: T) {
    this.#value = value
  }
  
  subscribe(fn: (value: T) => void) {
    // stub
  }
  
  get [Symbol.referenceValue]() {
    return this.#value
  }
  
  set [Symbol.referenceValue](value: T) {
    this.#value = value
    // notify listeners
  }
}

We now do this to access the value without subscribing:

const obs = new ObservableValue(1)
// read the current value
const cur = *obs
// set the value
*obs = 2

There are all kinds of wrapper value objects like this in JavaScript - another example one is useRef in React, you could be able to do this:

function ClickLogger() {
  const count = useRef(0)
  
  function onClick() {
    *count++
    console.log(*count)
  }
  
  return <button onClick={onClick} />
}

An interesting side effect of this is that it makes references kind of opaque - you don't know if the reference-like you were given has a value prop, you just know it has a setter and getter for an internal value using the symbol.

It may be the case that I'm actually overcomplicating things here and you can achieve what I'm proposing here by simply making references work with anything that matches the interface Symbol.referenceValue is intended for, meaning that you don't need to actually change the proposal much. Like maybe you just do this for the observable example:

const obs = new ObservableValue(1)
const ref value = obs
console.log(value)
value = 2

I'm sure there are lots of little issues with this. I'm curious what people think - assuming anyone even sees this little issue, lol.

Monkatraz avatar Dec 26 '24 14:12 Monkatraz

I'm extremely in favor of this alternate design at a basic level. It's also almost precisely how Rust and C both do references, and references in both languages are so easily used, they're admittedly overused.

However, I'd rather the protocol be more clearly defined as the core:

  • *v is shorthand for v[@@value]
  • *v = foo is shorthand for v[@@value] = foo
  • *v += foo is shorthand for v[@@value] += foo
  • &name returns an object with that symbol on its prototype, offering read-write access. &const name or similar could offer read-only access.
  • v is just the reified reference.
  • Optional: let ref v = ... unpacks the reference into an implicit normal variable and &v can recover it. The getter and (if not const) setter would be pre-fetched in this case, saving some overhead.

This syntax IMHO could also make https://github.com/tc39/proposal-signals a lot more ergonomic. To adapt their README example:

let ref counter = new Signal.State(0);
const ref isEven = new Signal.Computed(() => (counter & 1) == 0);
const ref parity = new Signal.Computed(() => isEven ? "even" : "odd");

// A library or framework defines effects based on other Signal primitives
declare function effect(cb: () => void): (() => void);

effect(() => element.innerText = parity);

// Simulate external updates to counter...
setInterval(() => counter++, 1000);

And from the README of https://github.com/proposal-signals/signal-polyfill:

export class Counter {
  #value = new Signal.State(0);

  get [Symbol.value]() {
    return *this.#value;
  }

  increment() {
    *this.#value++;
  }

  decrement() {
    if (*this.#value > 0) {
      *this.#value--;
    }
  }
}

dead-claudia avatar Dec 26 '24 20:12 dead-claudia