babel-plugin-dispose-sugar
A proof-of-concept implementation of Resource Disposal Syntax Sugar, a
Stage 0 TC39 proposal that adds dedicated syntax for declaring and invoking
Symbol.dispose / Symbol.asyncDispose:
class TempFile {
dispose { this.handle.close(); } // instead of [Symbol.dispose]() {...}
}
dispose file; // instead of file[Symbol.dispose]()
await dispose connection; // instead of await connection[Symbol.asyncDispose]()
dispose? cache; // instead of cache?.[Symbol.dispose]?.()
await dispose? cache; // instead of await cache?.[Symbol.asyncDispose]?.()
Please read "How this actually works" below before using this beyond a demo. It explains why this package needs a pre-parse transformation instead of relying on a normal Babel visitor.
How this actually works
Normal Babel plugins are visitors that run after @babel/parser has already
turned source text into an AST. dispose file; is not valid ECMAScript
today, so @babel/parser throws a SyntaxError on it before any plugin
gets a chance to run. Unlike JSX, TypeScript, or the pipeline operator,
there is no public API for a third-party package to teach @babel/parser
new grammar — those are wired into the parser's internals directly by the
Babel team. Adding real grammar support means forking @babel/parser.
This package works around this by using Babel 7's parserOverride hook. Before
@babel/parser sees the source, a lexical pre-pass rewrites the four
dispose forms into their already-valid JavaScript equivalents.
Once the source has been transformed into valid JavaScript, a real Babel
plugin (built with @babel/traverse and @babel/types) handles the parts
that benefit from AST awareness — generating collision-free temporary
variables and detecting duplicate Symbol.dispose definitions.
your source (with dispose syntax)
│
▼
┌────────────────────────┐ hand-rolled tokenizer, NOT regex substitution
│ src/preprocess.js │ (correctly ignores strings/comments/templates)
└────────────────────────┘
│ produces valid ES2022+ source, with __disposeOptionalSync(...)/
│ __disposeOptionalAsync(...) markers standing in for the two
│ "optional disposal" forms
▼
┌────────────────────────┐
│ @babel/parser.parse │
└────────────────────────┘
│ real AST
▼
┌────────────────────────┐ real @babel/traverse visitor:
│ src/index.js │ - expands the two marker calls into
│ (disposeSugarPlugin) │ `const _tempN = x; _tempN?.[Symbol.dispose]?.();`
│ │ using scope.generateUidIdentifier (guaranteed
│ │ collision-free, per the proposal's requirement)
│ │ - walks every ClassBody and throws a SyntaxError
│ │ on a duplicate [Symbol.dispose] / [Symbol.asyncDispose]
└────────────────────────┘
│
▼
┌────────────────────────┐
│ @babel/generator │
└────────────────────────┘
│
▼
final JavaScript
The two non-optional forms (dispose x / await dispose x) desugar to a
single expression with no new bindings, so the lexical pre-pass handles
them completely on its own — there's nothing left for the AST plugin to do.
The two optional forms (dispose? x / await dispose? x) need a temp
variable, which is exactly the kind of thing you want real scope analysis
for rather than string concatenation, so those go through the AST plugin.
Installation
npm install babel-plugin-dispose-sugar
This plugin requires:
- Node.js >= 16
- Babel 7 (
@babel/core >= 7.0.0)
Babel 6 (babel-core) is not supported because this plugin uses Babel 7's
parserOverride API.
The package internally uses:
@babel/parser, @babel/traverse, @babel/types, and
@babel/generator. These are installed automatically with the package.
@babel/core is a peer dependency because Babel provides the runtime
environment that loads the plugin.
Usage
Babel configuration
Add the plugin to your Babel config:
{
"plugins": [
"babel-plugin-dispose-sugar"
]
}
The convenience API (recommended)
const { transform } = require('babel-plugin-dispose-sugar/transform');
const { code } = transform(`
class TempFile {
#handle;
constructor(name) { this.#handle = open(name); }
dispose { this.#handle.close(); }
}
using file = new TempFile('data.txt');
if (someCondition) {
dispose file;
}
`, {
// Forwarded to @babel/parser. Useful for e.g. `using` declarations
// (Explicit Resource Management) or class private fields.
parserPlugins: ['explicitResourceManagement', 'classPrivateProperties'],
});
console.log(code);
class TempFile {
#handle;
constructor(name) {
this.#handle = open(name);
}
[Symbol.dispose]() {
this.#handle.close();
}
}
using file = new TempFile('data.txt');
if (someCondition) {
file[Symbol.dispose]();
}
transform() returns { code, intermediateCode, ast }. intermediateCode
is the valid-JS output of the lexical pre-pass, before the AST plugin runs —
handy for debugging or for demonstrating the two-stage pipeline to others.
Running just the lexical pre-pass
If you want to feed the result into your own @babel/core pipeline (e.g.
to combine it with other, unrelated Babel plugins), you can call the
pre-pass directly and hand its output to @babel/core yourself:
const babel = require('@babel/core');
const { transformDisposeSyntax } = require('babel-plugin-dispose-sugar/transform');
const validSource = transform.transformDisposeSyntax(rawSourceWithDisposeSyntax);
const { code } = babel.transformSync(validSource, {
plugins: [
require('babel-plugin-dispose-sugar/src/plugin'), // the bare plugin function
// ...your other plugins
],
});
(src/plugin.js is the Babel entry point. It exports the plugin function
and uses Babel 7's parserOverride hook to run the lexical pre-pass before
parsing. The src/index.js module contains the AST plugin and the
programmatic transform() API.)
Running the tests
npm test
This runs test.js, a small dependency-free test file (plain
node:assert, no test framework) covering:
- Class method declarations, sync and async, including alongside ordinary
methods/fields and with
static - Manual disposal, sync and async, including member chains and call expressions as operands
- Optional disposal, sync and async, including a hygiene check (multiple optional disposals in the same scope get distinct temp names) and a real runtime execution check that the operand is evaluated exactly once
- Edge cases: nested if/else branches, multiple dispose statements in a
row, dispose followed by line/block comments, the word "dispose"
appearing inside comments/strings/template literals (must be ignored),
and duplicate-
Symbol.disposedetection (including that it's scoped per-class, not global) - Negative tests proving
disposeis left alone as an ordinary identifier in every other context: variable declarations, function/class declarations, member access, reassignment, object property names,typeof, and bare expression statements
All 36 tests currently pass.
Project structure
babel-plugin-dispose-sugar/
├── src/
│ ├── tokenizer.js # hand-rolled JS tokenizer (strings/comments/templates/regex-aware)
│ ├── preprocess.js # token-stream pattern matching + source rewriting
│ ├── index.js # AST plugin + transform() convenience API
│ └── plugin.js # Babel plugin wrapper (parserOverride + re-export)
├── test.js
├── example.js
├── package.json
├── .babelrc # development config
└── README.md