Symbols as WeakMap keys

Stage 2

Coauthors/champions:

  • Robin Ricard (@rricard)
  • Rick Button (@rickbutton)
  • Daniel Ehrenberg (@littledan)
  • Leo Balter (@leobalter)
  • Caridy Patiño (@caridy)
  • Rick Waldron (@rwaldron)

Spec text


Introduction

This proposal extends the WeakMap API to allow usage of unique Symbols as keys.

Currently, WeakMaps are limited to only allow objects as keys, and this is a limitation for WeakMaps as the goal is to have unique values that can be eventually GC'ed.

Symbol is the only primitive type in ECMAScript that allows unique values. A symbol value - like the one produced by calling the Symbol( [ description] ) expression - can only be identified with access to its original production. Any reproduction of the same expression - using the same value for description - will not restore the original value of any previous production. This is why we call the symbol values distinct.

Objects are used as keys for WeakMaps because they share the same identity aspect. The identity of an object can only be verified with access to the original production, no new object will match a pre-existing one in - e.g. - a strict comparison.

Earlier discussions

See earlier discussion on Symbols as WeakMap keys.

Draft PR

See the current draft PR to ECMA-262 with the proposed spec.

Use Cases

Easy to create and share keys

Instead of requiring creating a new object to be only used as a key, a symbol would provide more clarity for the ergonomics of a WeakMap and the proper roles of its keys and mapped items.

const weak = new WeakMap();

// Pun not intended: being a symbol makes it become a more symbolic key
const key = Symbol('my ref');
const someObject = { /* data data data */ };

weak.set(key, someObject);

Realms, Membranes, and Virtualization

The revised Realms proposal disallow access to object values. For most virtualization cases, a membrane system is built on top of Realms-related API to connect references using WeakMaps. A Symbol value, being a primitive value, is still accessible, allowing membranes being structured with proper weakmaps using connected identities.

// TODO: Add example here

Record and Tuples

This proposal aims to solve a problem space introduced by the Record & Tuple Proposal; how can we reference and access non-primitive values in a primitive?

tl;dr We see Symbols, dereferenced through WeakMaps, as the most reasonable way forward to reference Objects from Records and Tuples, given all the constraints raised in the discussion so far.

There are some open questions as to how this should they work exactly, and also valid ergonomics/ecosystem coordination issues, which we hope to resolve/validate in the course of the TC39 stage process. We'll start with an understanding of the problem space, including why Records and Tuples are a good first step without this feature. Then, we'll examine various possible solutions, with their pros and cons,

Records & Tuples can't contain objects, functions, or methods and will throw a TypeError when someone attempts to do it:

const server = #{
    port: 8080,
    handler: function (req) { /* ... */ }, // TypeError!
};

This limitation exists because the one of the key goals of the Record & Tuple Proposal is to have deep immutability guarantees and structural equality by default.

The userland solutions mentioned below provide multiple methods of side-stepping this limitation, and Record and Tuple is viable and useful without additional language support for boxing objects. This proposal attempts to describe solutions that complement the usage of these userland solutions with Record and Tuple, but is not a prerequisite to landing Record and Tuple in the language.

Accepting Symbol values as WeakMap keys would allow JavaScript libraries to implement their own RefCollection-like things which could be reusable (avoiding the need to pass around the mapping all over the place, using a single global one, and just passing around Records and Tuples) while not leaking memory over time.

class RefBookkeeper {
    #references = new WeakMap();
    ref(obj) {
        // (Simplified; we may want to return an existing symbol if it's already there)
        const sym = Symbol();
        this.#references.set(sym, obj);
        return sym;
    }
    deref(sym) { return this.#references.get(sym); }
}
globalThis.refs = new RefBookkeeper();

// Usage
const server = #{
    port: 8080,
    handler: refs.ref(function handler(req) { /* ... */ }),
};
refs.deref(server.handler)({ /* ... */ });

Some open questions

Well-known and registered symbols as WeakMap keys

Some TC39 delegates have argued strongly in either direction. We see both "allowing" and "disallowing" as acceptable options.

Allowing registered symbols doesn't seem so bad, since registered Symbols are analogous to Objects that are held alive for the lifetime of the Realm. In the context of a Realm that stays alive as long as there's JS running (e.g., on the Web, the Realm of a Worker), things like Symbol.iterator are analogous to primordials like Object.prototype and Array.prototype, and registered Symbol.for() symbols are analogous to properties of the global object, in terms of lifetime. Just because these will stay alive doesn't mean we disallow them as WeakMap keys.

Prohibiting registered symbols doesn't seem so bad, since it's already readily observable whether a Symbol is registered, and it's not very useful to include these as WeakMap keys. Therefore, it's hard to see what practical or consistency problems the prohibition would create, or why it would be surprising (if there's a meaningful error message).

Caveat: Retrievable Symbols

If a symbol is created using Symbol.for, the value is registered within a GlobalSymbolRegistry List shared by all realms and retrievable through Symbol.keyFor. Well-known symbol values are also shared by all realms. These are the only events specified in ECMAScript that might remove some of the unique distinctness of a symbol value.

At least, usage of Symbol.for and Symbol.keyFor is the only built-in way within a single Realm to retrieve Symbol values after they are dereferenced by user code.

In this case, a solution would be limiting the WeakMap keys extension to only non-retrievable Symbols at some capacity. This means, if GlobalSymbolRegistry contains a Symbol value, this value is not accepted as a key for a WeakMap and might end as an abrupt completion if a code tries to do so.

The current proposed spec doesn't reflect this solution, while it still allows any Symbol or Object value to become a WeakMap Key.

Support for Symbols in WeakRefs and FinalizationRegistry

We could support Symbols in WeakRefs and FinalizationRegistry, or not. It's not clear what the use cases are, but it would seem consistent with adding them as WeakMap keys.

As starting points, we propose that all Symbols be allowed as WeakMap keys, WeakSet entries, and in WeakRefs and FinalizationRegistry.

Summing up

We think that adding Symbols as WeakMap keys is a useful, minimal primitive enabling Records and Tuples to reference Objects while respecting the constraints imposed by the goal to support membrane-based isolation within a single Realm. At the same time, the userspace solutions seem sufficient for many/most use cases; we believe that Records and Tuples are very useful without any additional mechanism for referencing objects from primitives, and therefore makes sense to proceed with Records and Tuples independently of this proposal.