ECMAScript 2023 feature: symbols as WeakMap keys

[2024-05-19] dev, javascript, es2023
(Ad, please don’t block)

In this blog post, we take a look at the ECMAScript 2023 feature “Symbols as WeakMap keys” – which was proposed by Robin Ricard, Rick Button, Daniel Ehrenberg, Leo Balter, Caridy Patiño, Rick Waldron, and Ashley Claymore.

What are WeakMaps good for?  

The key ability of a WeakMap is to associate data with a value:

  • The value is the key of a WeakMap entry.
  • The data is the value of that entry.

Consider the following code:

const obj = {};
obj.attached = { data: 123 };
assert.deepEqual(
  obj.attached,
  { data: 123 }
);

With a WeakMap, we can attach data without mutating obj:

const attached = new WeakMap();
{
  const obj = {};
  attached.set(obj, { data: 123 });
  assert.deepEqual(
    attached.get(obj),
    { data: 123 }
  );
}

This kind of non-mutating attaching has two main use cases:

How is that different from a normal Map?  

The WeakMap holds the key weakly: If the key is garbage-collected, the whole entry is removed and the data can be garbage-collected, too (unless it is references somewhere else). That means that the illusion of the attached data being part of the value is really good and there won’t be memory leaks.

What values can be keys in WeakMaps?  

Which values can be keys in WeakMaps is documented in the ECMAScript specification, via the specification function CanBeHeldWeakly():

  • Objects
  • Symbols which are not registered (created via Symbol.for())

The latter kind of key is new and was added as part of the feature “Symbols as WeakMap keys”.

All kinds of keys have one thing in common – they have identity semantics:

  1. When compared via ===, two keys are considered equal if they have the same identity – they are not compared by comparing their contents, their values. That means there are never two or more different keys (“different” meaning “at different locations in memory”) that are all considered equal. Each key is unique.
  2. They are garbage-collected.

Both conditions are important so that WeakMaps can dispose entries when keys disappear and no memory leaks.

Let’s look at examples:

  • Non-registered symbols can be used as WeakMap keys: They are primitive but they are compared by identity and they are garbage-collected.
  • The following two kinds of values cannot be used as WeakMap keys:
    • Strings are garbage-collected but they are compared by value.
    • Registered symbols are different from normal symbols – they do not have identity semantics (source). This is how registered symbols are used:
    // Get a symbol from the registry
    const mySymbol = Symbol.for('key-in-symbol-registry');
    assert.equal(
      // Retrieve that symbol again
      Symbol.for('key-in-symbol-registry'),
      mySymbol
    );
    

Why are symbols as WeakMap keys interesting?  

Symbols as WeakMap keys solve important issues for upcoming JavaScript features:

Preparation: a module that creates references to objects  

symbol-refs.js contains an API that lets us use symbols as references to values:

const refToValue = new WeakMap();

export function createRef(value) {
  const ref = Symbol();
  refToValue.set(ref, value);
  return ref;
}

export function deref(ref) {
  return refToValue.get(ref);
}

This is what using the API looks like:

import {createRef, deref} from './symbol-refs.js';

const obj = {};
const ref = createRef(obj);
assert.equal(
  typeof ref, 'symbol'
);
assert.ok(
  deref(ref) === obj
);

Next, we’ll use this API for the two aforementioned use cases.

Use case: references to objects in records and tuples  

records and tuples are value types (and therefore immutable) that cannot contain objects. Thanks to WeakMaps, we can use symbols as references to objects and put them inside records and tuples (example based on an idea by Robin Ricard):

import {createRef, deref} from './symbol-refs.js';

function createNode(id) {
  const elemRef = createRef(document.getElementById(id));
  return #{
    id,
    elemRef,
  };
}

const node = createNode('menu-list');
assert.equal(
  typeof deref(node.elemRef),
  'object'
);
assert.equal(
  deref(node.elemRef).id,
  'menu-list'
);

Use case: passing references to objects in and out of ShadowRealms  

Values passed into and out of ShadowRealms must be primitive or callable. That rules out objects – which are neither. Once again, we can use symbols as references to objects as a work-around:

import {createRef, deref} from './symbol-refs.js';

const otherRealm = new ShadowRealm();
const shadowIdentity = otherRealm.evaluate(
  `x => x`
);

const object = { color: 'green' };
// Object identites are preserved if they are passed through
//  another realm
const result = shadowIdentity(createRef(object));
assert.ok(
  deref(result) === object
);

Conclusion and further reading  

  • Symbols can now be keys in WeakMaps – which enables us to use them as references to arbitrary values.
  • That is mainly useful for two upcoming JavaScript features:
  • If you have other use cases then let us know in the comments!

Content in “JavaScript for impatient programmers”:

Other material: