August 24, 2023·6 min read·engineeringtypescriptdisposaljavascriptdecorators

TypeScript 5.2 makes disposal follow lexical scope

Using declarations connect resource acquisition with deterministic cleanup. Their ordering and error behavior matter as much as the shorter syntax, and runtime support remains a separate requirement.

Cleanup code becomes difficult when several resources must be released after an early return or an exception. A finally block can establish the required path, but adding another resource also adds ordering and failure questions. If the first cleanup operation throws, later operations still need a defined outcome. TypeScript 5.2 implements a protocol and syntax that make those questions part of the language transformation.

The August 24 release supports the ECMAScript explicit resource management proposal. A using declaration registers disposal at the end of its containing scope. The object supplies a method named by Symbol.dispose, and TypeScript provides a Disposable type describing that protocol.

I prefer resource ownership that is visible near acquisition. TypeScript's surrounding tooling is useful for web development, but a type alone cannot guarantee that a caller remembers cleanup. Connecting the declaration to scope exit gives that obligation a concrete execution path.

Disposal is an operation distinct from collection

The resource-management protocol asks an object to release something at a defined point in control flow. A file or connection can require this even while a JavaScript object representing it remains reachable. The purpose is to perform cleanup when the scope ends, rather than infer when the program has stopped retaining every reference.

The object decides what its disposal method does. A file wrapper might close a handle. Another object could unregister a callback. The well-known symbol gives language-generated code a common way to invoke that behavior without requiring every API to use the same ordinary method name.

An existing method called close does not automatically satisfy the protocol. The object needs the relevant symbol-named method or an adapter that registers the cleanup. This is a library adoption boundary, even though the syntax appears at the call site.

The declaration creates a fixed binding, much like const. Its additional behavior concerns cleanup at scope exit. A developer still needs to choose the scope corresponding to the intended resource lifetime. Declaring a resource in an unnecessarily broad scope can keep it open longer than the operation requires.

The object can still have methods and state beyond its disposal method. Implementing Disposable adds a protocol to that ordinary object instead of replacing its application interface. This lets a library keep its existing operations while exposing a standard cleanup path. Callers can recognize the obligation from the return type, and the using declaration connects that declared capability to control flow at the point where the object is received.

Reverse order preserves acquisition dependencies

Resources declared with using are disposed in reverse order. The release announcement demonstrates nested scopes and early returns. Inner-scope resources are released when that inner scope ends, while resources in an outer scope remain until their own scope exits.

That ordering supports dependencies created during acquisition. A later resource may rely on an earlier one remaining available. Releasing the later resource first gives its cleanup access to the prerequisite until its own work finishes. The rule does not infer dependencies, but it supplies a predictable order that acquisition code can follow.

A declaration that execution never reaches does not register a resource. If control returns earlier, there is nothing to dispose for that later declaration. Conversely, an exception after a successful declaration still exits through disposal for the registered resource.

Here is a small example that demonstrates the ordering without depending on a particular file API. It assumes that the runtime provides Symbol.dispose or that it has been polyfilled before this code executes.

function resource(name: string): Disposable {
    return {
        [Symbol.dispose]() {
            console.log(name);
        }
    };
}

function work() {
    using outer = resource("outer");
    {
        using inner = resource("inner");
    }
}

Calling work logs inner before outer. The source makes the inner lifetime visible without repeating disposal at each exit. A real resource implementation would replace the logging method with the appropriate release operation.

Cleanup failures preserve earlier failures

An exception from the body and an exception from disposal can occur during the same exit. Simply replacing one with the other would lose information about the operation that first failed. The proposal supplies SuppressedError to represent this combination.

The release example places the disposal error in the error property and the earlier body error in suppressed. This makes the newest failure available without discarding the preceding one. Error handling needs to inspect that relationship when explaining why an operation failed during cleanup.

The protocol does not make a disposal implementation infallible. A close operation can still fail, and the application may need a policy for reporting that failure. What the language supplies is defined handling of competing exceptions and consistent attempts to dispose registered resources.

This matters when evaluating wrappers around older APIs. Moving two cleanup calls into one disposal method does not itself guarantee that both execute if the first throws. The method still needs correct internal control flow. Language-managed ordering governs registered resources, while each resource remains responsible for its own implementation.

Async disposal extends the lifetime through awaited cleanup

Some resources cannot finish cleanup synchronously. The release supports await using and Symbol.asyncDispose, with an AsyncDisposable type for the asynchronous protocol. Scope exit waits for the registered disposal work to complete.

This is different from merely invoking an async cleanup method and ignoring its promise. The surrounding operation retains the cleanup obligation through the await. Code after the scope can therefore depend on the disposal step having completed according to that protocol.

Await using can also work with synchronous disposables. That permits code managing a mixture of resource implementations to use the asynchronous arrangement where appropriate. Disposal ordering remains defined, including the waiting involved in each step.

An async function can consequently take longer to finish because cleanup is part of its completion. That is the intended lifetime contract. The return path includes releasing its scoped resources, so a caller awaiting it observes the operation after those releases have run.

Disposable stacks adapt existing cleanup functions

Creating a new class for every cleanup arrangement can add unnecessary structure. DisposableStack provides another way to register resources and callbacks. Its defer method accepts cleanup work, and the stack itself satisfies the disposal protocol.

The announcement recommends registering cleanup immediately after acquiring the resource. This limits the interval in which later work could fail before the cleanup obligation has been recorded. Reverse-order stack disposal then follows the same acquisition discipline as using declarations.

AsyncDisposableStack serves the corresponding asynchronous role. These objects are useful when the number of resources is determined dynamically or when existing APIs already provide ordinary cleanup functions. They make the registration explicit without requiring the resource's own type to change.

I find that adapter path important for adoption. Libraries can implement the symbol protocol over time, while callers still have a way to organize cleanup around established APIs. The mechanism does not require a wholesale rewrite of every resource wrapper before it becomes useful.

Compiler declarations do not install runtime objects

The release announcement says most runtimes do not yet support the feature natively. It lists the symbol protocols, disposable stacks and SuppressedError among the runtime facilities requiring support. Using the stack classes specifically requires those constructors to exist at execution time.

For using and await using alone, the announcement says the symbols can be enough alongside TypeScript's generated support. It demonstrates configuring a target of ES2022 or below and including esnext.disposable in lib. The declaration library lets the checker know the APIs, while runtime support supplies the actual values.

That separation is easy to miss because selecting lib can remove a type error. It does not add an implementation to a browser or server runtime. A deployment still needs the facilities exercised by the emitted code, initialized before the code registers its resources.

Decorator metadata has its own runtime protocol

TypeScript 5.2 also adds metadata to the newer decorator context. Decorators on a class share a metadata object, which becomes accessible through Symbol.metadata on the class. A framework can attach information to the object or use its identity as a key in a private WeakMap.

This supplies a communication mechanism between decorators and consumers of their metadata. It does not mean that every TypeScript type becomes runtime information. Decorator implementations decide what to store, and their consumers decide how to interpret it.

The announcement likewise calls for Symbol.metadata support or a polyfill and appropriate decorator declarations. Resource disposal and decorator metadata address different problems, but both demonstrate the boundary of TypeScript's role. The compiler can check and transform a proposed language mechanism while the runtime and libraries must implement the protocols on which that mechanism depends.