TypeScript using Keyword and Explicit Resource Management: Done Right
Most memory leaks in production TypeScript applications stem from a single preventable failure: resources that developers acquire but never release. Database connections hang open after errors. File handles consume system resources indefinitely. WebSocket clients remain connected to servers that no longer exist. The pattern repeats across every codebase that relies on manual cleanup in finally blocks.
TypeScript's using keyword solves this at the language level. The feature—part of the ECMAScript Explicit Resource Management proposal—guarantees deterministic cleanup through the disposable pattern. When a resource goes out of scope, TypeScript invokes its disposal method automatically. No finally blocks. No forgotten cleanup. No leaked connections.
Key Takeaways
- The
usingkeyword guarantees disposal when resources exit scope, eliminating manual finally block management - Disposable resources implement
Symbol.disposefor synchronous cleanup orSymbol.asyncDisposefor async operations - TypeScript desugars
usingdeclarations into try-finally blocks with automatic disposal stack management - The pattern prevents the three most common resource leak scenarios: early returns, thrown exceptions, and forgotten cleanup
- Use
usingfor any resource with deterministic lifetime requirements—database connections, file handles, locks, timers
Understanding the using Keyword and Symbol.dispose
The using keyword establishes a binding that TypeScript automatically disposes when execution leaves the enclosing block. The mechanism works through the disposable protocol: objects implement a method keyed by Symbol.dispose that performs cleanup. When the scope exits—through normal completion, return, or exception—TypeScript calls that method.
class FileHandle {
private handle: number;
constructor(path: string) {
this.handle = openFileSync(path);
}
[Symbol.dispose]() {
if (this.handle !== -1) {
closeFileSync(this.handle);
this.handle = -1;
}
}
read(buffer: Buffer): number {
return readSync(this.handle, buffer);
}
}
function processFile(path: string) {
using file = new FileHandle(path);
const buffer = Buffer.alloc(1024);
file.read(buffer);
// File handle automatically closes here
}
The disposal method runs in a finally block that TypeScript generates during compilation. This execution timing is critical: disposal occurs even if the function throws or returns early. The traditional approach requires developers to write and maintain that finally logic manually. The failure mode is immediate when teams forget or incorrectly nest cleanup code.
Flow showing using keyword resource lifecycle from acquisition through automatic disposal
The pattern extends naturally to multiple resources. TypeScript maintains a disposal stack internally—resources dispose in reverse order of acquisition. The last resource acquired disposes first, mirroring the natural dependency order that most cleanup logic requires.
Implementing Disposable Resources: File Handles and Database Connections
Database connections demonstrate the disposable pattern's value proposition. Connection pools leak resources when developers forget to release connections back to the pool. The using keyword makes that release automatic and exception-safe.
Database connection management with disposable pattern
class DatabaseConnection {
private connection: any;
private pool: ConnectionPool;
constructor(pool: ConnectionPool) {
this.pool = pool;
this.connection = pool.acquire();
}
[Symbol.dispose]() {
if (this.connection) {
this.pool.release(this.connection);
this.connection = null;
}
}
async query<T>(sql: string, params: any[]): Promise<T[]> {
return this.connection.query(sql, params);
}
async transaction<T>(
callback: (conn: DatabaseConnection) => Promise<T>
): Promise<T> {
await this.connection.beginTransaction();
try {
const result = await callback(this);
await this.connection.commit();
return result;
} catch (error) {
await this.connection.rollback();
throw error;
}
}
}
async function updateUserBalance(userId: string, amount: number) {
using conn = new DatabaseConnection(globalPool);
await conn.transaction(async (tx) => {
const user = await tx.query(
'SELECT balance FROM users WHERE id = $1 FOR UPDATE',
[userId]
);
await tx.query(
'UPDATE users SET balance = $1 WHERE id = $2',
[user[0].balance + amount, userId]
);
});
// Connection returns to pool automatically
}
This implementation guarantees connection release regardless of how the function exits. The transaction method can throw. The query operations can fail. The calling code can return early. In every case, the disposal method runs and returns the connection to the pool.
The alternative—manual try-finally blocks around every connection acquisition—creates maintenance burden and error opportunities. Teams miss edge cases. Code reviews overlook missing cleanup. The using keyword eliminates that entire class of defect.
Async Resource Management with await using and Symbol.asyncDispose
Asynchronous disposal requires a separate keyword combination: await using. Resources that need async cleanup implement Symbol.asyncDispose instead of Symbol.dispose. The disposal method returns a Promise that TypeScript awaits before continuing execution.
class StreamProcessor {
private stream: ReadableStream;
private writer: WritableStream;
constructor(input: ReadableStream, output: WritableStream) {
this.stream = input;
this.writer = output;
}
async [Symbol.asyncDispose]() {
await this.stream.cancel();
await this.writer.close();
}
async process() {
const reader = this.stream.getReader();
const writer = this.writer.getWriter();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const transformed = this.transform(value);
await writer.write(transformed);
}
} finally {
reader.releaseLock();
writer.releaseLock();
}
}
private transform(chunk: Uint8Array): Uint8Array {
// Transform logic here
return chunk;
}
}
async function processStream(input: ReadableStream, output: WritableStream) {
await using processor = new StreamProcessor(input, output);
await processor.process();
// Streams close automatically after disposal completes
}
The distinction between using and await using is critical. Synchronous disposal with Symbol.dispose must not perform async operations. Async disposal with Symbol.asyncDispose requires the await using syntax. Mixing these patterns produces runtime errors or incomplete cleanup.
Using Declarations in Loops and Complex Control Flow
Resources declared with using in loops dispose at the end of each iteration. This behavior matches developer intuition but differs from traditional patterns where resources often leak across iterations.
Flow diagram showing resource disposal in loop iterations with early exit handling
The pattern applies cleanly to batch processing scenarios where each iteration needs its own resource scope:
async function processBatchFiles(filePaths: string[]) {
for (const path of filePaths) {
await using file = new FileHandle(path);
await using output = new FileHandle(`${path}.processed`);
const content = await file.readAll();
const processed = transform(content);
await output.write(processed);
// Both files close automatically before next iteration
}
}
Early exits—break, continue, return—trigger disposal immediately. The disposal stack unwinds in the correct order regardless of control flow complexity. This guarantee eliminates the resource leak patterns that plague traditional loop-based processing.
Resource management in complex control flow scenarios
How TypeScript Desugars using: Understanding the Compiled Output
TypeScript transforms using declarations into try-finally blocks with explicit disposal stack management. Understanding this transformation clarifies the mechanism's guarantees and limitations.
A simple using declaration:
function example() {
using resource = new Resource();
doWork(resource);
}
Compiles approximately to:
function example() {
const $$dispose = Symbol.dispose;
const $$stack = [];
try {
const resource = new Resource();
$$stack.push(resource);
doWork(resource);
} finally {
while ($$stack.length > 0) {
const resource = $$stack.pop();
resource[$$dispose]();
}
}
}
The disposal stack ensures correct cleanup order when multiple resources exist in the same scope. Resources dispose in reverse acquisition order automatically. The pattern handles exceptions during disposal: if one disposal throws, TypeScript continues disposing remaining resources and rethrows the original exception.
Diagram showing how using declarations compile to try-finally with disposal stack
This implementation strategy means using declarations have deterministic overhead: one stack push per resource, one stack pop during cleanup. The pattern performs identically to hand-written finally blocks but eliminates human error.
Common Pitfalls and Best Practices for Resource Management
The most common mistake developers make with using declarations is implementing disposal methods that throw exceptions. When disposal throws, TypeScript propagates that exception—but only after disposing all remaining resources. The original exception that triggered disposal gets lost.
class BadResource {
[Symbol.dispose]() {
// WRONG: throws during disposal
throw new Error('Disposal failed');
}
}
class GoodResource {
[Symbol.dispose]() {
try {
// Cleanup that might fail
this.dangerousCleanup();
} catch (error) {
// Log but don't throw
console.error('Cleanup failed:', error);
}
}
private dangerousCleanup() {
// Cleanup logic
}
}
The second mistake is implementing async operations in synchronous disposal methods. Symbol.dispose methods must complete synchronously. Async cleanup requires Symbol.asyncDispose and await using syntax. Mixing these patterns produces incomplete cleanup and race conditions.
Best practices flow showing error handling and disposal implementation patterns
The third pitfall is forgetting that using declarations create block-scoped bindings. A resource declared in a block disposes when that block exits—not when the containing function returns. This behavior matters for resources that need function-level lifetime:
function wrongScope() {
if (someCondition) {
using resource = new Resource();
// Resource disposes at end of if block
}
// Resource already disposed here
}
function correctScope() {
using resource = new Resource();
if (someCondition) {
// Use resource
}
// Resource disposes at function exit
}
When to Use using vs Traditional try-finally Blocks
The decision between using declarations and manual try-finally blocks depends on three factors: resource lifetime requirements, disposal complexity, and codebase consistency.
Comparison between using keyword and try-finally block patterns
Use using declarations when resources have clear lifetime boundaries that match block scope. Database connections, file handles, locks, and timers all fit this pattern. The automatic disposal eliminates entire categories of resource leak defects.
Use traditional try-finally blocks when disposal logic requires conditional behavior or complex error handling. Resources that need different cleanup paths based on execution state don't map cleanly to the disposable protocol. In these cases, explicit control flow in finally blocks provides necessary flexibility.
// Good: using for straightforward resource lifetime
async function goodUsingExample() {
await using conn = new DatabaseConnection(pool);
return await conn.query('SELECT * FROM users');
}
// Better: try-finally for conditional cleanup
async function conditionalCleanupExample() {
const transaction = await db.beginTransaction();
let committed = false;
try {
await transaction.execute('UPDATE users SET active = true');
await transaction.commit();
committed = true;
} finally {
if (!committed) {
await transaction.rollback();
}
}
}
The using pattern also assumes disposal methods handle all edge cases internally. Resources with complex cleanup requirements often need context from the calling code—information not available in the disposal method. These scenarios benefit from explicit cleanup logic that can access function-local state.
For additional context on modern TypeScript tooling patterns, see our guide on modern TypeScript library creation.
Frequently Asked Questions
Can I use the using keyword with existing classes that don't implement Symbol.dispose?
No—the using keyword requires the resource to implement Symbol.dispose or Symbol.asyncDispose. Existing classes need wrapper types that implement the disposable protocol and delegate cleanup to the original class's cleanup methods.
What happens if a disposal method throws an exception?
TypeScript continues disposing all remaining resources in the disposal stack, then throws the disposal exception. This behavior means the original exception that triggered disposal gets suppressed. Disposal methods should catch and log errors internally rather than throwing.
Does using work with null or undefined values?
Yes—TypeScript checks for null/undefined before calling disposal methods. Declaring using resource = null is valid and performs no disposal. This behavior simplifies conditional resource acquisition patterns where resources might not always be needed.
Can I manually dispose a resource before scope exit?
Yes—calling the disposal method directly is allowed. TypeScript won't call it again at scope exit. This pattern supports scenarios where immediate cleanup provides value, like releasing locks before expensive operations that don't need the lock.
Is there a performance cost to using declarations compared to manual cleanup?
The overhead is negligible: one stack push per resource and one stack pop during cleanup. Compiled output is nearly identical to hand-written try-finally blocks. The pattern prevents entire classes of defects at zero meaningful runtime cost.
Conclusion
That covers the essential patterns for explicit resource management in TypeScript. The using keyword eliminates manual cleanup boilerplate while guaranteeing deterministic disposal through the language itself. Implement Symbol.dispose for synchronous resources and Symbol.asyncDispose for async cleanup. Test disposal in error paths. Never throw from disposal methods. Apply these patterns in production and resource leaks become a solved problem rather than an ongoing maintenance burden.
For deeper context on TypeScript tooling decisions, explore our comparison of Biome and oxlint and our foundational guide on building TypeScript libraries.







Top comments (0)