Overloadable data accesor operator

As you know, obj['key'] is used to retrieve a property that's literally on the object (unless you're using proxy magic, getters, etc).
The proposed obj@['key'] operator doesn't do anything unless it is overloaded, but can be overloaded using two new, well-known symbols: Symbol.getDataValue and Symbol.setDataValue. The usefulness of an overloadable data-accessor operator will become clear with some example use cases.

If we set Map.prototype[Symbol.getDataValue] to Map.prototype.get, and set Map.prototype[Symbol.setDataValue] to Map.prototype.set, then the following would become possible:

m = new Map([['x', 2]])
m@['x'] ??= 5
m@['y'] ??= 5
console.log(m) // Map(2) { 'x' => 2, 'y' => 5 }

The "Array.prototype.at" proposal provides a convinient way to index into arrays with negative inidices. With the data accessor operator, we could provide similar functionality:

items = [5, 6, 7]
items@[-1]++
console.log(items@[2]) // 8
items@[-2] = 0
console.log(items) // [5, 0, 8]

I presume URLSearchParams is not part of ECMAScript, but whatever comittee that has juristiction over it could also take advantage of the data accessor operator:

const params = new URLSearchParams('x=2&y=3')
params@['x'] = '5'
console.log(params.toString()) // x=5&y=3

We could potentially provide a default data-access behavior for objects (by defining the new well-known symbols on Object.prototype), The default behavior can be defined similar to normal propecty access, except it only accesses own properties.

const untrustedKey = 'toString'
const mapping = { x: 1, y: 2 }

console.log(mapping[untrustedKey]) // [Function: toString]
console.log(mapping@[untrustedKey]) // undefined

Implementation Details

Here's an example of how one would go about creating their own overload for this operator, along with some examples of when these operators get called:

const customObj = {
  [Symbol.getDataValue](key) {
    console.log('GET', key)
    return 5
  },
  [Symbol.setDataValue](key, value) {
    console.log('SET', key, value)
  },
}

customObj@['x'] // GET x
customObj@['x'] = 'NEW_VALUE' // SET x NEW_VALUE
customObj@['x']++ // "GET x" then "SET x 6"

Suggestions?

I'd love to hear any feedback on this idea, especially on these points:

  • I'm certainly not set on the obj@[key] syntax - I just picked something random for this. If anyone has any other ideas for syntax that they would prefer, please share!
  • Let me know if you can think of other use cases for such an operator, I'm sure there's plenty more out there that I haven't thought of.
2 Likes

Looking at GitHub - tc39/proposal-decorators: Decorators for ES6 classes it looks like this wouldn't clash with the @ uses there because square brackets are not allowed around decorators. So that's good.

In terms of number of characters to type. If Map had a single character alias for get it would only be one more character than adding new syntax.

Map.prototype.g = Map.prototype.get;

map@['x']
map.g('x')

I assume you meant to use parentheses, not square brackets, when calling your shorthand get?

I agree that for the simple cases of getting or setting a value, this operator wouldn't add too much value for Map (except that the operator is easier to remember then the function names). Where it really shines is with self assignment:

map = new Map()

map@['myKey'] ??= 5
// vs
map.set('myKey', map.get('myKey') ?? 5)

map@[computeKey()]++
// vs
const computedKey = computeKey()
map.set(computedKey, map.get(computedKey) + 1)
1 Like

yep :man_facepalming:t2: (I've edited it now)

Those examples are quite compelling. Worth noting the existence of GitHub - tc39/proposal-upsert: ECMAScript Proposal, specs, and reference implementation for Map.prototype.upsert

map@['myKey'] ??= 5;
map.emplace({ insert: () => 5 });

map@[computeKey()]++
map.emplace({ update: v => v + 1 });

Just mixing @aclaymore's first example with some of that proxy magic which gets you pretty close:

m = new Map([['x', 2]])
m.g = new Proxy(m, {
    get (target, prop) {
        return target.get(prop)
    },
    set (target, prop, value) {
        target.set(prop, value)
    }
})
m.g['x'] ??= 5
m.g['y'] ??= 5
console.log(m) // Map(2) { 'x' => 2, 'y' => 5 }
m.g['x']++
console.log(m) // Map(2) { 'x' => 3, 'y' => 5 }
1 Like

Very cool! Only downside is that this only works for string keys, and one of the main advantages of Map over objects is not having this restriction. Still cool though!

2 Likes

Even given the usage of Map I wasn't even considering that aspect of the proposal. Support for property key values other than strings and symbols would be compelling.

1 Like

Some more use cases:

We could potentially allow this syntax to be used within object destructuring:

const map = new Map([['key1', 1], ['key2', 2]])
const { @['key1']: value1, @['key2']: value2 } = map

const items = ['a', 'b', 'c', 'd']
const { 0: firstItem, @[-1]: lastItem } = items // This example mixes the data-accessor operator with normal attribute accessing.

We could also implement the data accessor operator on strings, to support negative indexing, like with arrays. We could potentially make this UTF-16-aware, so it doesn't split surrogate pairs, but I'm leaning away from that idea as I understand this to be a O(n) operation.

console.log('abcde'@[-1]) // e

The last one you could just do 'abcde'.slice(-1) if it's not Unicode-aware, and Unicode-aware or not, it'd be roughly the same complexity as string[string.length - 1].

I've figured out a solution that I like better than this overloadable data accessor operator (mostly because it doesn't require new syntax). The idea is to add a .getRef(key) function to Map (or any other data structure that wants to add it). The returned value is an object, with a value property that's a getter/setter, which will automatically fetch/set map values.

Example:

// Default assign
map.getRef(key).value ??= value

// Increment
counts.getRef(key).value++

I proposed this idea on the Map.prototype.emplace proposal here, along with some additional details.

1 Like