Resource management

For automatic cleanup of resources

Resources like database connections, file handles, and locks need cleanup. Traditional approaches are error-prone:

// 🚨 Manual cleanup is easy to forget
const conn = openConnection();
doWork(conn);
conn.close(); // What if doWork throws?
// 🚨 try/finally is verbose and doesn't compose
const conn = openConnection();
try {
  doWork(conn);
} finally {
  conn.close();
}

The using declaration is a new JavaScript feature that automatically disposes resources when they go out of scope:

const process = () => {
  using conn = openConnection();
  doWork(conn);
}; // conn is automatically disposed here

This works even if doWork throws—disposal is guaranteed.

Disposable resources

A resource is disposable if it has a [Symbol.dispose] method:

interface Disposable {
  [Symbol.dispose](): void;
}

For async cleanup, use [Symbol.asyncDispose] with await using:

interface AsyncDisposable {
  [Symbol.asyncDispose](): PromiseLike<void>;
}

Block scopes

Use block scopes to control exactly when resources are disposed:

const createLock = (name: string): Disposable => ({
  [Symbol.dispose]: () => {
    console.log(`unlock:${name}`);
  },
});

const process = () => {
  console.log("start");

  {
    using lock = createLock("a");
    console.log("critical-section-a");
  } // lock "a" released here

  console.log("between");

  {
    using lock = createLock("b");
    console.log("critical-section-b");
  } // lock "b" released here

  console.log("end");
};

// Output:
// "start"
// "critical-section-a"
// "unlock:a"
// "between"
// "critical-section-b"
// "unlock:b"
// "end"

Combining with Result

Result and Disposable are orthogonal:

  • Result answers: "Did the operation succeed?"
  • Disposable answers: "When do we clean up resources?"

Early returns from Result checks don't bypass using—disposal is guaranteed on any exit path (see below).

DisposableStack

When acquiring multiple resources, use DisposableStack to ensure all are cleaned up:

const processResources = (): Result<string, CreateResourceError> => {
  using disposer = new DisposableStack();

  const db = createResource("db");
  if (!db.ok) return db; // disposer disposes nothing yet

  disposer.use(db.value);

  const file = createResource("file");
  if (!file.ok) return file; // disposer disposes db

  disposer.use(file.value);

  return ok("processed");
}; // disposer disposes file, then db (reverse order)

The pattern is simple:

  1. Create a DisposableStack with using
  2. Try to create a resource (returns Result)
  3. If failed, return early—the disposer disposes what's been acquired
  4. If succeeded, add to the disposer with disposer.use()
  5. Repeat for additional resources

For async resources, use AsyncDisposableStack with await using.

API overview:

  • disposer.use(resource) — adds a disposable resource
  • disposer.defer(fn) — adds a cleanup function (like Go's defer)
  • disposer.adopt(value, cleanup) — wraps a non-disposable value with cleanup
  • disposer.move() — transfers ownership to caller

Ownership transfer

When a factory function creates resources for use elsewhere, use move() to transfer ownership:

interface OpenFiles extends Disposable {
  readonly handles: ReadonlyArray<FileHandle>;
}

const openFiles = (
  paths: ReadonlyArray<string>,
): Result<OpenFiles, OpenFileError> => {
  using disposer = new DisposableStack();

  const handles: Array<FileHandle> = [];
  for (const path of paths) {
    const file = open(path);
    if (!file.ok) return file; // Error: disposer cleans up opened files

    disposer.use(file.value);
    handles.push(file.value);
  }

  // Success: transfer ownership to caller
  const disposables = disposer.move();
  return ok({
    handles,
    [Symbol.dispose]: () => disposables.dispose(),
  });
};

const processFiles = (): Result<void, MyError> => {
  const result = openFiles(["a.txt", "b.txt", "c.txt"]);
  if (!result.ok) return result;

  using files = result.value;

  // ... use files.handles ...

  return ok();
}; // files cleaned up here

Without move(), the disposer would dispose files when openFiles returns, even on success.

This naming mirrors the two roles:

  • disposer while you are still registering resources
  • disposables after ownership has been moved to the returned object

Lifetime guards

Not every DisposableStack usage owns an external resource such as a file handle or connection.

Some helpers use a stack only to model lifetime and make assertNotDisposed available for synchronous methods that must fail fast after disposal.

This mirrors what C# does by default with ObjectDisposedException: using a disposed object is a programmer error and should fail fast. In Evolu we enforce that correctness constraint by convention with explicit assertNotDisposed guards.

The same rule applies to AsyncDisposable helpers that also expose synchronous methods: guard those synchronous methods with assertNotDisposed on the moved AsyncDisposableStack.

Async methods are different. For reusable async resources, create one internal Run with run.create() and use that Run for the resource's async operations. Disposing the resource then disposes that internal Run, which aborts in-flight child tasks, waits for them to settle, and rejects later calls through it.

If aborting one of those async operations would indicate a lifecycle bug rather than ordinary control flow, assert that explicitly with assertNotAborted.

interface RefCount extends Disposable {
  readonly increment: () => number;
  readonly getCount: () => number;
}

const createRefCount = (): RefCount => {
  using disposer = new DisposableStack();
  let count = 0;
  const disposables = disposer.move();

  return {
    increment: () => {
      assertNotDisposed(disposables);
      count += 1;
      return count;
    },

    getCount: () => {
      assertNotDisposed(disposables);
      return count;
    },

    [Symbol.dispose]: () => disposables.dispose(),
  };
};

This pattern is useful when the helper owns mutable state rather than an external resource, but use-after-dispose is still a programmer error.

Anti-patterns

Avoid writing disposal logic by hand when a stack can own it for you.

Manual disposed flags

// Avoid
interface Counter extends Disposable {
  readonly next: () => number;
}

const createCounter = (): Counter => {
  let isDisposed = false;
  let value = 0;

  return {
    next: () => {
      if (isDisposed) throw new Error("disposed");
      value += 1;
      return value;
    },
    [Symbol.dispose]: () => {
      if (isDisposed) return;
      isDisposed = true;
    },
  };
};

Prefer a stack plus assertNotDisposed.

Manual disposal arrays or loops

// Avoid
for (const resource of resources) {
  resource[Symbol.dispose]();
}

If one dispose throws, later resources are skipped. A DisposableStack preserves reverse-order disposal and continues cleanup even when an earlier dispose fails.

// Use
using disposer = new DisposableStack();
for (const resource of resources) {
  disposer.use(resource);
}

Ready to use

TypeScript 5.2+ implements the using keyword, and Evolu polyfills runtime resource management for environments that still need it (Safari and React Native), see polyfills setup.

Learn more