npm.io
0.5.0 • Published 2d ago

babel-plugin-dispose-sugar

Licence
MIT
Version
0.5.0
Deps
4
Size
37 kB
Vulns
0
Weekly
0

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"
  ]
}
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.dispose detection (including that it's scoped per-class, not global)
  • Negative tests proving dispose is 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

Keywords