├── .npmrc
├── .gitattributes
├── .github
└── workflows
│ ├── build.yml
│ └── deploy.yml
├── package.json
├── .gitignore
├── LICENSE
├── README.md
├── spec.emu
└── README_old.md
/.npmrc:
--------------------------------------------------------------------------------
1 | package-lock=false
2 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | index.html -diff merge=ours
2 | spec.js -diff merge=ours
3 | spec.css -diff merge=ours
4 |
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: 'build & lint ecmarkup'
2 |
3 | on: [pull_request, push]
4 |
5 | jobs:
6 | build:
7 | name: 'build & lint ecmarkup'
8 | runs-on: ubuntu-latest
9 |
10 | steps:
11 | - name: Checkout 🛎️
12 | uses: actions/checkout@v2
13 | - uses: actions/setup-node@v1
14 | with:
15 | node-version: '14.x'
16 | - run: npm install
17 | - run: npm run build
18 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "name": "Symbols-as-WeakMap-Keys",
4 | "description": "A TC39 Proposal for Symbols as WeakMap Keys",
5 | "scripts": {
6 | "start": "npm run build-loose -- --watch",
7 | "build": "npm run build-loose -- --strict",
8 | "build-loose": "mkdir -p out && ecmarkup --verbose spec.emu out/index.html"
9 | },
10 | "homepage": "https://github.com/tc39/proposal-symbols-as-weakmap-keys#readme",
11 | "repository": {
12 | "type": "git",
13 | "url": "git+https://github.com/tc39/proposal-symbols-as-weakmap-keys.git"
14 | },
15 | "license": "MIT",
16 | "devDependencies": {
17 | "ecmarkup": "^7.0.3"
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/.github/workflows/deploy.yml:
--------------------------------------------------------------------------------
1 | name: 'deploy github pages'
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 |
8 | jobs:
9 | deploy:
10 | name: 'deploy github pages'
11 | runs-on: ubuntu-latest
12 | if: ${{ github.repository == 'tc39/proposal-symbols-as-weakmap-keys' }}
13 |
14 | steps:
15 | - name: Checkout 🛎️
16 | uses: actions/checkout@v2
17 | - uses: actions/setup-node@v1
18 | with:
19 | node-version: '14.x'
20 | - run: npm install
21 | - run: npm run build-loose
22 | - name: Deploy 🚀
23 | uses: JamesIves/github-pages-deploy-action@4.0.0
24 | with:
25 | branch: gh-pages
26 | folder: out
27 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 |
6 | # Runtime data
7 | pids
8 | *.pid
9 | *.seed
10 |
11 | # Directory for instrumented libs generated by jscoverage/JSCover
12 | lib-cov
13 |
14 | # Coverage directory used by tools like istanbul
15 | coverage
16 |
17 | # nyc test coverage
18 | .nyc_output
19 |
20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
21 | .grunt
22 |
23 | # node-waf configuration
24 | .lock-wscript
25 |
26 | # Compiled binary addons (http://nodejs.org/api/addons.html)
27 | build/Release
28 |
29 | # Dependency directories
30 | node_modules
31 | jspm_packages
32 |
33 | # Optional npm cache directory
34 | .npm
35 |
36 | # Optional REPL history
37 | .node_repl_history
38 |
39 | # Only apps should have lockfiles
40 | yarn.lock
41 | package-lock.json
42 | npm-shrinkwrap.json
43 | pnpm-lock.yaml
44 |
45 | # ecmarkup build
46 | out/
47 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2017 ECMA TC39 and contributors
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Symbols as WeakMap keys
2 |
3 | Stage 4
4 |
5 | **Coauthors/champions**:
6 |
7 | - Robin Ricard (@rricard)
8 | - Rick Button (@rickbutton)
9 | - Daniel Ehrenberg (@littledan)
10 | - Leo Balter (@leobalter)
11 | - Caridy Patiño (@caridy)
12 | - Rick Waldron (@rwaldron)
13 | - Ashley Claymore (@acutmore)
14 |
15 | [Spec text](https://tc39.es/proposal-symbols-as-weakmap-keys)
16 |
17 | ---
18 |
19 | ## Introduction
20 |
21 | This proposal extends the WeakMap API to allow usage of unique Symbols as keys.
22 |
23 | 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.
24 |
25 | 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.
26 |
27 | 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.
28 |
29 | ### Earlier discussions
30 |
31 | See [earlier discussion](https://github.com/tc39/ecma262/issues/1194) on Symbols as WeakMap keys.
32 |
33 | ### Draft PR
34 |
35 | See the current [draft PR to ECMA-262](https://github.com/tc39/ecma262/pull/2777) with the proposed spec.
36 |
37 | ## Use Cases
38 |
39 | ### Easy to create and share keys
40 |
41 | 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.
42 |
43 | ```javascript
44 | const weak = new WeakMap();
45 |
46 | // Pun not intended: being a symbol makes it become a more symbolic key
47 | const key = Symbol('my ref');
48 | const someObject = { /* data data data */ };
49 |
50 | weak.set(key, someObject);
51 | ```
52 |
53 | ### ShadowRealms, Membranes, and Virtualization
54 |
55 | The [ShadowRealms proposal](https://github.com/tc39/proposal-shadowrealm) disallows 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.
56 |
57 | ```javascript
58 | const objectLookup = new WeakMap();
59 | const otherRealm = new ShadowRealm();
60 | const coinFlip = otherRealm.evaluate(`(a, b) => Math.random() > 0.5 ? a : b;`);
61 |
62 | // later...
63 | let a = { name: 'alice' };
64 | let b = { name: 'bob' };
65 | let symbolA = Symbol();
66 | let symbolB = Symbol();
67 | objectLookup.set(symbolA, a);
68 | objectLookup.set(symbolB, b);
69 | a = b = null; // ok to drop direct object references
70 |
71 | // connected identities preserved as the symbols round-tripped through the other realm
72 | let chosen = objectLookup.get(coinFlip(symbolA, symbolB));
73 | assert(['alice', 'bob'].includes(chosen.name));
74 | ```
75 |
76 | ### Record and Tuples
77 |
78 | This proposal aims to solve a problem space introduced by the [Record & Tuple Proposal][rtp]; how can we reference and access non-primitive values in a primitive?
79 |
80 | 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.
81 |
82 | 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,
83 |
84 | [Records & Tuples][rtp] can't contain objects, functions, or methods and will throw a `TypeError` when someone attempts to do it:
85 |
86 | ```js
87 | const server = #{
88 | port: 8080,
89 | handler: function (req) { /* ... */ }, // TypeError!
90 | };
91 | ```
92 |
93 | This limitation exists because the one of the **key goals** of the [Record & Tuple Proposal][rtp] is to have deep immutability guarantees and structural equality _by default_.
94 |
95 | 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.
96 |
97 | 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](https://github.com/tc39/proposal-record-tuple)) while not leaking memory over time.
98 |
99 | ```js
100 | class RefBookkeeper {
101 | #references = new WeakMap();
102 | ref(obj) {
103 | // (Simplified; we may want to return an existing symbol if it's already there)
104 | const sym = Symbol();
105 | this.#references.set(sym, obj);
106 | return sym;
107 | }
108 | deref(sym) { return this.#references.get(sym); }
109 | }
110 | globalThis.refs = new RefBookkeeper();
111 |
112 | // Usage
113 | const server = #{
114 | port: 8080,
115 | handler: refs.ref(function handler(req) { /* ... */ }),
116 | };
117 | refs.deref(server.handler)({ /* ... */ });
118 | ```
119 |
120 | ## Well-known and Registered symbols as WeakMap keys
121 |
122 | Some TC39 delegates have argued strongly in either direction. We see both "allowing" and "disallowing" as acceptable options.
123 |
124 | ### [Registered](https://tc39.es/ecma262/multipage#sec-symbol.for) symbols
125 |
126 | Disallowing registered symbols is discussed in [issue 21](https://github.com/tc39/proposal-symbols-as-weakmap-keys/issues/21).
127 |
128 | ### [Well-Known](https://tc39.es/ecma262/multipage#sec-well-known-symbols) symbols
129 |
130 | Allowing well-known symbols doesn't seem so bad, since they 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 is 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`. Just because these will stay alive doesn't mean we disallow them as WeakMap keys.
131 |
132 | While 'registered' symbols can be detected using `Symbol.keyFor`, there is currently no built in predicate to test if a symbol is 'well-known' or not. If 'well-known' symbols were not allowed as keys in a WeakMap code would need to ensure it handles this potential abrupt completion.
133 |
134 | ## Support for Symbols in WeakRefs and FinalizationRegistry
135 |
136 | We should also support Symbols in WeakRefs and FinalizationRegistry. Not only is this consistent with Objects as WeakMap keys but it also enables user-land to build/demonstrate more advanced functionality. e.g. WeakMaps that support Records/Tuples as keys.
137 |
138 | ## Summing up
139 |
140 | 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.
141 |
142 | [rtp]: https://github.com/tc39/proposal-record-tuple
143 |
--------------------------------------------------------------------------------
/spec.emu:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | title: Symbol as WeakMap Keys Proposal
8 | stage: 4
9 | contributors:
10 | - Robin Ricard
11 | - Rick Button
12 | - Daniel Ehrenberg
13 | - Leo Balter
14 | - Caridy Patiño
15 | - Rick Waldron
16 | - Ashley Claymore
17 |
18 |
19 |
20 |
Executable Code and Execution Contexts
21 |
22 |
23 |
Processing Model of WeakRef and FinalizationRegistry Objects
32 | AddToKeptObjects (
33 | _object_: an Object,
34 | _value_: an Object or a Symbol,
35 | ): ~unused~
36 |
37 |
38 |
39 |
40 | 1. Let _agentRecord_ be the surrounding agent's Agent Record.
41 | 1. Append _object_ to _agentRecord_.[[KeptAlive]].
42 | 1. Append _value_ to _agentRecord_.[[KeptAlive]].
43 | 1. Return ~unused~.
44 |
45 |
46 | When the abstract operation AddToKeptObjects is called with a target object , or symbol, reference, it adds the target to a list that will point strongly at the target until ClearKeptObjects is called.
47 |
48 |
49 |
50 |
51 |
52 |
59 |
60 | 1. If Type(_v_) is Object, return *true*.
61 | 1. If Type(_v_) is Symbol, then
62 | 1. For each element _e_ of the GlobalSymbolRegistry List (see ), do
63 | 1. If SameValue(_e_.[[Symbol]], _v_) is *true*, return *false*.
64 | 1. Return *true*.
65 | 1. Return *false*.
66 |
67 |
68 |
This abstract operation determines if _v_ is considered to have an identity suitable for a weak reference. i.e. Is _v_ a valid candidate key of a WeakMap, or entry in a WeakSet, or target of a WeakRef or FinalizationRegistry.
69 |
'Registered symbols' are not considered to have a suitable identity because their identity is similar to strings. Implementations may attempt to collect them when they are unreachable.
70 |
'Well-Known symbols' return *true* - this should not be interpreted as evidence that using them as a key in a WeakMap will not result in a memory leak.
71 |
72 |
73 |
74 |
75 |
Modifications to the properties of WeakMap.prototype
76 |
77 |
78 |
WeakMap.prototype.delete ( _key_ )
79 |
The following steps are taken:
80 |
81 | 1. Let _M_ be the *this* value.
82 | 1. Perform ? RequireInternalSlot(_M_, [[WeakMapData]]).
83 | 1. Let _entries_ be the List that is _M_.[[WeakMapData]].
84 | 1. If Type(_key_) is not Object, return *false*.
85 | 1. If CanBeHeldWeakly(_key_) is *false*, return *false*.
86 | 1. For each Record { [[Key]], [[Value]] } _p_ of _entries_, do
87 | 1. If _p_.[[Key]] is not ~empty~ and SameValue(_p_.[[Key]], _key_) is *true*, then
88 | 1. Set _p_.[[Key]] to ~empty~.
89 | 1. Set _p_.[[Value]] to ~empty~.
90 | 1. Return *true*.
91 | 1. Return *false*.
92 |
93 |
94 |
95 |
96 |
WeakMap.prototype.get ( _key_ )
97 |
The following steps are taken:
98 |
99 | 1. Let _M_ be the *this* value.
100 | 1. Perform ? RequireInternalSlot(_M_, [[WeakMapData]]).
101 | 1. Let _entries_ be the List that is _M_.[[WeakMapData]].
102 | 1. If Type(_key_) is not Object, return *undefined*.
103 | 1. If CanBeHeldWeakly(_key_) is *false*, return *undefined*.
104 | 1. For each Record { [[Key]], [[Value]] } _p_ of _entries_, do
105 | 1. If _p_.[[Key]] is not ~empty~ and SameValue(_p_.[[Key]], _key_) is *true*, return _p_.[[Value]].
106 | 1. Return *undefined*.
107 |
108 |
109 |
110 |
111 |
WeakMap.prototype.has ( _key_ )
112 |
The following steps are taken:
113 |
114 | 1. Let _M_ be the *this* value.
115 | 1. Perform ? RequireInternalSlot(_M_, [[WeakMapData]]).
116 | 1. Let _entries_ be the List that is _M_.[[WeakMapData]].
117 | 1. If Type(_key_) is not Object, return *false*.
118 | 1. If CanBeHeldWeakly(_key_) is *false*, return *false*.
119 | 1. For each Record { [[Key]], [[Value]] } _p_ of _entries_, do
120 | 1. If _p_.[[Key]] is not ~empty~ and SameValue(_p_.[[Key]], _key_) is *true*, return *true*.
121 | 1. Return *false*.
122 |
123 |
124 |
125 |
126 |
WeakMap.prototype.set ( _key_, _value_ )
127 |
The following steps are taken:
128 |
129 | 1. Let _M_ be the *this* value.
130 | 1. Perform ? RequireInternalSlot(_M_, [[WeakMapData]]).
131 | 1. Let _entries_ be the List that is _M_.[[WeakMapData]].
132 | 1. If Type(_key_) is not Object, throw a *TypeError* exception.
133 | 1. If CanBeHeldWeakly(_key_) is *false*, throw a *TypeError* exception.
134 | 1. For each Record { [[Key]], [[Value]] } _p_ of _entries_, do
135 | 1. If _p_.[[Key]] is not ~empty~ and SameValue(_p_.[[Key]], _key_) is *true*, then
136 | 1. Set _p_.[[Value]] to _value_.
137 | 1. Return _M_.
138 | 1. Let _p_ be the Record { [[Key]]: _key_, [[Value]]: _value_ }.
139 | 1. Append _p_ as the last element of _entries_.
140 | 1. Return _M_.
141 |
142 |
143 |
144 |
145 |
146 |
Modifications to the properties of WeakSet.prototype
147 |
148 |
149 |
WeakSet.prototype.add ( _value_ )
150 |
The following steps are taken:
151 |
152 | 1. Let _S_ be the *this* value.
153 | 1. Perform ? RequireInternalSlot(_S_, [[WeakSetData]]).
154 | 1. If Type(_value_) is not Object, throw a *TypeError* exception.
155 | 1. If CanBeHeldWeakly(_value_) is *false*, throw a *TypeError* exception.
156 | 1. Let _entries_ be the List that is _S_.[[WeakSetData]].
157 | 1. For each element _e_ of _entries_, do
158 | 1. If _e_ is not ~empty~ and SameValue(_e_, _value_) is *true*, then
159 | 1. Return _S_.
160 | 1. Append _value_ as the last element of _entries_.
161 | 1. Return _S_.
162 |
163 |
164 |
165 |
166 |
WeakSet.prototype.delete ( _value_ )
167 |
The following steps are taken:
168 |
169 | 1. Let _S_ be the *this* value.
170 | 1. Perform ? RequireInternalSlot(_S_, [[WeakSetData]]).
171 | 1. If Type(_value_) is not Object, return *false*.
172 | 1. If CanBeHeldWeakly(_value_) is *false*, return *false*.
173 | 1. Let _entries_ be the List that is _S_.[[WeakSetData]].
174 | 1. For each element _e_ of _entries_, do
175 | 1. If _e_ is not ~empty~ and SameValue(_e_, _value_) is *true*, then
176 | 1. Replace the element of _entries_ whose value is _e_ with an element whose value is ~empty~.
177 | 1. Return *true*.
178 | 1. Return *false*.
179 |
180 |
181 |
182 |
183 |
WeakSet.prototype.has ( _value_ )
184 |
The following steps are taken:
185 |
186 | 1. Let _S_ be the *this* value.
187 | 1. Perform ? RequireInternalSlot(_S_, [[WeakSetData]]).
188 | 1. Let _entries_ be the List that is _S_.[[WeakSetData]].
189 | 1. If Type(_value_) is not Object, return *false*.
190 | 1. If CanBeHeldWeakly(_value_) is *false*, return *false*.
191 | 1. For each element _e_ of _entries_, do
192 | 1. If _e_ is not ~empty~ and SameValue(_e_, _value_) is *true*, return *true*.
193 | 1. Return *false*.
194 |
195 |
196 |
197 |
198 |
199 |
Modifications to WeakRef and FinalizationRegistry
200 |
201 |
202 |
WeakRef ( _target_ )
203 |
When the `WeakRef` function is called with argument _target_, the following steps are taken:
204 |
205 | 1. If NewTarget is *undefined*, throw a *TypeError* exception.
206 | 1. If Type(_target_) is not Object, throw a *TypeError* exception.
207 | 1. If CanBeHeldWeakly(_target_) is *false*, throw a *TypeError* exception.
208 | 1. Let _weakRef_ be ? OrdinaryCreateFromConstructor(NewTarget, *"%WeakRef.prototype%"*, « [[WeakRefTarget]] »).
209 | 1. Perform ! AddToKeptObjects(_target_).
210 | 1. Set _weakRef_.[[WeakRefTarget]] to _target_.
211 | 1. Return _weakRef_.
212 |
213 |
214 |
215 |
216 |
218 |
219 | 1. Let _finalizationRegistry_ be the *this* value.
220 | 1. Perform ? RequireInternalSlot(_finalizationRegistry_, [[Cells]]).
221 | 1. If Type(_target_) is not Object, throw a *TypeError* exception.
222 | 1. If CanBeHeldWeakly(_target_) is *false*, throw a *TypeError* exception.
223 | 1. If SameValue(_target_, _heldValue_) is *true*, throw a *TypeError* exception.
224 | 1. If Type(_unregisterToken_) is not Object, then
225 | 1. If CanBeHeldWeakly(_unregisterToken_) is *false*, then
226 | 1. If _unregisterToken_ is not *undefined*, throw a *TypeError* exception.
227 | 1. Set _unregisterToken_ to ~empty~.
228 | 1. Let _cell_ be the Record { [[WeakRefTarget]]: _target_, [[HeldValue]]: _heldValue_, [[UnregisterToken]]: _unregisterToken_ }.
229 | 1. Append _cell_ to _finalizationRegistry_.[[Cells]].
230 | 1. Return *undefined*.
231 |
232 |
233 |
234 |
235 |
237 |
238 | 1. Let _finalizationRegistry_ be the *this* value.
239 | 1. Perform ? RequireInternalSlot(_finalizationRegistry_, [[Cells]]).
240 | 1. If Type(_unregisterToken_) is not Object, throw a *TypeError* exception.
241 | 1. If CanBeHeldWeakly(_unregisterToken_) is *false*, throw a *TypeError* exception.
242 | 1. Let _removed_ be *false*.
243 | 1. For each Record { [[WeakRefTarget]], [[HeldValue]], [[UnregisterToken]] } _cell_ of _finalizationRegistry_.[[Cells]], do
244 | 1. If _cell_.[[UnregisterToken]] is not ~empty~ and SameValue(_cell_.[[UnregisterToken]], _unregisterToken_) is *true*, then
245 | 1. Remove _cell_ from _finalizationRegistry_.[[Cells]].
246 | 1. Set _removed_ to *true*.
247 | 1. Return _removed_.
248 |
249 |
250 |
251 |
--------------------------------------------------------------------------------
/README_old.md:
--------------------------------------------------------------------------------
1 | # Symbols as WeakMap keys
2 |
3 | Stage 1
4 |
5 | **Coauthors/champions**:
6 |
7 | - Robin Ricard (@rricard)
8 | - Rick Button (@rickbutton)
9 | - Daniel Ehrenberg (@littledan)
10 |
11 | [Spec text](https://arai-a.github.io/ecma262-compare/?pr=2038)
12 |
13 | ---
14 |
15 | This proposal aims to solve a problem space introduced by the [Record & Tuple Proposal][rtp]; how can we reference and access non-primitive values in a primitive?
16 |
17 | 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.
18 |
19 | 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,
20 |
21 | ----
22 |
23 | [Records & Tuples][rtp] can't contain objects, functions, or methods and will throw a `TypeError` when someone attempts to do it:
24 |
25 | ```js
26 | const server = #{
27 | port: 8080,
28 | handler: function (req) { /* ... */ }, // TypeError!
29 | };
30 | ```
31 |
32 | This limitation exists because the one of the **key goals** of the [Record & Tuple Proposal][rtp] is to have deep immutability guarantees and structural equality _by default_.
33 |
34 | 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.
35 |
36 | ## Userland solutions
37 |
38 | You can already escape the aforementioned constraints without additional language features.
39 |
40 | Instead of directly storing objects in a record or tuple, you can instead store some application domain specific information that can be used somewhere else to perform the desired action. For example, instead of embedding an `execute` function in an object like this example:
41 |
42 | ```js
43 | const object = {
44 | execute() {
45 | console.log("foo");
46 | },
47 | };
48 | object.execute();
49 | ```
50 |
51 | You can instead store an `action` that gets consumed by another function when needed:
52 | ```js
53 | const record = #{
54 | action: "foo",
55 | };
56 |
57 | function execute(record) {
58 | if (record.action === "foo") {
59 | console.log("foo");
60 | }
61 | }
62 |
63 | execute(record);
64 | ```
65 |
66 | As another example, if you want to store an object containing primitives in a record, you can store the individual properties themselves instead with the spread operator:
67 |
68 | Instead of:
69 | ```js
70 | const object = { foo: "bar" };
71 | const record = #{ object: object }; // TypeError
72 | ```
73 |
74 | Try this:
75 | ```js
76 | const object = { foo: "bar" };
77 | const record = #{ object: #{ ...object } };
78 | ```
79 |
80 | #### Centralizing references to objects
81 |
82 | If you can't convert everything to primitives, Records and Tuples directly, then a separate mapping, implemented in JavaScript, could be used to explain how some primitives should be interpreted in terms of objects. You can think of this as generalizing the interpretation of the action strings above.
83 |
84 | For example, a simple Array of references could be passed around, in parallel to a Record, and certain numbers in the Record could be treated as indices into that Array when needed.
85 |
86 | ```js
87 | const server = (() => {
88 | const handlerRef = 0;
89 | function handler(req) { /* ... */ }
90 | const structure = #{
91 | port: 8080,
92 | handler: handlerRef,
93 | };
94 | const references = [];
95 | references[handlerRef] = handler;
96 | return {
97 | structure,
98 | references,
99 | };
100 | })();
101 | server.references[server.structure.handler]({ /* ... */ });
102 | ```
103 |
104 | To access, explicit reference to the Array needs to be made. These references could be encapsulated in a bookkeeper class, to assist with dereferencing:
105 |
106 | ```js
107 | class RefBookkeeper {
108 | #references = [];
109 | ref(obj) {
110 | const idx = this.#references.length;
111 | this.#references[idx] = obj;
112 | return idx;
113 | }
114 | deref(sym) { return this.#references[sym]; }
115 | }
116 |
117 | // Usage
118 | const server = (() => {
119 | const references = new RefBookkeeper();
120 | const structure = #{
121 | port: 8080,
122 | handler: references.ref(function handler(req) { /* ... */ }),
123 | };
124 | return {
125 | structure,
126 | references,
127 | };
128 | })();
129 | server.references.deref(server.structure.handler)({ /* ... */ });
130 | ```
131 |
132 | You might want to reuse the same `RefBookkeeper` object across multiple server instances, so you don't have as much to keep track of for each instance:
133 |
134 | ```js
135 | globalThis.refs = new RefBookkeeper();
136 |
137 | // Usage
138 | const server = #{
139 | port: 8080,
140 | handler: refs.ref(function handler(req) { /* ... */ }),
141 | };
142 | refs.deref(server.handler)({ /* ... */ });
143 | ```
144 |
145 | See also [a larger worked example](https://github.com/rricard/proposal-refcollection/issues/5#issuecomment-619104173) demonstrating the kinds of code patterns where you'd want to be able to use a RefBookkeeper across several functions.
146 |
147 | However, this pattern would lead to some memory management issues: As the program runs, more and more things might get added to `refs`, the Array just gets longer and longer, and all of the entries in the Array have to be held in memory just in case someone calls `deref` to get them, even if no one is actually going to do that. But really, we should avoid referencing things that are no longer needed anymore, so the garbage collector can reclaim memory. There are two ways to do this:
148 | - If we reuse one global `RefBookkeeper` everywhere, then we need to manually delete unused entries when we're done with them, with an extra `delete` method added to the class, allowing the index to be reused. (We can't use FinalizationRegistry for this, since Numbers are never really dead.)
149 | - Otherwise, we could avoid using a global RefBookkeeper, and instead use smaller local ones, which we pass around in parallel to the Record/Tuple, as in the previous examples.
150 |
151 | Both of these are a bit unfortunate; it would be nice if you didn't have to worry about deleting entries from `RefBookkeeper`, and if you didn't have to pass it around in parallel with Records and Tuples. Further, it's not really optimal ergonomics to have to call `refs.deref(idx)` all the time, and to have to remember which numbers serve as an index into the RefBookkeeper and which are just meant to be numbers.
152 |
153 | # Boxing objects to place in Records and Tuples while avoiding bookeeping
154 |
155 | Overall, with the patterns described above, Records and Tuples can work with JavaScript objects in a variety of ways, such that many different problems can be solved with them directly. We don't believe that any additions are *needed* to make Records and Tuples significantly useful. However, to improve ergonomics further, this proposal exists to discuss various mechanisms to make simple, flexible references to JavaScript objects.
156 |
157 | ### The `box` primitive type
158 |
159 | This potential solution introduces a new primitive type called `box`. These primitives can be constructed with `Box(obj)`, and the object can be retrieved with `box.deref()`, which would return `obj`.
160 |
161 | #### Examples
162 |
163 | ```js
164 | const obj = { hello: "world" };
165 | const box = Box(obj);
166 | assert(typeof box === "box", "boxes are a new primitive type");
167 | assert(obj !== box, "boxes are not their boxed object");
168 | assert(obj.hello === box.deref().hello, "boxes can deref props");
169 | assert(obj === box.deref(), "boxes can deref the full object");
170 | ```
171 |
172 | ```js
173 | const server = #{
174 | port: 8080,
175 | handler: Box(function handler(req) { /* ... */ }),
176 | };
177 | server.handler.deref()({ /* ... */ });
178 | ```
179 |
180 | #### Semantics
181 |
182 | ##### Boxing the same object gives the same box
183 |
184 | ```js
185 | const obj = {};
186 | const box1 = Box(obj);
187 | const box2 = Box(obj);
188 | assert(box1 === box2);
189 | ```
190 |
191 | ##### Boxes remove the need for bookkeeping
192 |
193 | There's no need to pass around any kind of `RefBookkeeper` object; just call `.deref()` directly on the box that's in the Record or Tuple.
194 |
195 | JS's built-in garbage collector understands boxes, so you don't have to worry about keeping track of a separate reference to the object, or nulling that reference out when it's no longer needed. As long as you can access a box, calling `.deref()` on the box will give you access to the object it was created with.
196 |
197 | ##### Edge case: Calling `.deref()` on a Box when passed to a separate global object (Realm)
198 |
199 | When working in the context of multiple JS global objects (e.g., with a same-origin iframe, or the Node.js vm module), if a box is passed from one global object to another, then calling the `.deref()` method in the context of that other global object would lead to behavior as if the box is unrecognized (either returning `undefined` or throwing; bikeshedding welcome).
200 |
201 | It's possible to get around this by passing `Box.prototype.deref()` to that other global object. This method "grants the right" to dereference boxes created in the context of that global object. In this way, different global objects are a little bit isolated from each other, when it comes to `Box`.
202 |
203 | ##### Formalization of Box semantics
204 |
205 | Data model
206 | - Each Box has an associated [[Id]]
207 | - Each Realm has an associated weak mapping [[Id]] -> Boxed object
208 |
209 | API
210 | - Box(obj)
211 | - Create a new [[Id]], and write [[Id]] -> obj into the Realm's mapping
212 | - Return a Box value with an associated [[Id]]
213 | - Box.prototype.deref
214 | - If this Realm's mapping contains an entry for this.[[Id]]
215 | - Return the associated object
216 | - Else, return undefined
217 |
218 | ###### Sharing and isolating with Boxes
219 |
220 | `Box.prototype.deref` is the only way to get at that Realm's mapping of [[Id]] to objects. Two different Realms cannot see into each other's Boxes, unless one Realm gives the other Realm a reference to its `Box.prototype.deref` method. A Realm can pull off its `deref`, delete it, etc, to control access to the mapping.
221 |
222 | If using membrane-based isolation within a Realm, `Box()` + `Box.prototype.deref` bypasses the membrane. To preserve the membrane, the feature must be disabled early, by
223 | ```js
224 | delete Box.prototype.deref
225 | ```
226 | Some committee members want all TC39 features to be available within a single (potentially frozen) Realm, membrane-isolated world. This goal makes the Box proposal inviable.
227 |
228 |
229 | Our hope was that, the fact that object operations are used to access and call `Box.prototype.deref`, and this can be membrane-wrapped, would imply that the Box system would work great for membrane-based security--just run each membrane-enclosed piece of code in a separate Realm, and you can safely share Boxes, granting or denying access based on the access to the `Box` constructor or `deref` method.
230 |
231 | However, in systems which want to use membrane-based isolation *within* the same Realm, the whole feature would have to be turned off. This is to prevent boxes from "piercing" through membranes: a box primitive could be passed from one side of the membrane to the other (the membrane has no chance to intervene since boxes are not objects), and then it can simply be dereferenced. This would constitute an unmediated cross-membrane communication channel, going against the goals of the membrane system.
232 |
233 | All access to the contents of boxes goes through `Box.prototype.deref`. A Realm's `Box.prototype` is ambiently available within that Realm, since it's referenced from ToObject, as long as you have a box. Although it is possible to turn off this feature from JavaScript by removing that function, this seems unfortunate for two reasons:
234 | - If the Realm is already deeply frozen, it's no longer possible to mutate `Box.prototype`
235 | - It'd be unfortunate to lose the functionality!
236 |
237 |
238 | ### BoxMakers to avoid Realm-wide communication channels
239 |
240 | > Boxmaker, boxmaker, plan me no plans.
241 | >
242 | > I’m in no rush. Maybe I’ve learned
243 | >
244 | > Playing with boxes a girl can get burned.
245 | >
246 | > -- [A musical](http://www.exelana.com/lyrics/MatchmakerMatchmaker.html), probably
247 |
248 | Ultimately, what we want is, a way to construct and dereference boxes, without being attached to the Realm. It could be used something like this:
249 |
250 | ```js
251 | const Box = new BoxMaker();
252 |
253 | const obj = { hello: "world" };
254 | const box = Box(obj);
255 | assert(typeof box === "box", "boxes are a new primitive type");
256 | assert(obj !== box, "boxes are not their boxed object");
257 | assert(obj.hello === Box.deref(box).hello, "boxes can deref props");
258 | assert(obj === Box.deref(box), "boxes can deref the full object");
259 | ```
260 |
261 | ```js
262 | const server = #{
263 | port: 8080,
264 | handler: Box(function handler(req) { /* ... */ }),
265 | };
266 | Box.deref(server.handler)({ /* ... */ });
267 | ```
268 |
269 | This is definitely a bit uglier--it's annoying to have to instantiate your own `Box` constructor, call out to it explicitly to call `deref` as opposed to using method chaining, and to coordinate all your code to use the same `Box`. Still, if you reuse the same `Box` constructor across all your functions, they interact "for free" without any manual bookkeeping or passing around of auxiliary structures, as described with `RefBookkeeper` above.
270 |
271 | The need for a `Box`/`BoxMaker` separation is a direct consequence of the goal to avoid membranes within the same Realm that can use this feature.
272 |
273 | #### Another way to look at it: RefCollection
274 |
275 | Our initial attempt to provide a solution for this problem space was [RefCollection](https://github.com/rricard/proposal-refcollection), which is basically the same as `BoxMaker`, except that it uses Symbols instead of Box primitives.
276 |
277 | When you call the Symbol constructor, you get a new Symbol; ultimately it can serve just as well to reference the Object as a Box primitive does. The RefCollection API allocates the Symbol for you, just like `Box.ref` creates a box. They're quite similar, when it comes down to it!
278 |
279 | RefCollection is a bit simpler since it doesn't create a new primitive type, just reusing what we have. However, it's not possible to polyfill RefCollection without something new in the language: we run into the exact problem described with RefBookkeeper above, where it can fill up with junk that's not relevant anymore, if the same RefCollection keeps getting reused. (Refer to [this example](https://github.com/rricard/proposal-refcollection/issues/5#issuecomment-619104173) to see how you might want to continue using a RefCollection over time.)
280 |
281 | There was a bit of debate about the details of the API surface of RefCollection, e.g., as it would relate to reusable templates. This seems to point towards exposing a low-level primitive that could address these memory management issues, leaving JavaScript libraries and frameworks to develop the best API surface.
282 |
283 | ### `symbol` as `WeakMap` keys
284 |
285 | We propose to make `WeakMap` accept `symbol` as keys. This 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.
286 |
287 | ```js
288 | class RefBookkeeper {
289 | #references = new WeakMap();
290 | ref(obj) {
291 | // (Simplified; we may want to return an existing symbol if it's already there)
292 | const sym = Symbol();
293 | this.#references.set(sym, obj);
294 | return sym;
295 | }
296 | deref(sym) { return this.#references.get(sym); }
297 | }
298 | globalThis.refs = new RefBookkeeper();
299 |
300 | // Usage
301 | const server = #{
302 | port: 8080,
303 | handler: refs.ref(function handler(req) { /* ... */ }),
304 | };
305 | refs.deref(server.handler)({ /* ... */ });
306 | ```
307 |
308 | See [earlier discussion](https://github.com/tc39/ecma262/issues/1194) on Symbols as WeakMap keys--it remains controversial and not currently supported.
309 |
310 | Some questions to discuss about Symbols as WeakMap keys:
311 | - Can registered symbols be used as WeakMap keys? Some TC39 delegates have argued strongly in either direction. We see both "allowing" and "disallowing" as acceptable options.
312 | - 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.
313 | - 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).
314 | - 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.
315 |
316 | As starting points, we propose that all Symbols be allowed as WeakMap keys, WeakSet entries, and in WeakRefs and FinalizationRegistry.
317 |
318 | ## Summing up
319 |
320 | 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.
321 |
322 | [rtp]: https://github.com/tc39/proposal-record-tuple
323 | [rcp]: https://github.com/rricard/proposal-refcollection
324 |
--------------------------------------------------------------------------------