JavaScript's "destructors" or the explicit resource management

Published on

TypeScript

The limitations of the JavaScript's current resource management

JavaScript is a language with a garbage collection (GC). This means if any value becomes inaccessible it will be eventually deallocated from the memory. This implicit cleanup mechanism works well for a lot of cases and developers shouldn't worry about memory leaks as long as they don't leave references on no longer used resources.

However, this works well only if it's about deallocating the objects from the JS memory heap. Some resources, such as open file handles, also require additional custom logic for the resource deallocation, such as closing the open file handles. This is especially important in NodeJS.

Nowadays, in order to do this reliably, experienced developers usually write something like this:

let fileHandle; try { fileHandle = await fs.open('some_file_path', 'r'); // Do something // ... } finally { // Wrapping in finally guarantees that the // file handle will eventually close // even in a case of an error. filehandle?.close(); }

Why doesn't JavaScript support destructors?

As you see, this construct looks bulky. Also notice that fileHandle should be declared outside the try block in order to be accessible from the finally block. So why doesn't JavaScript support destructors which will cleanup the code eventually and allow us to avoid writing such boilerplate code? There are some possible reasons for this:

  1. It's now too late to interpret a function with a name "destructor" or any other name as a destructor, because it will break the backward compatibility. The only acceptable way is to add some symbol, such as Symbol.destructor.
  2. Even if we add a symbol for the destructor, the destructor will not be called immediately, since it takes time for the garbage collector to determine which objects are not reachable. This will not work very predictably and consistently across different JavaScript engines. Also, JavaScript already supports WeakRef and FinalizationRegistry which are not recommended to be used even by Mozilla because of the mentioned reason.
  3. Garbage collectors will have to somehow handle exceptions, infinite loops, stuck code and deadlocks in the destructor. This is unrealistic.

In short, forget about destructors in JavaScript.

Explicit resource management

In order to solve this problem a proposal was created for explicit resource management. Basically, the feature adds keyword using and 2 new symbols: Symbol.dispose and Symbol.asyncDispose.

How using, Symbol.dispose and Symbol.asyncDispose work

Suppose you have some resource class called DisposableResource and you want the cleanup method to be called automatically when the resource variable is no longer in the block scope. The simple synchronous case can be written like this:

class DisposableResource { constructor() { console.log('The resource is created'); } [Symbol.dispose]() { console.log('The resource is disposed'); } } { // Keyword "using" behaves like "const", but also tells the compiler to dispose // the object immediately after the declared variable becomes unreachable. using resource = new DisposableResource(); console.log('Doing something with the resource...'); } // After the end the resource block scope [Symbol.dispose]() is called.

[Symbol.dispose]() will be called even when an uncaught error is thrown:

class DisposableResource { constructor() { console.log('The resource is created'); } [Symbol.dispose]() { console.log('The resource is disposed'); } } { using resource = new DisposableResource(); throw "Error"; } // [Symbol.dispose]() will be called even when an uncaught error occurs.

The resources are disposed in the reverse order of their declaration to ensure that the older resources are available when disposing the newer resources which might depend on the older resources:

class DisposableResource { #name; constructor(name) { this.#name = name; console.log(`The resource ${this.#name} is created`); } [Symbol.dispose]() { console.log(`The resource ${this.#name} is disposed`); } } { using resourceA = new DisposableResource('A'); using resourceB = new DisposableResource('B'); console.log('Doing something with the resources...'); } // The resource B is disposed // The resource A is disposed

If an uncaught exception occurs during the disposal(s), SuppressedError is thrown.

class DisposableResource { #name; constructor(name) { this.#name = name; console.log(`The resource ${this.#name} is created`); } [Symbol.dispose]() { console.log(`The resource ${this.#name} is disposed`); throw `Error occurred during the disposal of ${this.#name}`; } } { using resourceA = new DisposableResource('A'); using resourceB = new DisposableResource('B'); } // SuppressedError

Asynchronous disposal

The disposal can also be asynchronous if it's used in an asynchronous function or the top level context. In this case we need to use keyword await using and Symbol.asyncDispose:

class DisposableResource { #name; constructor(name) { this.#name = name; console.log(`The resource ${this.#name} is created`); } [Symbol.dispose]() { console.log(`The resource ${this.#name} is disposed`); } async [Symbol.asyncDispose]() { console.log(`The resource ${this.#name} is disposed asynchronously`); } } { using resourceA = new DisposableResource('A'); using resourceB = new DisposableResource('B'); await using resourceC = new DisposableResource('C'); await using resourceD = new DisposableResource('D'); console.log('Doing something with the resources...'); } // The resource D is disposed asynchronously // The resource C is disposed asynchronously // The resource B is disposed // The resource A is disposed

If [Symbol.asyncDispose] is missing, [Symbol.dispose] will be called instead:

class DisposableResource { #name; constructor(name) { this.#name = name; console.log(`The resource ${this.#name} is created`); } [Symbol.dispose]() { console.log(`The resource ${this.#name} is disposed`); } } { using resourceA = new DisposableResource('A'); using resourceB = new DisposableResource('B'); await using resourceC = new DisposableResource('C'); await using resourceD = new DisposableResource('D'); console.log('Doing something with the resources...'); } // The resource D is disposed // The resource C is disposed // The resource B is disposed // The resource A is disposed

Disposal in for loops

JavaScript also adds a nice syntax for using disposal for each item in the loop, here are examples for all combinations of sync/async loops and sync/async disposals:

class DisposableResource { #name; constructor(name) { this.#name = name; console.log(`The resource ${this.#name} is created`); } [Symbol.dispose]() { console.log(`The resource ${this.#name} is disposed`); } async [Symbol.asyncDispose]() { console.log(`The resource ${this.#name} is disposed asynchronously`); } } function * syncGeneratorSyncDisposal() { yield new DisposableResource('A'); yield new DisposableResource('B'); } function * syncGeneratorAsyncDisposal() { yield new DisposableResource('C'); yield new DisposableResource('D'); } async function * asyncGeneratorSyncDisposal() { yield new DisposableResource('E'); yield new DisposableResource('F'); } async function * asyncGeneratorAsyncDisposal() { yield new DisposableResource('G'); yield new DisposableResource('H'); } for (using resource of syncGeneratorSyncDisposal()) { // Do something with the resource } // The resource A is created // The resource A is disposed // The resource B is created // The resource B is disposed for (await using resource of syncGeneratorAsyncDisposal()) { // Do something with the resource } // The resource C is created // The resource C is disposed asynchronously // The resource D is created // The resource D is disposed asynchronously for await (using resource of asyncGeneratorSyncDisposal()) { // Do something with the resource } // The resource E is created // The resource E is disposed // The resource F is created // The resource F is disposed for await (await using resource of asyncGeneratorAsyncDisposal()) { // Do something with the resource } // The resource G is created // The resource G is disposed asynchronously // The resource H is created // The resource H is disposed asynchronously

DisposableStack and AsyncDisposableStack

As a bonus we have 2 built-in classes that help us to manage the resources: DisposableStack and AsyncDisposableStack. They have the following main methods and properties:

  1. disposed. A getter that returns a boolean indicating whether the stack has been disposed.
  2. dispose() (DisposableStack only). Disposes the stack.
  3. disposeAsync() (AsyncDisposableStack only). Disposes the stack asynchronously, returns a promise.
  4. use(value). Adds a disposable resource to the top of the stack. Has no effect if provided null or undefined.
  5. adopt(value, onDispose). Adds a non disposable resource to the top of the stack and an associated callback. The callback will be called with the value as an argument when the stack is disposed.
  6. defer(onDispose). Adds a callback which is called after the stack is disposed.
  7. move(). Returns a new disposable stack with the added items and empties the current stack.

DisposableStack and AsyncDisposableStack are useful for grouping the resources into a single object. When they're disposed they dispose the added resources in the reverse order.

Here are some examples:

class DisposableResource { #name; constructor(name) { this.#name = name; console.log(`The resource ${this.#name} is created`); } [Symbol.dispose]() { console.log(`The resource ${this.#name} is disposed`); } async [Symbol.asyncDispose]() { console.log(`The resource ${this.#name} is disposed asynchronously`); } } { using syncStack = new DisposableStack(); await using asyncStack = new AsyncDisposableStack(); syncStack.use(new DisposableResource('A')); syncStack.use(new DisposableResource('B')); asyncStack.use(new DisposableResource('C')); asyncStack.use(new DisposableResource('D')); } console.log('All resources are disposed'); // The resource A is created // The resource B is created // The resource C is created // The resource D is created // The resource D is disposed asynchronously // The resource C is disposed asynchronously // The resource B is disposed // The resource A is disposed // All resources are disposed

Conclusion

Explicit resource management is a very nice feature to have, especially considering that JavaScript cannot support destructors. It allows to write clean and reliable code for resource cleanups. Currently this feature is not supported across all the mainstream browsers, so use it with caution. This feature is very useful for NodeJS because on the server side you often deal with resources that need some logic for cleanups, such as files and locks. Starting from NodeJS 24 you can use this feature with caution. Some APIs already have this experimental feature integrated, such as file handles. Chrome 134+ also support this feature.





Read previous