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:
- Create a
DisposableStackwithusing - Try to create a resource (returns
Result) - If failed, return early—the disposer disposes what's been acquired
- If succeeded, add to the disposer with
disposer.use() - Repeat for additional resources
For async resources, use AsyncDisposableStack with await using.
API overview:
disposer.use(resource)— adds a disposable resourcedisposer.defer(fn)— adds a cleanup function (like Go'sdefer)disposer.adopt(value, cleanup)— wraps a non-disposable value with cleanupdisposer.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:
disposerwhile you are still registering resourcesdisposablesafter 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
- MDN: Resource management
- MDN: using statement
- MDN: DisposableStack
- MDN: AsyncDisposableStack
Result.test.tsfor comprehensive usage patterns- Resources for reference-counted shared resources with delayed disposal