using and Symbol.dispose at the WASM edge
Rust drops at the closing brace. JavaScript cannot see that brace. Give it one.
<!-- hal:authoritative:yaml -->
Rust drops at the closing brace. JavaScript cannot see that brace. Give it one.
§I - Frame
Beat B for Duha session 17. Beat A taught Drop and Weak. This shell is the other side of the boundary those traits will meet once Rust compiles to WASM and a Deno or Bun script holds the result.
When wasm-bindgen exports a Rust struct, JavaScript receives a class that wraps a pointer into WASM memory. The Rust value lives until something calls its drop glue. The JS garbage collector does not know that memory exists. So the JS side needs its own Drop, and TypeScript 5.2 supplies one: Symbol.dispose plus the using declaration.
Three moves land by the end:
- The Disposable Shape: a class with a
[Symbol.dispose]()method thatimplements Disposable. - The Scope Brace:
usingcalls that method at scope exit, in reverse order, on every exit path. - The Backstop:
FinalizationRegistryfrees what you forgot, eventually, with no timing promise.
Done-criteria: Can write a Disposable handle, release it with using, and name the Rust Ch.15 move each piece mirrors.
§II - The Disposable Shape
Symbol.dispose is a well-known symbol, in the same family Cherny describes for Symbol.iterator: a fixed key the runtime looks up on your object. TypeScript names the shape Disposable. Declaring implements Disposable puts the check on the class itself, so a missing method fails at the class and not at some distant call site.
A stand-in for what a wasm-bindgen export gives you:
// Hand-written stand-in for a wasm-bindgen class: a JS handle to Rust-owned memory.
class Counter implements Disposable {
#ptr: number
constructor(ptr: number) {
this.#ptr = ptr
}
free(): void {
if (this.#ptr === 0) return // already freed
console.log(`free ${this.#ptr}`)
this.#ptr = 0 // real bindings call the Rust drop export here
}
[Symbol.dispose](): void {
this.free()
}
}
Current wasm-bindgen emits both methods on exported classes: free() for explicit release and [Symbol.dispose]() that forwards to it. Read the zeroed pointer as the JS answer to E0040. Rust stops a double drop by moving the value into std::mem::drop. JavaScript cannot move a value, so the handle guards itself.
§III - The Scope Brace
using binds like const and disposes when the scope ends:
function tick(early: boolean): void {
using a = new Counter(8)
using b = new Counter(16)
if (early) return
console.log("work")
} // either path prints: free 16, then free 8
Compare Beat A's Guard output. Rust dropped c before a; TypeScript disposes b before a. Both run last-in, first-out. The release notes spell out the exit paths: end of block, early return, and a thrown error, which is rethrown after disposal. If disposal also throws, you get a SuppressedError that carries both.
For a handle you want gone before the scope ends, call .free() directly. That is your std::mem::drop. A later using exit will call [Symbol.dispose]() again, and the zeroed pointer turns it into a no-op.
Setup, per the TS 5.2 notes and Cherny's polyfill rule:
// tsconfig.json
{
"compilerOptions": {
"target": "es2022",
"lib": ["es2022", "esnext.disposable"]
}
}
The lib entry tells the checker the runtime has Symbol.dispose. Cherny's rule from Ch.12 applies: a lib entry is a promise, so polyfill what the runtime lacks. The minimal line is Symbol.dispose ??= Symbol("Symbol.dispose"). Recent Deno and Bun releases run using in .ts files directly; if yours throws on a missing Symbol.dispose, add that line at the entry point. wasm-bindgen itself avoids writing the global and falls back to a private symbol.
§IV - The Backstop and the Map
If JS code drops a handle without free() or using, wasm-bindgen still has a net. Each exported object registers with a FinalizationRegistry, and when the garbage collector reclaims the JS wrapper, the registry callback frees the Rust side. The guide calls explicit deallocation always available; the registry is the fallback. It runs on the collector's schedule, and the spec does not promise it runs at all before the process exits. Treat it the way Beat A treats a leaked Rc cycle: survivable, and a bug.
WeakRef completes the pairing. ref.deref() returns the object or undefined, the same contract as Weak::upgrade() returning Option<Rc<T>>. Check before use.
| Rust Ch.15 | JS / TS at the WASM edge |
|---|---|
impl Drop | [Symbol.dispose]() |
| drop at scope end, reverse order | using, reverse order |
std::mem::drop(x) | x.free(), guarded against a second call |
Weak::upgrade() gives Option<Rc<T>> | WeakRef.deref() gives T or undefined |
leaked Rc cycle (memory safe, still a bug) | handle left to FinalizationRegistry (freed late, still a bug) |
§V - Proof and close
- Write
Counterwithimplements Disposable, a guardedfree(), and a[Symbol.dispose]()that calls it. - Open two handles with
using, return early, and predict the print order. - Call
.free()on one handle mid-scope and explain why scope exit does not free it twice. - Set
libforesnext.disposableand write the one-lineSymbol.disposepolyfill. - Fill the map table from memory, and say why
FinalizationRegistryis a net and not a plan.
Done-criteria: Can write a Disposable handle, release it with using, and name the Rust Ch.15 move each piece mirrors.
When a later Duha seats Leptos or a Deno WASM edge, bring this table to the boundary and check each row. Next Beat B pairs with whatever domain Beat A's Ch.16 threads open.