TypeScript `using` in Real Codebases: Database Connections, File Handles, and Async Disposal Done Right
The `using` keyword eliminates resource leak footguns in production TypeScript. Learn how to implement disposable database connections, file handles, and async cleanup patterns that replace brittle try/finally blocks.
TypeScript using in Real Codebases: Database Connections, File Handles, and Async Disposal Done Right
Most resource leak bugs stem from a single assumption: developers remember to close what they open. Production codebases overflow with database connections held open by early returns, file handles left dangling after exceptions, and transaction scopes rolled back inconsistently because the cleanup logic sits five functions away from the acquisition site.
The using keyword shipped in ES2026 and TypeScript 5.2 eliminates this entire class of failure by guaranteeing disposal at scope exit. When a resource marked with using goes out of scope, the runtime calls its Symbol.dispose method automatically. No try/finally scaffolding. No manual cleanup tracking. No leaked connections because an engineer forgot to close a stream in the error path.
flowchart LR
A("Acquire database connection")
B("Execute query")
C("Early return on error")
D("Connection stays open")
style D stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
Traditional resource management requires explicit cleanup in every exit path. Miss one and the connection leaks. Add a new return statement and the leak reappears. The cognitive load compounds in async contexts where finally blocks must await disposal calls and race conditions turn subtle.
flowchart LR
A("Acquire database connection")
B("using connection = pool.acquire()")
C("Execute query")
D("Scope ends, dispose called")
style D stroke:#34d399,fill:#0b3b2e,color:#d1fae5
style B stroke:#c084fc,fill:#3b0764,color:#f3e8ff,stroke-width:4px
The using declaration makes disposal deterministic. The connection closes when the block ends, whether by normal return, throw, or break. The disposal happens in the correct order when multiple resources stack. Async disposal via await using handles asynchronous cleanup without race conditions. The pattern scales from file handles to distributed locks without new footguns.
Key Takeaways
- The
usingkeyword guarantees disposal at scope exit, eliminating the entire class of resource leaks caused by missed cleanup in early-return or exception paths. await usinghandles async disposal correctly by awaiting theSymbol.asyncDisposemethod before continuing execution, preventing race conditions in asynchronous resource cleanup.- Database connection pools, file streams, and transaction scopes become dramatically simpler when implemented as disposable resources instead of manual try/finally scaffolding.
- Multiple
usingdeclarations in the same scope dispose in reverse order of declaration, matching the stack-unwinding semantics developers expect from nested resource acquisition. - Migration to
usingpatterns requires careful attention to existing disposal timing contracts, especially in codebases where manual cleanup order encodes business logic or synchronization guarantees.
The using and await using Keywords: Syntax and Symbol.dispose Fundamentals
The using keyword marks a variable declaration as disposable. When the containing block exits, the runtime calls the Symbol.dispose method on the value. The disposal happens synchronously. If the method throws, the error propagates after the block completes.
class FileHandle {
constructor(private fd: number) {}
[Symbol.dispose]() {
// Called automatically at scope exit
closeSync(this.fd);
}
}
function processFile(path: string) {
using handle = new FileHandle(openSync(path));
// Use the file handle
// Disposal happens here when scope ends
}The await using variant handles asynchronous disposal. The Symbol.asyncDispose method returns a promise. The runtime awaits it before continuing. This matters for network connections, database transactions, and distributed locks where cleanup requires async operations.
class DatabaseConnection {
constructor(private conn: Connection) {}
async [Symbol.asyncDispose]() {
// Called automatically at scope exit
await this.conn.close();
}
}
async function runQuery(sql: string) {
await using conn = await pool.acquire();
return await conn.query(sql);
// Disposal awaited here before function returns
}The disposal order matters. When multiple using declarations exist in the same scope, they dispose in reverse order of declaration. This matches the stack-unwinding semantics developers expect from nested resource acquisition.
sequenceDiagram
participant Code
participant Runtime
participant Resource1
participant Resource2
Code->>Resource1: using r1 = acquire()
Code->>Resource2: using r2 = acquire()
Note over Code: Scope ends
Runtime->>Resource2: Symbol.dispose()
Resource2-->>Runtime: disposed
Runtime->>Resource1: Symbol.dispose()
Resource1-->>Runtime: disposed
The disposal happens even when exceptions occur. If the block throws, the runtime calls Symbol.dispose on all declared resources before propagating the error. If disposal itself throws, the runtime aggregates the errors using SuppressedError. The original error remains the primary exception and the disposal error attaches as the suppressed property.
Database Connection Pools: Implementing Disposable Connection Wrappers
Database connection leaks destroy production systems. A connection held open for ten seconds too long under load means hundreds of requests queued behind it. Teams add connection pool monitoring, implement timeouts, and still find connections leaking in error paths because manual cleanup logic lives three layers away from the acquisition site.
The disposable connection pattern wraps pool connections with automatic release. The wrapper acquires a connection on construction and releases it in Symbol.asyncDispose. When the connection goes out of scope, it returns to the pool immediately. No timeouts. No manual release calls. No leaked connections because an engineer forgot to call release() in the error handler.
class DisposableConnection {
private constructor(
private conn: PoolConnection,
private pool: Pool
) {}
static async acquire(pool: Pool): Promise<DisposableConnection> {
const conn = await pool.connect();
return new DisposableConnection(conn, pool);
}
async query<T>(sql: string, params?: unknown[]): Promise<T[]> {
return await this.conn.query<T>(sql, params);
}
async [Symbol.asyncDispose]() {
await this.pool.release(this.conn);
}
}
async function fetchUsers(ids: number[]): Promise<User[]> {
await using conn = await DisposableConnection.acquire(pool);
return await conn.query('SELECT * FROM users WHERE id = ANY($1)', [ids]);
// Connection released here automatically
}The pattern eliminates the try/finally ceremony. Traditional code requires explicit release in every exit path. Add a new return statement and the release disappears. Throw an exception and the connection leaks unless the finally block catches it. The disposable wrapper makes leaks impossible by construction.
Transaction scopes become simpler. A transaction wrapper handles begin, commit, rollback, and connection release as a single disposable resource. The transaction commits on normal scope exit and rolls back on exception. The connection returns to the pool in both cases.
class Transaction {
private committed = false;
private constructor(
private conn: DisposableConnection,
private pool: Pool
) {}
static async begin(pool: Pool): Promise<Transaction> {
const conn = await DisposableConnection.acquire(pool);
await conn.query('BEGIN');
return new Transaction(conn, pool);
}
async query<T>(sql: string, params?: unknown[]): Promise<T[]> {
return await this.conn.query<T>(sql, params);
}
async commit(): Promise<void> {
await this.conn.query('COMMIT');
this.committed = true;
}
async [Symbol.asyncDispose]() {
if (!this.committed) {
await this.conn.query('ROLLBACK');
}
await this.conn[Symbol.asyncDispose]();
}
}
async function transferFunds(from: number, to: number, amount: number) {
await using tx = await Transaction.begin(pool);
await tx.query('UPDATE accounts SET balance = balance - $1 WHERE id = $2', [amount, from]);
await tx.query('UPDATE accounts SET balance = balance + $1 WHERE id = $2', [amount, to]);
await tx.commit();
// Rollback happens automatically if commit not called
}The transaction commits only when explicitly called. If any query throws or the function returns early, the transaction rolls back. The connection returns to the pool after rollback completes. The pattern guarantees cleanup without manual error handling scaffolding.
File Handles and Stream Management: When using Beats try/finally
File handle leaks manifest as "too many open files" errors under load. A stream left open after an exception means one less available file descriptor. Multiply by concurrent requests and the process hits ulimit within seconds. Teams add manual close calls, wrap operations in try/finally, and still find handles leaking when new error paths appear.
The disposable stream pattern makes leaks impossible. The wrapper opens the stream on construction and closes it in Symbol.dispose. When the stream goes out of scope, it closes immediately. No try/finally. No manual tracking. No handle leaks because an engineer forgot to close in the error path.
class DisposableReadStream {
private constructor(private stream: ReadStream) {}
static open(path: string): DisposableReadStream {
return new DisposableReadStream(createReadStream(path));
}
async read(): Promise<Buffer> {
const chunks: Buffer[] = [];
for await (const chunk of this.stream) {
chunks.push(chunk);
}
return Buffer.concat(chunks);
}
[Symbol.dispose]() {
this.stream.destroy();
}
}
function processLogFile(path: string): void {
using stream = DisposableReadStream.open(path);
const content = await stream.read();
// Process content
// Stream closed here automatically
}The pattern works for write streams with buffering guarantees. A disposable write stream flushes and closes in disposal. The flush completes before disposal returns, ensuring data reaches disk before the function exits. Traditional code requires explicit flush calls before close, creating another footgun.
class DisposableWriteStream {
private constructor(private stream: WriteStream) {}
static open(path: string): DisposableWriteStream {
return new DisposableWriteStream(createWriteStream(path));
}
write(data: string | Buffer): void {
this.stream.write(data);
}
async [Symbol.asyncDispose]() {
return new Promise<void>((resolve, reject) => {
this.stream.end((err) => {
if (err) reject(err);
else resolve();
});
});
}
}
async function writeReport(path: string, data: ReportData): Promise<void> {
await using stream = DisposableWriteStream.open(path);
stream.write(JSON.stringify(data));
// Stream flushed and closed here automatically
}The async disposal ensures the flush completes. If the flush fails, the error propagates. If the function throws before disposal, the stream still flushes and closes. The pattern eliminates the entire class of bugs where data sits in buffers because the stream closed without flushing.
Temporary file management becomes simpler. A disposable temporary file creates the file on construction and deletes it in disposal. The file disappears when it goes out of scope, whether by normal return or exception. No manual cleanup. No leaked temp files because an engineer forgot to delete in the error path.
class TemporaryFile {
readonly path: string;
constructor() {
this.path = join(tmpdir(), `temp-${randomUUID()}.tmp`);
writeFileSync(this.path, '');
}
[Symbol.dispose]() {
unlinkSync(this.path);
}
}
function processWithTempFile(data: Buffer): Result {
using temp = new TemporaryFile();
writeFileSync(temp.path, data);
// Process the file
return result;
// Temp file deleted here automatically
}Async Disposal Patterns: Lock Management, Transaction Scopes, and Worker Threads
Distributed locks require precise acquisition and release timing. Hold a lock too long and throughput collapses. Release too early and race conditions appear. Traditional lock management wraps acquisition in try/finally blocks and hopes engineers remember to release in every exit path. Add an early return and the lock leaks. Throw an exception and the release disappears.
The disposable lock pattern guarantees release at scope exit. The lock acquires on construction and releases in Symbol.asyncDispose. When the lock goes out of scope, it releases immediately. No manual release calls. No leaked locks because an engineer forgot to release in an error handler.
class DistributedLock {
private constructor(
private key: string,
private redis: Redis
) {}
static async acquire(redis: Redis, key: string): Promise<DistributedLock> {
const acquired = await redis.set(key, '1', 'NX', 'EX', 10);
if (!acquired) {
throw new Error('Lock already held');
}
return new DistributedLock(key, redis);
}
async [Symbol.asyncDispose]() {
await this.redis.del(this.key);
}
}
async function processExclusively(userId: number): Promise<void> {
await using lock = await DistributedLock.acquire(redis, `user:${userId}`);
// Critical section here
// Lock released here automatically
}flowchart LR
A("Request arrives")
B("await using lock = acquire()")
C("Execute critical section")
D("Scope ends")
E("Lock released to redis")
F("Next request unblocked")
style B stroke:#c084fc,fill:#3b0764,color:#f3e8ff,stroke-width:4px
style E stroke:#34d399,fill:#0b3b2e,color:#d1fae5
The disposal happens even when exceptions occur. If the critical section throws, the lock releases before the error propagates. If the lock acquisition fails, no disposal occurs because the lock never entered scope. The pattern eliminates the entire class of deadlocks caused by locks held after exceptions.
Worker thread pools benefit from the same pattern. A disposable worker acquires a thread on construction and releases it in disposal. When the worker goes out of scope, the thread returns to the pool. No manual release. No thread leaks because an engineer forgot to terminate in an error path.
class DisposableWorker {
private constructor(private worker: Worker) {}
static async spawn(script: string): Promise<DisposableWorker> {
const worker = new Worker(script);
await new Promise((resolve) => worker.once('online', resolve));
return new DisposableWorker(worker);
}
async execute<T>(task: Task): Promise<T> {
return new Promise((resolve, reject) => {
this.worker.postMessage(task);
this.worker.once('message', resolve);
this.worker.once('error', reject);
});
}
async [Symbol.asyncDispose]() {
await this.worker.terminate();
}
}
async function processInWorker(data: Buffer): Promise<Result> {
await using worker = await DisposableWorker.spawn('./worker.js');
return await worker.execute({ type: 'process', data });
// Worker terminated here automatically
}The pattern scales to nested resource acquisition. Multiple using declarations stack naturally. Each resource disposes in reverse order of acquisition when the scope ends. Disposal order matches the dependency graph automatically.
Common Pitfalls: Early Returns, Nested Scopes, and Disposal Order Guarantees
The using keyword eliminates manual cleanup but introduces new failure modes. Developers expect disposal to happen at function exit, not at block exit. When a using declaration sits inside a conditional or loop, disposal happens when the block ends, not when the function returns. This creates surprising behavior when resources outlive their intended scope.
async function processItems(items: Item[]): Promise<void> {
for (const item of items) {
await using lock = await DistributedLock.acquire(redis, item.id);
// Lock released here at end of loop iteration
await processItem(item);
}
// Locks already released, not here
}The lock releases at the end of each loop iteration, not at function exit. If the processing requires the lock to persist across iterations, the pattern breaks. Developers must explicitly widen the scope by moving the declaration outside the loop or restructuring the logic to acquire once.
flowchart LR
A("Start loop iteration")
B("using lock = acquire()")
C("Process item")
D("Block ends")
E("Lock released")
F("Next iteration starts")
style E stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
Nested scopes compound the confusion. When a using declaration sits inside a try block, disposal happens when the try block ends, not when the function returns. If the catch block needs the resource, it no longer exists. If the finally block expects to clean up the resource, disposal already happened.
async function attemptOperation(): Promise<void> {
try {
await using conn = await DisposableConnection.acquire(pool);
await conn.query('SELECT 1');
// Connection released here when try block ends
} catch (error) {
// Connection already disposed, cannot use here
console.error('Operation failed');
}
}The solution requires moving the using declaration outside the try block. This widens the disposal scope to encompass the entire function. The connection remains available in the catch block. Disposal happens after the catch block completes.
Disposal order guarantees matter in complex scenarios. Multiple using declarations dispose in reverse order of declaration. If disposal order encodes business logic or synchronization requirements, the declaration order becomes load-bearing. Reorder declarations and the disposal order changes, potentially breaking correctness.
async function nestedResources(): Promise<void> {
await using lock = await DistributedLock.acquire(redis, 'key');
await using conn = await DisposableConnection.acquire(pool);
// Operations here
// conn disposes first, then lock
}The connection disposes before the lock. If the connection disposal requires the lock to remain held, the order breaks. Developers must structure declarations to match the required disposal order explicitly. The compiler provides no warnings when disposal order matters for correctness.
flowchart LR
A("Declare resource A")
B("Declare resource B")
C("Scope ends")
D("B disposes first")
E("A disposes second")
F("Order mismatch breaks correctness")
style F stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
Early returns inside using scopes behave correctly but require mental model adjustment. When a function returns early from a block containing using declarations, disposal happens before the return completes. The disposal runs synchronously for using and awaits for await using. This guarantees cleanup happens before the function exits but changes the execution order developers expect from traditional try/finally patterns.
Migration Strategy: Converting Existing Resource Code to using Without Breaking Production
Migrating existing resource management to using patterns requires careful attention to disposal timing. Production code encodes subtle contracts around when cleanup happens. Change disposal timing and race conditions appear. Change disposal order and synchronization breaks.
The safest migration path converts one resource type at a time. Start with leaf resources that have no dependencies on other cleanup. Database connections, file handles, and temporary files make good initial candidates because their disposal rarely depends on other resources being alive.
flowchart LR
A("Identify leaf resource")
B("Implement Symbol.dispose")
C("Deploy disposable wrapper")
D("Convert call sites incrementally")
E("Remove manual cleanup")
style C stroke:#c084fc,fill:#3b0764,color:#f3e8ff,stroke-width:4px
style E stroke:#34d399,fill:#0b3b2e,color:#d1fae5
The disposable wrapper sits alongside existing cleanup code during migration. Both paths coexist. Call sites convert incrementally. The wrapper includes assertions to catch double-disposal bugs where manual cleanup runs alongside automatic disposal.
class MigrationConnection {
private disposed = false;
constructor(private conn: PoolConnection, private pool: Pool) {}
async query<T>(sql: string, params?: unknown[]): Promise<T[]> {
if (this.disposed) {
throw new Error('Connection already disposed');
}
return await this.conn.query<T>(sql, params);
}
async release(): Promise<void> {
// Legacy manual cleanup path
await this[Symbol.asyncDispose]();
}
async [Symbol.asyncDispose]() {
if (this.disposed) return;
this.disposed = true;
await this.pool.release(this.conn);
}
}The migration wrapper supports both manual and automatic cleanup. Call sites using manual cleanup continue working. Call sites using await using work correctly. The disposed flag prevents double-cleanup. Once all call sites convert, the manual cleanup method disappears.
Disposal order migration requires mapping existing cleanup sequences. If manual cleanup runs in a specific order to prevent race conditions, the using declarations must appear in the same order. The compiler provides no validation. Teams must document disposal order requirements explicitly.
// Before: manual cleanup order matters
const lockA = await acquireLockA();
const lockB = await acquireLockB();
try {
// operations
} finally {
await releaseLockB(lockB); // Must release B before A
await releaseLockA(lockA);
}
// After: declaration order encodes disposal order
await using lockA = await DisposableLockA.acquire();
await using lockB = await DisposableLockB.acquire();
// lockB disposes before lockA automaticallyThe disposal order reverses declaration order. When manual cleanup required releasing B before A, the using declarations must declare A before B. This inversion confuses engineers familiar with the manual cleanup order. Documentation must highlight the reversal explicitly.
Testing disposal behavior requires synthetic scope boundaries. Production disposal happens at scope exit, which occurs at unpredictable times. Tests must force disposal by creating explicit blocks or using dispose helpers that trigger disposal manually.
test('connection disposes on scope exit', async () => {
let disposed = false;
{
await using conn = new TestConnection(() => { disposed = true; });
expect(disposed).toBe(false);
}
expect(disposed).toBe(true);
});The test creates a synthetic block to trigger disposal. The disposal callback sets a flag. The test verifies the flag transitions at the expected time. Without the explicit block, disposal timing becomes non-deterministic and the test flakes.
Frequently Asked Questions
Can using declarations appear in loops without causing disposal on each iteration?
No. A using declaration inside a loop body creates a new resource instance on each iteration and disposes it when the iteration completes. If the resource must persist across iterations, move the declaration outside the loop or restructure the logic to acquire once and reuse.
Does await using block the event loop during disposal?
Yes. When a scope containing await using exits, execution suspends until the Symbol.asyncDispose method completes. This ensures cleanup finishes before control returns to the caller. If disposal takes significant time, it delays subsequent operations. Profile disposal timing in production to catch unexpectedly slow cleanup.
What happens when disposal itself throws an exception?
If a block exits normally and disposal throws, the disposal error propagates as the primary exception. If the block exits via throw and disposal also throws, the runtime creates a SuppressedError containing both the original exception and the disposal error. The original error remains primary and the disposal error attaches as the suppressed property.
Can multiple using declarations reference the same resource instance?
Yes, but disposal runs multiple times. Each using declaration tracks disposal independently. If multiple declarations reference the same object, Symbol.dispose runs once per declaration. Implement idempotent disposal or track disposed state internally to prevent double-cleanup bugs.
Does TypeScript enforce that a class implements Symbol.dispose when used with using?
TypeScript 5.2+ requires the type to implement the Disposable or AsyncDisposable interface when used with using or await using. The compiler errors if the symbol method is missing. This catches usage errors at compile time instead of runtime. Older TypeScript versions provide no type safety and fail at runtime if the method is absent.
Conclusion: When to Reach for using and When Traditional Patterns Still Win
The using keyword eliminates resource leak footguns in scenarios where disposal timing maps to scope boundaries. Database connections, file handles, distributed locks, and transaction scopes benefit immediately. The pattern guarantees cleanup happens in the correct order at the correct time without manual scaffolding.
Traditional patterns still win when disposal timing decouples from scope boundaries. Long-lived resources managed by external lifecycles, resources shared across multiple scopes, and cleanup that depends on runtime conditions outside block structure require explicit control. The using keyword cannot express "close this resource after the third call" or "dispose when the metric crosses a threshold". Those scenarios still need manual cleanup.
The migration decision reduces to a simple heuristic: if acquisition and disposal map to entry and exit of a single block, convert to using. If cleanup timing depends on anything else, keep manual control. Apply this in production and the difference will be immediate. Resource leaks disappear. Cleanup code shrinks. The cognitive load of tracking disposal across error paths evaporates.
That covers the essential patterns for TypeScript resource management with using. Apply these in production and the difference will be immediate.