Loading learning content...
Real-world systems rarely consist of isolated Disposable objects. A database context owns multiple repositories, which own prepared statements, which own result sets. A web request handler owns an HTTP client, which owns a connection pool, which owns socket connections. Disposables compose into hierarchies.
This composition introduces complexity:
This page explores patterns for managing nested Disposables—from simple parent-child relationships to complex graphs of shared ownership.
By the end of this page, you will master: (1) Ownership hierarchy patterns, (2) Composite Disposable implementations, (3) Factory methods that transfer ownership, (4) Shared ownership with reference counting, (5) Disposal order in complex graphs, and (6) Testing nested Disposable structures.
The foundation of nested Disposables is the concept of ownership. An object that creates or receives a Disposable must decide: do I own this? Will I be responsible for its disposal?
Ownership Patterns:
| Pattern | Description | Parent Disposes Child? | Example |
|---|---|---|---|
| Owned Composition | Parent creates child, owns it exclusively | Yes | DbContext → Transaction |
| Injected Dependency | Parent receives child, does NOT own it | No | Service receives Repository |
| Ownership Transfer | Parent receives child, explicitly takes ownership | Yes | Builder.Build() returns owned object |
| Shared Ownership | Multiple owners, last owner disposes | Last to release | Pooled connections |
| Borrowed Reference | Temporary use without ownership | No | Transaction receives Connection |
The Ownership Rule:
If you create a Disposable or explicitly accept ownership, you are responsible for disposing it. If you receive one as a parameter or borrow it temporarily, you must NOT dispose it.
This rule forms a clear ownership tree where each Disposable has exactly one owner responsible for its disposal.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143
/** * Ownership Patterns for Nested Disposables */ interface Disposable { dispose(): void; readonly isDisposed: boolean;} // Pattern 1: Owned Composition// Parent CREATES and OWNS child - disposes when parent disposesclass DatabaseContext implements Disposable { private connection: DatabaseConnection; // Owned private transaction: Transaction | null = null; // Owned (when active) private _isDisposed = false; constructor(connectionString: string) { // We create it, we own it, we dispose it this.connection = new DatabaseConnection(connectionString); } get isDisposed(): boolean { return this._isDisposed; } beginTransaction(): Transaction { if (this.transaction !== null) { throw new Error("Transaction already active"); } // We create it, we own it this.transaction = new Transaction(this.connection); return this.transaction; } dispose(): void { if (this._isDisposed) return; this._isDisposed = true; // Dispose children in reverse order of creation if (this.transaction !== null) { this.transaction.dispose(); this.transaction = null; } // Then dispose our owned resources this.connection.dispose(); }} // Pattern 2: Injected Dependency// Parent RECEIVES dependency - does NOT own, does NOT disposeclass UserRepository { // Connection is injected - we don't own it constructor(private readonly connection: DatabaseConnection) {} getUser(id: string): User { return this.connection.query(`SELECT * FROM users WHERE id = '${id}'`); } // No dispose() method - we don't own the connection} // Pattern 3: Explicit Ownership Transfer// Factory pattern where caller takes ownership of created objectinterface DisposableFactory<T extends Disposable> { /** * Creates a new Disposable. * @returns The new instance. CALLER TAKES OWNERSHIP and must dispose. */ create(): T;} class ConnectionFactory implements DisposableFactory<DatabaseConnection> { constructor(private connectionString: string) {} /** * Creates a new database connection. * * @returns A new connection. The CALLER owns this connection * and is responsible for calling dispose(). */ create(): DatabaseConnection { return new DatabaseConnection(this.connectionString); }} // Usage with explicit ownershipfunction processWithFactory(factory: ConnectionFactory): void { const connection = factory.create(); // We now own this try { // Use connection } finally { connection.dispose(); // Our responsibility }} // Pattern 4: Documented Ownership Transfer via Method Nameclass ResourceBuilder { private resources: NativeResource[] = []; addResource(config: ResourceConfig): this { this.resources.push(new NativeResource(config)); return this; } /** * Builds and returns the composite resource. * * OWNERSHIP TRANSFER: After this call, the builder no longer owns * the internal resources. The returned CompositeResource owns them. * The caller must dispose the returned object. */ build(): CompositeResource { const resources = this.resources; this.resources = []; // Transfer ownership return new CompositeResource(resources); }} class CompositeResource implements Disposable { private _isDisposed = false; // Takes ownership of provided resources constructor(private resources: NativeResource[]) {} get isDisposed(): boolean { return this._isDisposed; } dispose(): void { if (this._isDisposed) return; this._isDisposed = true; for (const resource of this.resources) { try { resource.dispose(); } catch (e) { console.error("Resource disposal failed:", e); } } this.resources = []; }}When ownership is ambiguous, document it explicitly. Use JSDoc/Javadoc comments stating 'Caller takes ownership' or 'This method does not take ownership of the provided resource.' Naming conventions also help: create/build/open imply ownership transfer, while process/use/with imply borrowing.
The Composite Disposable pattern provides a container that manages multiple Disposables as a unit. When the composite is disposed, all contained Disposables are disposed automatically. This pattern is fundamental for managing nested resources.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188
/** * CompositeDisposable - Manages a collection of Disposables * * Features: * - Add/remove individual Disposables * - Dispose all at once * - Immediate disposal of items added after container is disposed * - Thread-safe operations * - Configurable disposal order (FIFO or LIFO) */ interface Disposable { dispose(): void; readonly isDisposed: boolean;} type DisposalOrder = 'fifo' | 'lifo'; interface CompositeDisposableOptions { disposalOrder?: DisposalOrder; continueOnError?: boolean;} class CompositeDisposable implements Disposable { private disposables: Disposable[] = []; private _isDisposed = false; private options: Required<CompositeDisposableOptions>; constructor(options: CompositeDisposableOptions = {}) { this.options = { disposalOrder: options.disposalOrder ?? 'lifo', continueOnError: options.continueOnError ?? true, }; } get isDisposed(): boolean { return this._isDisposed; } get count(): number { return this.disposables.length; } /** * Add a Disposable to the composite. * * If the composite is already disposed, the new item is * immediately disposed. */ add<T extends Disposable>(disposable: T): T { if (this._isDisposed) { // Immediate disposal if container is already disposed try { disposable.dispose(); } catch (e) { console.error("Immediate disposal failed:", e); } return disposable; } this.disposables.push(disposable); return disposable; } /** * Remove a Disposable from the composite WITHOUT disposing it. * * Use this to transfer ownership out of the composite. */ remove(disposable: Disposable): boolean { const index = this.disposables.indexOf(disposable); if (index !== -1) { this.disposables.splice(index, 1); return true; } return false; } /** * Remove and dispose a specific Disposable. */ removeAndDispose(disposable: Disposable): boolean { if (this.remove(disposable)) { try { disposable.dispose(); } catch (e) { console.error("Disposal failed:", e); } return true; } return false; } /** * Dispose all contained Disposables. * * Order depends on options.disposalOrder: * - 'lifo': Last added is first disposed (stack semantics) * - 'fifo': First added is first disposed (queue semantics) */ dispose(): void { if (this._isDisposed) return; this._isDisposed = true; const errors: Error[] = []; const toDispose = this.options.disposalOrder === 'lifo' ? [...this.disposables].reverse() : this.disposables; for (const disposable of toDispose) { try { disposable.dispose(); } catch (error) { if (this.options.continueOnError) { errors.push(error as Error); console.error("Disposal error:", error); } else { throw error; } } } this.disposables = []; if (errors.length > 0) { console.error(`CompositeDisposable: ${errors.length} error(s) during disposal`); } } /** * Clear all Disposables WITHOUT disposing them. * Use for ownership transfer of all items. */ clear(): Disposable[] { const result = this.disposables; this.disposables = []; return result; }} // Example usage: Service with multiple owned resourcesclass DataProcessingService implements Disposable { private resources = new CompositeDisposable({ disposalOrder: 'lifo' }); private _isDisposed = false; private fileReader: FileReader; private dataTransformer: DataTransformer; private outputWriter: OutputWriter; constructor( inputPath: string, outputPath: string, transformerConfig: TransformerConfig ) { // Add resources in order of acquisition // Disposed in reverse order this.fileReader = this.resources.add( new FileReader(inputPath) ); this.dataTransformer = this.resources.add( new DataTransformer(transformerConfig) ); this.outputWriter = this.resources.add( new OutputWriter(outputPath) ); } get isDisposed(): boolean { return this._isDisposed; } process(): void { const data = this.fileReader.read(); const transformed = this.dataTransformer.transform(data); this.outputWriter.write(transformed); } dispose(): void { if (this._isDisposed) return; this._isDisposed = true; // Single call disposes all in correct order: // outputWriter, dataTransformer, fileReader this.resources.dispose(); }}Many frameworks provide composite disposable implementations: RxJS has CompositeSubscription, .NET Reactive Extensions has CompositeDisposable, Python has contextlib.ExitStack. Use framework-provided implementations when available—they're well-tested and handle edge cases.
When a parent Disposable contains children, several patterns govern how disposal cascades:
Cascade Disposal: When parent disposes, all children are automatically disposed. This is the most common pattern.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152
/** * Cascade Disposal: Parent disposes all children */class DocumentEditor implements Disposable { private documents: Document[] = []; private undoStack: UndoStack; private _isDisposed = false; constructor() { this.undoStack = new UndoStack(); } get isDisposed(): boolean { return this._isDisposed; } openDocument(path: string): Document { const doc = new Document(path); this.documents.push(doc); return doc; } closeDocument(doc: Document): void { const index = this.documents.indexOf(doc); if (index !== -1) { this.documents.splice(index, 1); doc.dispose(); } } /** * Disposing the editor disposes ALL owned resources: * - All open documents * - The undo stack */ dispose(): void { if (this._isDisposed) return; this._isDisposed = true; // Dispose in reverse order (LIFO) while (this.documents.length > 0) { const doc = this.documents.pop()!; try { doc.dispose(); } catch (e) { console.error(`Failed to dispose document:`, e); } } this.undoStack.dispose(); }}Sometimes multiple consumers need to share a single Disposable. In such cases, no single consumer "owns" the resource—instead, ownership is shared, and the resource is disposed only when the last consumer is done.
Reference Counting is the classic solution: track how many consumers hold references, and dispose when the count reaches zero.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
/** * Reference Counted Disposable * * Enables shared ownership where the resource is disposed * only when all references are released. */ interface Disposable { dispose(): void; readonly isDisposed: boolean;} class RefCounted<T extends Disposable> implements Disposable { private refCount = 1; // Creator has first reference private _isDisposed = false; constructor(private resource: T) {} get isDisposed(): boolean { return this._isDisposed; } get currentRefCount(): number { return this.refCount; } /** * Get a reference to the underlying resource. * Only valid while not disposed. */ get value(): T { if (this._isDisposed) { throw new Error("Cannot access disposed resource"); } return this.resource; } /** * Acquire an additional reference. * * Returns a new RefCounted wrapper that shares the same underlying * resource. When the returned wrapper is disposed, it just decrements * the ref count. */ acquire(): RefCounted<T> { if (this._isDisposed) { throw new Error("Cannot acquire reference to disposed resource"); } this.refCount++; return new SharedReference(this); } /** * Release this reference. * If this was the last reference, disposes the underlying resource. */ dispose(): void { if (this._isDisposed) return; this.refCount--; if (this.refCount === 0) { this._isDisposed = true; this.resource.dispose(); } }} /** * A shared reference that points to the original RefCounted. * Disposing this just decrements the parent's count. */class SharedReference<T extends Disposable> implements Disposable { private released = false; constructor(private parent: RefCounted<T>) {} get isDisposed(): boolean { return this.released || this.parent.isDisposed; } get value(): T { if (this.released) { throw new Error("Reference already released"); } return this.parent.value; } dispose(): void { if (this.released) return; this.released = true; this.parent.dispose(); }} // Example usage: Shared database connectionasync function demoSharedOwnership(): Promise<void> { // Create the resource with ref count 1 const connection = new RefCounted(new DatabaseConnection(connectionString)); try { // Share with multiple consumers const ref1 = connection.acquire(); // ref count = 2 const ref2 = connection.acquire(); // ref count = 3 // Run operations concurrently await Promise.all([ processWithConnection(ref1), processWithConnection(ref2), ]); // Each consumer disposes their reference ref1.dispose(); // ref count = 2 ref2.dispose(); // ref count = 1 } finally { connection.dispose(); // ref count = 0 → actually disposes }} async function processWithConnection(ref: RefCounted<DatabaseConnection>): Promise<void> { try { const conn = ref.value; await conn.query("SELECT 1"); } finally { ref.dispose(); // Release our reference when done }} /** * Alternative: Scoped Reference that auto-releases */class ScopedRef<T extends Disposable> implements Disposable { private ref: RefCounted<T>; constructor(source: RefCounted<T>) { this.ref = source.acquire() as RefCounted<T>; } get isDisposed(): boolean { return this.ref.isDisposed; } get value(): T { return this.ref.value; } dispose(): void { this.ref.dispose(); }} // Usage with scoped referencefunction withScopedRef<T extends Disposable, R>( source: RefCounted<T>, action: (value: T) => R): R { const scoped = new ScopedRef(source); try { return action(scoped.value); } finally { scoped.dispose(); }}Reference counting is powerful but has pitfalls: (1) Circular references prevent disposal (A refs B, B refs A, count never reaches 0), (2) Forgetting to release creates leaks, (3) Thread-safety requires atomic operations. Use weak references to break cycles, and consider linting tools that detect unbalanced acquire/release calls.
When dependencies form complex graphs rather than simple trees, disposal order becomes critical. Consider: A uses B and C; B uses D; C uses D. What order should disposal follow?
The Rule: Dependents before Dependencies
A dependent should be disposed before its dependencies. In the example:
This is reverse topological order—the opposite of initialization order.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174
/** * Dependency-Aware Disposal * * Disposes resources in reverse topological order, * ensuring dependents are disposed before their dependencies. */ interface DisposableNode extends Disposable { readonly dependencies: DisposableNode[];} /** * Dispose a graph of dependent disposables in correct order. * * Uses reverse topological sort to determine disposal order. */function disposeGraph(roots: DisposableNode[]): DisposeReport { const disposed = new Set<DisposableNode>(); const errors: Array<{ node: DisposableNode; error: Error }> = []; // Build reverse dependency map (who depends on me?) const dependents = new Map<DisposableNode, Set<DisposableNode>>(); const allNodes = new Set<DisposableNode>(); function collectNodes(node: DisposableNode): void { if (allNodes.has(node)) return; allNodes.add(node); for (const dep of node.dependencies) { if (!dependents.has(dep)) { dependents.set(dep, new Set()); } dependents.get(dep)!.add(node); collectNodes(dep); } } for (const root of roots) { collectNodes(root); } // Dispose in reverse topological order // Start with nodes that have no dependents const readyToDispose: DisposableNode[] = []; for (const node of allNodes) { const deps = dependents.get(node); if (!deps || deps.size === 0) { readyToDispose.push(node); } } while (readyToDispose.length > 0) { const node = readyToDispose.pop()!; if (disposed.has(node)) continue; // Dispose this node try { node.dispose(); } catch (error) { errors.push({ node, error: error as Error }); } disposed.add(node); // Check if any of node's dependencies are now ready for (const dep of node.dependencies) { if (disposed.has(dep)) continue; // Can dispose dep if all its dependents are disposed const depDependents = dependents.get(dep); if (depDependents) { const allDependentsDisposed = [...depDependents].every( d => disposed.has(d) ); if (allDependentsDisposed) { readyToDispose.push(dep); } } else { readyToDispose.push(dep); } } } return { totalNodes: allNodes.size, disposedCount: disposed.size, errors, };} interface DisposeReport { totalNodes: number; disposedCount: number; errors: Array<{ node: DisposableNode; error: Error }>;} // Example usageclass Database implements DisposableNode { readonly dependencies: DisposableNode[] = []; private _isDisposed = false; get isDisposed() { return this._isDisposed; } dispose(): void { console.log("Disposing Database"); this._isDisposed = true; }} class Repository implements DisposableNode { readonly dependencies: DisposableNode[]; private _isDisposed = false; constructor(db: Database) { this.dependencies = [db]; } get isDisposed() { return this._isDisposed; } dispose(): void { console.log("Disposing Repository"); this._isDisposed = true; }} class CacheService implements DisposableNode { readonly dependencies: DisposableNode[]; private _isDisposed = false; constructor(db: Database) { this.dependencies = [db]; } get isDisposed() { return this._isDisposed; } dispose(): void { console.log("Disposing CacheService"); this._isDisposed = true; }} class ApplicationService implements DisposableNode { readonly dependencies: DisposableNode[]; private _isDisposed = false; constructor(repo: Repository, cache: CacheService) { this.dependencies = [repo, cache]; } get isDisposed() { return this._isDisposed; } dispose(): void { console.log("Disposing ApplicationService"); this._isDisposed = true; }} // Demofunction demo(): void { const db = new Database(); const repo = new Repository(db); const cache = new CacheService(db); const app = new ApplicationService(repo, cache); const report = disposeGraph([app]); // Output: // Disposing ApplicationService // Disposing Repository // Disposing CacheService // Disposing Database console.log(`Disposed ${report.disposedCount}/${report.totalNodes} nodes`);}Factories that create Disposables require special care. The factory must decide:
Clear patterns ensure correct lifecycle management.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
/** * Factory Patterns for Disposable Objects */ interface Disposable { dispose(): void; readonly isDisposed: boolean;} /** * Pattern 1: Transferring Factory * * Factory creates resources; ownership transfers to caller. * Factory does NOT track created resources. */class TransferringFactory { constructor(private config: FactoryConfig) {} /** * Creates a new connection. * * OWNERSHIP: Transfers to caller. Caller MUST dispose. */ create(): DatabaseConnection { return new DatabaseConnection(this.config); } // No dispose() - factory doesn't own anything} /** * Pattern 2: Tracking Factory * * Factory creates resources AND tracks them. * When factory is disposed, all created resources are disposed. */class TrackingFactory implements Disposable { private created: Disposable[] = []; private _isDisposed = false; constructor(private config: FactoryConfig) {} get isDisposed() { return this._isDisposed; } /** * Creates a new connection. * * OWNERSHIP: Factory retains co-ownership. Factory disposal * will dispose this resource even if caller doesn't. */ create(): DatabaseConnection { if (this._isDisposed) { throw new Error("Factory is disposed"); } const conn = new DatabaseConnection(this.config); this.created.push(conn); return conn; } /** * Dispose all resources created by this factory. */ dispose(): void { if (this._isDisposed) return; this._isDisposed = true; for (const resource of this.created) { if (!resource.isDisposed) { try { resource.dispose(); } catch (e) { console.error("Disposal failed:", e); } } } this.created = []; }} /** * Pattern 3: Pooling Factory * * Factory maintains a pool of reusable resources. * "Creating" returns a pooled resource; "disposing" returns it to pool. */class PoolingFactory implements Disposable { private pool: DatabaseConnection[] = []; private inUse = new Set<DatabaseConnection>(); private _isDisposed = false; constructor( private config: FactoryConfig, private maxPoolSize: number = 10 ) {} get isDisposed() { return this._isDisposed; } /** * Acquire a connection from the pool. * * OWNERSHIP: Resource is LEASED, not transferred. * Caller must call release() when done. */ acquire(): PooledConnection { if (this._isDisposed) { throw new Error("Pool is disposed"); } // Get from pool or create new let connection: DatabaseConnection; if (this.pool.length > 0) { connection = this.pool.pop()!; } else { connection = new DatabaseConnection(this.config); } this.inUse.add(connection); // Wrap with auto-return wrapper return new PooledConnection(connection, this); } /** * Return a connection to the pool (internal use). */ release(connection: DatabaseConnection): void { if (!this.inUse.has(connection)) { console.warn("Connection not from this pool"); return; } this.inUse.delete(connection); if (this._isDisposed || this.pool.length >= this.maxPoolSize) { // Pool is full or disposed - actually close the connection connection.dispose(); } else { // Return to pool for reuse this.pool.push(connection); } } dispose(): void { if (this._isDisposed) return; this._isDisposed = true; // Dispose pooled connections for (const conn of this.pool) { try { conn.dispose(); } catch (e) { } } this.pool = []; // In-use connections will be disposed when returned // But log a warning if any are still in use if (this.inUse.size > 0) { console.warn(`Pool disposed with ${this.inUse.size} connections in use`); } }} /** * Wrapper that auto-returns to pool when disposed */class PooledConnection implements Disposable { private _isDisposed = false; constructor( private connection: DatabaseConnection, private pool: PoolingFactory ) {} get isDisposed() { return this._isDisposed; } query(sql: string): ResultSet { if (this._isDisposed) { throw new Error("Connection returned to pool"); } return this.connection.query(sql); } dispose(): void { if (this._isDisposed) return; this._isDisposed = true; // Return to pool instead of disposing this.pool.release(this.connection); }} /** * Pattern 4: Scoped Factory * * Factory that creates resources with automatic scoped cleanup. */class ScopedFactory { constructor(private config: FactoryConfig) {} /** * Execute action with a scoped resource. * Resource is automatically disposed after action completes. */ async withConnection<T>( action: (conn: DatabaseConnection) => Promise<T> ): Promise<T> { const connection = new DatabaseConnection(this.config); try { return await action(connection); } finally { connection.dispose(); } } /** * Execute action with multiple scoped resources. */ async withResources<T>( action: (resources: ScopedResources) => Promise<T> ): Promise<T> { const resources = new ScopedResources(this.config); try { return await action(resources); } finally { resources.dispose(); } }} class ScopedResources implements Disposable { private resources = new CompositeDisposable(); private _isDisposed = false; constructor(private config: FactoryConfig) {} get isDisposed() { return this._isDisposed; } connection(): DatabaseConnection { return this.resources.add(new DatabaseConnection(this.config)); } fileReader(path: string): FileReader { return this.resources.add(new FileReader(path)); } dispose(): void { if (this._isDisposed) return; this._isDisposed = true; this.resources.dispose(); }}Testing nested Disposables requires verifying:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172
/** * Testing Strategies for Nested Disposables */ // Test helper: Spy Disposableclass SpyDisposable implements Disposable { public disposeCallCount = 0; public disposeOrder = 0; public disposedAt: Date | null = null; private _isDisposed = false; private static orderCounter = 0; static resetOrderCounter(): void { SpyDisposable.orderCounter = 0; } get isDisposed() { return this._isDisposed; } dispose(): void { if (this._isDisposed) return; this.disposeCallCount++; this.disposeOrder = ++SpyDisposable.orderCounter; this.disposedAt = new Date(); this._isDisposed = true; }} // Test helper: Failing Disposableclass FailingDisposable implements Disposable { private _isDisposed = false; public disposeAttempted = false; constructor(public errorMessage: string = "Disposal failed") {} get isDisposed() { return this._isDisposed; } dispose(): void { this.disposeAttempted = true; throw new Error(this.errorMessage); }} // Testsdescribe("Nested Disposables", () => { beforeEach(() => { SpyDisposable.resetOrderCounter(); }); describe("CompositeDisposable", () => { it("disposes all children when disposed", () => { const composite = new CompositeDisposable(); const child1 = composite.add(new SpyDisposable()); const child2 = composite.add(new SpyDisposable()); const child3 = composite.add(new SpyDisposable()); composite.dispose(); expect(child1.isDisposed).toBe(true); expect(child2.isDisposed).toBe(true); expect(child3.isDisposed).toBe(true); }); it("disposes in LIFO order by default", () => { const composite = new CompositeDisposable({ disposalOrder: 'lifo' }); const first = composite.add(new SpyDisposable()); const second = composite.add(new SpyDisposable()); const third = composite.add(new SpyDisposable()); composite.dispose(); // Last added should be first disposed expect(third.disposeOrder).toBe(1); expect(second.disposeOrder).toBe(2); expect(first.disposeOrder).toBe(3); }); it("continues disposing when one child fails", () => { const composite = new CompositeDisposable({ continueOnError: true }); const child1 = composite.add(new SpyDisposable()); const failing = composite.add(new FailingDisposable()); const child3 = composite.add(new SpyDisposable()); composite.dispose(); expect(child1.isDisposed).toBe(true); expect(failing.disposeAttempted).toBe(true); expect(child3.isDisposed).toBe(true); }); it("immediately disposes items added after container is disposed", () => { const composite = new CompositeDisposable(); composite.dispose(); const lateChild = new SpyDisposable(); composite.add(lateChild); expect(lateChild.isDisposed).toBe(true); }); it("is idempotent - multiple dispose calls are safe", () => { const composite = new CompositeDisposable(); const child = composite.add(new SpyDisposable()); composite.dispose(); composite.dispose(); composite.dispose(); expect(child.disposeCallCount).toBe(1); }); }); describe("Parent-Child Relationships", () => { it("parent disposes children before itself", () => { // Parent with custom spy to track order class ParentWithChildren implements Disposable { public child1 = new SpyDisposable(); public child2 = new SpyDisposable(); public selfDisposed = false; public selfDisposeOrder = 0; private _isDisposed = false; get isDisposed() { return this._isDisposed; } dispose(): void { if (this._isDisposed) return; this._isDisposed = true; this.child1.dispose(); this.child2.dispose(); this.selfDisposed = true; this.selfDisposeOrder = SpyDisposable.orderCounter + 1; } } const parent = new ParentWithChildren(); parent.dispose(); expect(parent.child1.disposeOrder).toBe(1); expect(parent.child2.disposeOrder).toBe(2); expect(parent.selfDisposeOrder).toBe(3); }); }); describe("Disposal Graph", () => { it("disposes in reverse topological order", () => { // A depends on B,C; B depends on D; C depends on D const d = new SpyDisposable() as SpyDisposable & DisposableNode; (d as any).dependencies = []; const b = new SpyDisposable() as SpyDisposable & DisposableNode; (b as any).dependencies = [d]; const c = new SpyDisposable() as SpyDisposable & DisposableNode; (c as any).dependencies = [d]; const a = new SpyDisposable() as SpyDisposable & DisposableNode; (a as any).dependencies = [b, c]; disposeGraph([a]); // A should be first (no dependents) expect(a.disposeOrder).toBe(1); // B and C next (depend on D, but not each other) expect([2, 3]).toContain(b.disposeOrder); expect([2, 3]).toContain(c.disposeOrder); // D last (B and C depend on it) expect(d.disposeOrder).toBe(4); }); });});Module Complete:
You have now mastered the Disposable Pattern for resource management:
These patterns form the foundation of reliable resource management in any object-oriented system. Apply them consistently, and your applications will handle resources gracefully, avoiding the leaks and corruption that plague systems lacking proper cleanup discipline.
You have completed the Disposable Pattern module. You now understand how to design, implement, use, and test Disposable objects—from simple single-resource wrappers to complex hierarchies with shared ownership. This knowledge is essential for building robust, leak-free systems that maintain integrity even under adverse conditions.