Loading learning content...
Understanding the Disposable interface is only half the equation. The other half—equally critical—is knowing how to use Disposable objects correctly. A perfectly implemented Disposable class is worthless if consuming code fails to call dispose().
Consider this scenario: You design a beautifully crafted database connection wrapper with proper dispose() semantics. It handles edge cases, logs appropriately, and integrates with connection pooling. Then a developer uses it like this:
function getUserData(userId: string): User {
const connection = new DatabaseConnection(connectionString);
const result = connection.query("SELECT * FROM users WHERE id = ?", userId);
return result.toUser();
// Connection never disposed - leaked!
}
Every call to this function leaks a connection. Under load, the connection pool exhausts within minutes. The application crashes. The root cause? Not the Disposable implementation—the consumption was wrong.
This page explores how to use Disposable objects safely, leveraging language constructs that guarantee cleanup regardless of how code exits.
By the end of this page, you will master: (1) Language constructs for automatic cleanup (using, try-with-resources, with, defer), (2) Exception handling semantics during disposal, (3) Ownership transfer patterns, (4) Common anti-patterns that cause resource leaks, and (5) Testing strategies for Disposable consumption.
At the core of safe Disposable usage is a universal pattern: try-finally. This construct ensures cleanup code runs regardless of how the try block exits—normal completion, early return, or exception.
The Problem Without Try-Finally:
Consider what can go wrong with naive usage:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051
// ❌ WRONG: Resource leak on exceptionfunction processDataNaive(filePath: string): ProcessedData { const file = new FileReader(filePath); // Resource acquired const data = file.readAll(); // May throw! const processed = transform(data); // May throw! file.dispose(); // Never reached if above throws return processed;} // ❌ WRONG: Resource leak on early returnfunction findFirstMatch(patterns: string[]): Match | null { const connection = new DatabaseConnection(connectionString); for (const pattern of patterns) { const match = connection.query(`SELECT * WHERE pattern = '${pattern}'`); if (match.found) { return match; // Early return - connection never disposed! } } connection.dispose(); // Only reached if nothing found return null;} // ✅ CORRECT: Try-finally guarantees cleanupfunction processDataSafe(filePath: string): ProcessedData { const file = new FileReader(filePath); try { const data = file.readAll(); const processed = transform(data); return processed; } finally { file.dispose(); // ALWAYS runs - exception, return, or normal exit }} // ✅ CORRECT: Early returns are safe with try-finallyfunction findFirstMatchSafe(patterns: string[]): Match | null { const connection = new DatabaseConnection(connectionString); try { for (const pattern of patterns) { const match = connection.query(`SELECT * WHERE pattern = '${pattern}'`); if (match.found) { return match; // Early return is safe! } } return null; } finally { connection.dispose(); // Runs even on early return }}The Finally Block Guarantee:
The finally block executes:
return statement (before actually returning)break or continue that exits the try blockThe only cases where finally might not run:
For all practical purposes, finally is guaranteed to run.
While try-finally works, it's verbose and error-prone. Developers must remember to add it every time. One missed try-finally creates a leak. This is why modern languages provide syntactic sugar: using, try-with-resources, and with statements. These compile down to try-finally but are impossible to forget once you acquire the resource.
Modern languages recognize that try-finally is boilerplate that developers frequently forget. They provide dedicated syntax that ties resource acquisition to automatic cleanup. Using these constructs should be your default—never manually call dispose() outside of special circumstances.
C#'s using statement is the most polished implementation. It declares a variable and guarantees Dispose() is called when the scope exits.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647
// Traditional using statement (scoped block)using (var connection = new SqlConnection(connectionString)){ connection.Open(); var result = connection.ExecuteQuery("SELECT * FROM users"); // Process result} // connection.Dispose() called automatically here // Modern using declaration (C# 8.0+) - cleaner syntaxpublic async Task<User?> GetUserAsync(int userId){ using var connection = new SqlConnection(connectionString); using var command = new SqlCommand("SELECT * FROM users WHERE id = @id", connection); command.Parameters.AddWithValue("@id", userId); await connection.OpenAsync(); using var reader = await command.ExecuteReaderAsync(); if (await reader.ReadAsync()) { return MapToUser(reader); } return null;} // reader, command, and connection all disposed in reverse order // Multiple resources in single using (traditional)using (var connection = new SqlConnection(connectionString))using (var transaction = connection.BeginTransaction()){ try { // Perform operations transaction.Commit(); } catch { transaction.Rollback(); throw; }} // transaction.Dispose() then connection.Dispose() // Async disposal with await using (C# 8.0+)await using (var asyncResource = new AsyncDatabaseContext()){ await asyncResource.SaveChangesAsync();} // Calls DisposeAsync() instead of Dispose()The moment you acquire a resource, immediately write the cleanup code. In C#: write using on the declaration line. In Go: write defer Close() on the line after Open(). In Python: use with. Don't write any business logic between acquisition and cleanup scheduling—that's where bugs hide.
What happens when both business logic AND disposal throw exceptions? This is a nuanced area where languages differ and where incorrect handling can mask bugs or lose important error information.
The Problem:
1. Try block executes - throws ExceptionA
2. Finally/dispose runs - throws ExceptionB
3. What exception does the caller see?
If only ExceptionB propagates, you've lost the original error. If only ExceptionA propagates, you've silently ignored a disposal failure. Both matter.
| Language | Default Behavior | Solution for Preserving Both |
|---|---|---|
| C# | Finally exception replaces try exception | Dispose() should NOT throw; log errors internally |
| Java | Try exception preserved; close() exception suppressed and attached | Use getSuppressed() to access close exceptions |
| Python | Recent exception replaces earlier (by default) | Context managers can suppress or chain exceptions |
| Go | Defer runs; panic in defer can replace original panic | Recover in defer; handle both errors explicitly |
| Rust | Drop::drop cannot panic (abort); must handle internally | Log errors; Drop should be infallible |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110
/** * Exception Handling in Disposal * * Best practice: Dispose should NOT throw exceptions. * If cleanup can fail, log the error but don't propagate. */ // ❌ WRONG: Dispose that throwsclass BadDisposable implements Disposable { dispose(): void { // This can throw, masking the original error this.connection.close(); // NetworkException! }} // ✅ CORRECT: Resilient dispose that captures errorsclass ResilientDisposable implements Disposable { private _isDisposed = false; private _disposeError: Error | null = null; get disposeError(): Error | null { return this._disposeError; } dispose(): void { if (this._isDisposed) return; this._isDisposed = true; // Attempt cleanup, capture any error try { this.connection.close(); } catch (error) { // Log but don't throw console.error("[Dispose] Cleanup error:", error); this._disposeError = error as Error; } }} /** * Pattern for when you need to know about dispose errors: * Use an aggregate error or check after disposal */function processWithErrorTracking(): void { const resource = new ResilientDisposable(); let processingError: Error | null = null; try { doProcessing(resource); } catch (error) { processingError = error as Error; } finally { resource.dispose(); } // Now handle both errors appropriately if (processingError && resource.disposeError) { throw new AggregateError( [processingError, resource.disposeError], "Both processing and cleanup failed" ); } else if (processingError) { throw processingError; } else if (resource.disposeError) { // Log but don't throw - cleanup-only errors are usually less critical console.error("Cleanup failed:", resource.disposeError); }} /** * Java-style suppressed exceptions in TypeScript */class SuppressedError extends Error { readonly suppressed: Error[]; constructor(message: string, primary: Error, suppressed: Error[]) { super(message); this.name = "SuppressedError"; this.cause = primary; this.suppressed = suppressed; }} function processWithSuppression<T>( resource: Disposable, operation: () => T): T { let primaryError: Error | null = null; const suppressedErrors: Error[] = []; try { return operation(); } catch (error) { primaryError = error as Error; throw error; // Will be caught by outer handler after finally } finally { try { resource.dispose(); } catch (disposeError) { if (primaryError) { // Attach dispose error as suppressed suppressedErrors.push(disposeError as Error); (primaryError as any).suppressed = suppressedErrors; } else { // Disposal was the only error throw disposeError; } } }}Dispose/Close methods should be designed to NOT throw exceptions. Cleanup failures are important to log and monitor, but they should not mask the original business logic exception. If you're implementing a Disposable, catch cleanup errors, log them, and continue cleaning up other resources.
The question "Who should dispose this?" is fundamental to Disposable usage. Misunderstanding ownership leads to two failure modes:
Clear ownership semantics prevent both failures.
new FileReader(), you must call dispose().123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134
/** * Ownership Pattern Examples */ // Pattern 1: Creator Ownsclass DataProcessor { process(filePath: string): void { // We create it, we own it, we dispose it const reader = new FileReader(filePath); try { const data = reader.readAll(); this.transform(data); } finally { reader.dispose(); // Our responsibility } }} // Pattern 2: Parameter Borrowing (DO NOT dispose)class ReportGenerator { /** * Generates a report using the provided connection. * * @param connection - A database connection. Caller retains ownership. * This method will NOT dispose the connection. */ generateReport(connection: DatabaseConnection): Report { // Use the connection const data = connection.query("SELECT * FROM metrics"); // DO NOT dispose - we don't own it return this.formatReport(data); }} // Pattern 3: Return Value Transfer (caller must dispose)class ConnectionFactory { /** * Creates a new database connection. * * @returns A new connection. CALLER OWNS and must dispose. */ create(): DatabaseConnection { const connection = new DatabaseConnection(this.connectionString); // We created it, but ownership transfers to caller return connection; }} // Usage of factoryfunction useFactory() { const factory = new ConnectionFactory(); const conn = factory.create(); // We now own this try { // Use connection } finally { conn.dispose(); // Our responsibility as owner }} // Pattern 4: Explicit Ownership Transferinterface Disposable { dispose(): void; readonly isDisposed: boolean;} /** * A wrapper that takes ownership of a Disposable. * When this wrapper is disposed, it disposes the wrapped resource. */class OwningWrapper implements Disposable { private ownedResource: Disposable | null; private _isDisposed = false; /** * Takes ownership of the provided resource. * * @param resource - The resource to wrap. Ownership TRANSFERS to this wrapper. * Caller must NOT dispose the resource after this call. */ constructor(resource: Disposable) { this.ownedResource = resource; } get isDisposed(): boolean { return this._isDisposed; } dispose(): void { if (this._isDisposed) return; this._isDisposed = true; if (this.ownedResource) { this.ownedResource.dispose(); this.ownedResource = null; } }} // Pattern 5: Dependency Injection (borrowed, not owned)class UserService { // Connection is injected - we DON'T own it constructor(private readonly connection: DatabaseConnection) {} getUser(id: string): User { // Use but don't dispose return this.connection.query(`SELECT * FROM users WHERE id = ${id}`); } // NO dispose method - we don't own the connection} // Container manages lifecycleclass ServiceContainer implements Disposable { private readonly connection = new DatabaseConnection(connectionString); private readonly userService = new UserService(this.connection); private _isDisposed = false; get isDisposed(): boolean { return this._isDisposed; } get users(): UserService { return this.userService; } dispose(): void { if (this._isDisposed) return; this._isDisposed = true; // Container owns the connection this.connection.dispose(); }}When ownership semantics are not obvious from context, document them explicitly. Use parameter documentation, method names (createXXX implies transfer, useXXX implies borrowing), or marker interfaces. Ambiguous ownership is a bug waiting to happen.
Even with language support, developers frequently make mistakes with Disposable usage. Here are the most dangerous anti-patterns:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586
// ❌ ANTI-PATTERN 1: Forgetting to disposefunction antipattern1() { const conn = new DatabaseConnection(url); const result = conn.query("SELECT * FROM users"); return result; // conn is never disposed - LEAK!} // ❌ ANTI-PATTERN 2: Disposing borrowed resourcesfunction antipattern2(conn: DatabaseConnection) { const result = conn.query("SELECT * FROM users"); conn.dispose(); // WRONG! We don't own this! return result;} // ❌ ANTI-PATTERN 3: Using after disposefunction antipattern3() { const conn = new DatabaseConnection(url); try { const result = conn.query("SELECT * FROM users"); return result; } finally { conn.dispose(); } // Later in same function... conn.query("SELECT * FROM orders"); // CRASH! Already disposed} // ❌ ANTI-PATTERN 4: Conditional disposal (incomplete cleanup)function antipattern4(condition: boolean) { const conn = new DatabaseConnection(url); if (condition) { conn.dispose(); // Only disposed sometimes return; } // Do work conn.dispose();} // ❌ ANTI-PATTERN 5: Disposal in constructor (can't use object)class Antipattern5 { constructor() { const helper = new DisposableHelper(); // If this throws, helper leaks this.initialize(); helper.dispose(); }} // ❌ ANTI-PATTERN 6: Storing disposable without lifecycle managementclass Antipattern6 { private connection: DatabaseConnection; constructor() { // Connection created but class has no dispose method this.connection = new DatabaseConnection(url); } // No dispose() - connection will never be cleaned up!} // ❌ ANTI-PATTERN 7: Long-lived disposables without cleanupclass Antipattern7 { private disposables: Disposable[] = []; createResource(): Disposable { const resource = new ExpensiveResource(); this.disposables.push(resource); // Array grows forever return resource; } // No way to clean up old resources!} // ❌ ANTI-PATTERN 8: Deferred dispose in loops (Go-specific)function antipattern8Go() { // In Go: // for _, file := range files { // f, _ := os.Open(file) // defer f.Close() // All defers run at FUNCTION end, not loop end // } // All files stay open until function returns!}Corrected Patterns:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105
// ✅ CORRECT 1: Always use try-finally or language constructfunction correct1() { const conn = new DatabaseConnection(url); try { return conn.query("SELECT * FROM users"); } finally { conn.dispose(); }} // ✅ CORRECT 2: Respect ownership - don't dispose borrowedfunction correct2(conn: DatabaseConnection) { // Just use it, don't dispose return conn.query("SELECT * FROM users");} // ✅ CORRECT 3: Don't use after disposefunction correct3() { let result; const conn = new DatabaseConnection(url); try { result = conn.query("SELECT * FROM users"); } finally { conn.dispose(); } return result; // Conn not used after dispose} // ✅ CORRECT 4: Unconditional cleanup with try-finallyfunction correct4(condition: boolean) { const conn = new DatabaseConnection(url); try { if (condition) { return; // Early return is fine } // Do work } finally { conn.dispose(); // ALWAYS runs }} // ✅ CORRECT 5: Handle disposal failure in constructorclass Correct5 { private helper: DisposableHelper; constructor() { this.helper = new DisposableHelper(); try { this.initialize(); // May throw } catch (e) { this.helper.dispose(); // Clean up on failure throw e; } } dispose(): void { this.helper.dispose(); }} // ✅ CORRECT 6: Class that holds disposable implements Disposableclass Correct6 implements Disposable { private connection: DatabaseConnection; private _isDisposed = false; constructor() { this.connection = new DatabaseConnection(url); } get isDisposed(): boolean { return this._isDisposed; } dispose(): void { if (this._isDisposed) return; this._isDisposed = true; this.connection.dispose(); }} // ✅ CORRECT 7: Bounded collection with cleanupclass Correct7 implements Disposable { private disposables: Disposable[] = []; private _isDisposed = false; get isDisposed(): boolean { return this._isDisposed; } createResource(): Disposable { const resource = new ExpensiveResource(); this.disposables.push(resource); return resource; } dispose(): void { if (this._isDisposed) return; this._isDisposed = true; for (const d of this.disposables) { try { d.dispose(); } catch (e) { console.error(e); } } this.disposables = []; }}How do you verify that code correctly disposes its resources? This requires specific testing strategies.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125
/** * Testing Disposable Consumption * * Key strategies: * 1. Create spy/mock disposables that track if dispose was called * 2. Use assertion helpers for cleanup verification * 3. Test both success and failure paths */ // A spy disposable that tracks disposalclass SpyDisposable implements Disposable { public disposeCallCount = 0; public disposeCalledAt: Date | null = null; private _isDisposed = false; get isDisposed(): boolean { return this._isDisposed; } dispose(): void { this.disposeCallCount++; this.disposeCalledAt = new Date(); this._isDisposed = true; } assertDisposed(): void { if (!this._isDisposed) { throw new Error("Expected dispose() to be called, but it wasn't"); } } assertDisposedOnce(): void { if (this.disposeCallCount !== 1) { throw new Error( `Expected dispose() to be called exactly once, \ but it was called ${this.disposeCallCount} times` ); } }} // Test: Verify disposal on normal pathdescribe("DataProcessor", () => { it("should dispose the reader after successful processing", () => { const spyReader = new SpyDisposable(); const processor = new DataProcessor(); processor.processWithReader(spyReader); spyReader.assertDisposedOnce(); }); it("should dispose the reader even when processing fails", () => { const spyReader = new SpyDisposable(); const processor = new DataProcessor(); try { processor.processWithReaderThatThrows(spyReader); } catch (e) { // Expected } // Critical: Disposal must happen even on exception spyReader.assertDisposedOnce(); }); it("should NOT dispose borrowed resources", () => { const spyConnection = new SpyDisposable(); const service = new UserService(spyConnection as any); service.getUsers(); // Service should NOT dispose the connection expect(spyConnection.disposeCallCount).toBe(0); });}); // Factory for creating trackable disposablesclass DisposableTracker { private tracked: SpyDisposable[] = []; create(): SpyDisposable { const disposable = new SpyDisposable(); this.tracked.push(disposable); return disposable; } assertAllDisposed(): void { const undisposed = this.tracked.filter(d => !d.isDisposed); if (undisposed.length > 0) { throw new Error( `${undisposed.length} disposable(s) were not disposed` ); } } get stats() { return { total: this.tracked.length, disposed: this.tracked.filter(d => d.isDisposed).length, leaked: this.tracked.filter(d => !d.isDisposed).length, }; }} // Integration test with trackerdescribe("ConnectionPool integration", () => { it("should dispose all connections when pool is closed", () => { const tracker = new DisposableTracker(); const pool = new ConnectionPool({ createConnection: () => tracker.create() as any }); // Use pool pool.acquire(); pool.acquire(); pool.acquire(); // Close pool pool.dispose(); // All connections should be disposed tracker.assertAllDisposed(); });});Consider adding a test helper that runs after each test to verify no resources leaked. Many testing frameworks support such hooks. If you find tests with leaked resources, it indicates production code that may leak under the same conditions.
What's Next:
Now that you know how to use Disposables safely, the next page explores resource cleanup guarantees—how to ensure cleanup happens under all circumstances, including concurrent access, partial failures, and complex nested resource hierarchies.
You now understand how to consume Disposable objects correctly. These patterns—using appropriate language constructs, respecting ownership, and testing disposal—will prevent resource leaks in your production systems.