This is a different take on https://github.com/tc39/proposal-richer-keys, looking at the same problem of "richer keys". It also includes an alternative to https://github.com/tc39/proposal-record-tuple.
Right now Map
and Set
always use SameValueZero for their internal equality predicate answering "Is this value in this collection?".
new Set([42, 42]).size; // 1
new Set([{}, {}]).size; // 2;
const m = new Map();
m.set("hello", "world");
m.set({}, "object");
m.get("hello"); // "world";
m.has({}); // false
As shown above, this means that when it comes to objects, all objects are only equal to themselves. There is no capability to override this behavior and allow two different objects to be treated equal within the collection.
const position1 = Object.freeze({ x: 0, y: 0 });
const position2 = Object.freeze({ x: 0, y: 0 });
const positions = new Set([position1, position2]);
positions.size; // 2
Whereas in Python:
position1 = (0, 0)
position2 = (0, 0)
positions = set()
positions.add(position1)
positions.add(position2)
print(len(positions)) # 1
or Clojure:
(def position1 '(0 0))
(def position2 '(0 0))
(count (set [position1 position2])) ; 1
One way to work around this limitation in JavaScript is to construct a string representation of the value.
const positions = new Set([JSON.stringify(position1), JSON.stringify(position2)]);
positions.size; // 1
The downsides of this are:
JSON.stringify
will produce a different string if the object keys are enumerated in a different order or throw if the value does not have a built-in JSON representation.Alternatively two collections can be used, one to track uniqueness and another to track values:
const positions = [];
const positionKeys = new Set();
function add(position) {
const asString = JSON.stringify(position);
if (positionKeys.has(asString)) return;
positions.push(position);
positionKeys.add(asString);
}
The downsides of this are:
There are a collection of ideas here. These can be broken down into separate but composable proposals; or delivered all together as one proposal. Listing all of them here is to set out a vision of where we could end up in the future, to check how everything fits together.
Allow Map
and Set
instances to be customized with a lookup function that will produce the value that will internally represent the key's uniqueness for that collection.
const keyBySet = new Set([], { keyBy: (v) => v.uuid });
keyBySet.add({ uuid: "ABCDE", id: 1 });
keyBySet.add({ uuid: "ABCDE", id: 2 });
keyBySet.has({ uuid: "ABCDE", id: 3 }); // true
[...keyBySet]; // [{ uuid: "ABCDE", id: 1 }]
This addresses the issue of using two separate collections to achieve these semantics.
Introduce a CompositeKey
type. This type can represent the compound equality of a sequence of values.
const key1 = new CompositeKey(0, 0);
const key2 = new CompositeKey(0, 0);
const key3 = new CompositeKey(0, 1);
key1 !== key2; // true (separate objects)
CompositeKey.equal(key1, key2); // true
CompositeKey.equal(key1, key3); // false
Reflect.ownKeys(key1); // [] (opaque empty object from the outside)
key1 instanceof CompositeKey; // true
Object.isFrozen(key1); // false (the key's value is internal+private)
This pairs nicely with the Map
/Set
config, allowing for more interesting keys. When the keyBy
function returns a CompositeKey
it is not compared with other values using SameValueZero
but by the equality of two CompositeKey
s.
const positions = new Set([], { keyBy: ({x, y}) => new CompositeKey(x, y) });
const position1 = Object.freeze({ x: 0, y: 0 });
const position2 = Object.freeze({ x: 0, y: 0 });
positions.add(position1);
positions.add(position2);
positions.size; // 1
[...positions].at(0) === position1; // true
CompositeKey
can be nested recursively with the resulting 'structure' participating in the equality. A CompositeTree if you will.
const key1 = new CompositeKey(1, new CompositeKey(2, 3));
const key2 = new CompositeKey(1, new CompositeKey(2, 3));
CompositeKey.equal(key1, key2); // true
const key3 = new CompositeKey(1, 2, 3);
CompositeKey.equal(key1, key3); // false (nesting keys does not flatten them)
While being able to customize the keyBy
function when constructing the collection provides flexibility, it may be common that the values themselves are best placed to define how their CompositeKey
should be constructed to help ensure correctness.
const positions = new Set([], { keyBy: ({x, y}) => new CompositeKey(x, y) });
positions.add({ x: 0, y: 0, z: 1 });
positions.add({ x: 0, y: 0, z: 99 }); // 'z' prop is not inspected by the keyBy function
positions.add({ x: 0, y: 1 });
positions.values().toArray(); // [{ x: 0, y: 0, z: 1 }, { x:0, y: 1 }]
Introduce a new well-known Symbol to act as a co-ordination point:
class Position {
x;
y;
constructor(x, y) {
this.x = x;
this.y = y;
}
[Symbol.keyBy]() {
return new CompositeKey(Position, this.x, this.y);
}
}
const positions = Set.usingKeys(); // name to be bike-shedded
// ~sugar for:
const positions = new Set([], { keyBy: (v) => v[Symbol.keyBy](), });
positions.add(new Position(0, 1));
positions.add(new Position(0, 1));
positions.add(new Position(0, 2));
positions.size; // 2
There can be a CompositeKey
static factory that looks up the this symbol on the arguments keeping the constructor the minimal required functionality.
CompositeKey.of(position1, position2); // name tbc
// ~sugar for:
function lookupKey(v) {
if (Object(v) === v) {
let keyBy = v[Symbol.keyBy];
return typeof keyBy === "function"
? Reflect.apply(keyBy, v, [])
: v; // or throw if no keyBy protocol ?
} else {
return v;
}
}
new CompositeKey(lookupKey(position1), lookupKey(position2));
There could potentially be a built-in decorator to aid classes in keeping their Symbol.keyBy
protocol up-to-date as new fields are added.
class Position {
@CompositeKey.field
x;
@CompositeKey.field
y;
@CompositeKey.field
z;
constructor(x, y, z) {
this.x = x;
this.y = y;
this.z = z;
}
[Symbol.keyBy]() {
return CompositeKey.keyFor(this); // no need to update as new fields are added
}
}
We can also have built in immutable values that take this further by implicitly implementing the Symbol.keyBy
protocol to further reduce common boilerplate and help ensure correctness.
let r1 = #{ x: 0, y: 0, offset: #[0, 0] };
let r2 = Record({ x: 0, y: 0, offset: Tuple(0, 0) });
typeof r1; // "object"
Object.isFrozen(r1); // true
r1.x; // 0
r1 === r2; // false
r1[Symbol.keyBy]() instanceof CompositeKey; // true
let s = Set.usingKeys([r1]);
s.has(r2); // true
The built-in keyBy
implementation of these types will also look up Symbol.keyBy
on the values within the Record/Tuple. i.e. it is deep equality, not shallow.
Immutable values types such as those in Temporal could implement Symbol.keyBy
, without requiring users to work out the best way to represent these types using a CompositeKey
.
new CompositeKey
always return a fresh object
CompositeKey(0) === CompositeKey(0)
, which would require either an eager global intern-table or for ===
to be have a special overload for these objects.Map
and Set
changes opt-in, and do not work with existing default constructors new Map()
and new Set()
?
Symbol.keyBy
to an object could invalidate existing code that assumes existing Map
and Set
will use object identity.Symbol.keyBy
rather than silently falling back to object identity.hash()
and equals(other)
methods?
hash
values.
CompositeKey
can be thought of as a type that implements these on behalf of the user, ensuring that the two methods are aligned and equality follows the rules of reflectivity, symmetry, transitivity and consistency.CompositeKeys
, both values need to produce a full CompositeKey
rather than doing this gradually and exiting early as soon as one part does not match. A separate API for this use case could avoid this issue.Date.now()
and Math.random()
being examples of the few exceptions) and backwards-compatible with previous versions; this would mean that built-in hash functions would most likely need to be fully specified and limited ability to evolve the hashing algorithm. Exposing these low level details may also pose a security risk.Record
and Tuple
's keys to still use a hash+equals approach under the hood. Even if the observable behavior is that they are producing CompositeKey
.Symbol.keyBy
then Symbol.keyBy
on the R/T will throw, rather than silently/implicitly fallback to object identity.===
, likely making them simpler for engines to implementSymbol.keyBy
is implemented on values that are not mutated so that it behaves consistency. There is no protection against a badly behaving Symbol.keyBy
method.hashcode
, equals
, compare
etc.CompositeKey
won't work across membranes because their uniqueness is encoded within an internal slot. Membranes would need to add explicit support for re-constructing CompositeKeys when used across a membrane.WeakMap
and WeakSet
?
CompositeKey
s would carry object information. So it it might be that only CompositeKeys
that were created from at least one value that itself is allowed as a WeakMap
key would be permissable as a WeakMap
key.