In the previous chapter, we saw all the problems we run into when dealing with opening and closing resources using JavaScript’s try/finally. Now let’s rewrite the same file handle demo using Effect’s Scope and see what changes.
Replace the code inside resource-leak-demo.ts with the following:
import { mkdtemp, open, rm, writeFile } from "node:fs/promises";import { tmpdir } from "node:os";import { join } from "node:path";import { Effect, Exit, Scope } from "effect";
let handlesOpened = 0;let handlesClosed = 0;
function printStatus() { const handlesLeaked = handlesOpened - handlesClosed; console.log( ` Handles opened: ${handlesOpened} | Handles closed: ${handlesClosed} | Handles leaked: ${handlesLeaked}`, );}
class UnsupportedFormatError { readonly _tag = "UnsupportedFormatError"; constructor(readonly message: string) {}}
// Create a temporary directory and then create data.txt inside it with some contentconst tmpDir = await mkdtemp(join(tmpdir(), "leak-demo-"));const filePath = join(tmpDir, "data.txt");await writeFile(filePath, "some important data", "utf-8");
// Read the data inside data.txt and throw an error if the data contains the term "important"function readData() { return Effect.gen(function* () { // Step 1: Create a scope const scope = yield* Scope.make();
// Step 2: Acquire the resource const handle = yield* Effect.promise(async () => { const fileHandle = await open(filePath, "r"); handlesOpened++; return fileHandle; });
// Step 3: Register a finalizer on the scope yield* Scope.addFinalizer( scope, Effect.promise(async () => { await handle.close(); handlesClosed++; }), );
// Step 4: Use the resource const content = yield* Effect.promise(() => handle.readFile("utf-8"));
if (content.includes("important")) { // Step 5a: Close the scope before failing yield* Scope.close(scope, Exit.fail("error")); return yield* Effect.fail( new UnsupportedFormatError("Failed to process: unsupported format"), ); }
// Step 5b: Close the scope on success yield* Scope.close(scope, Exit.void); return content; });}
// Run readData 5 timesasync function main() { for (let i = 1; i <= 5; i++) { const result = await Effect.runPromise( readData().pipe( Effect.catch(() => Effect.succeed(`error caught and "handled"`)), ), ); console.log(`Call ${i}: ${result}`); printStatus(); console.log(); }
// Delete the temporary directory await rm(tmpDir, { recursive: true, force: true });}
await main();Let’s walk through the readData function step by step.
Step 1: Create a Scope
const scope = yield * Scope.make();Scope.make() returns an Effect<Scope.Closeable>. A Closeable scope is one that you can explicitly close later by calling Scope.close on it.
Note that at this point, the scope’s internal state is empty. No finalizers are registered.
Step 2: Acquire the Resource
const handle = yield * Effect.promise(async () => { const fileHandle = await open(filePath, "r"); handlesOpened++; return fileHandle; });Here, we open the file and increment our tracker.
Notice that the scope and the acquisition are completely separate at this point. The scope does not know about the file handle yet. They get connected in the next step.
Step 3: Register a Finalizer on the Scope
yield * Scope.addFinalizer( scope, Effect.promise(async () => { await handle.close(); handlesClosed++; }), );Scope.addFinalizer takes two arguments:
- The scope to register with: the one we created in Step 1.
- The finalizer itself: an
Effectthat describes the cleanup work. In our case, it closes the file handle and increments our tracker.
After this call, the scope now contains one entry: our file-closing effect.
The finalizer does not run now. It just sits inside the scope, waiting. It will only run when someone calls Scope.close on this scope.
There is also Scope.addFinalizerExit, which gives your finalizer access to
the Exit value so it can react differently depending on whether the scope
closed due to success or failure. Our finalizer here does not need this, so we
use the simpler Scope.addFinalizer.
Step 4: Use the Resource
const content = yield * Effect.promise(() => handle.readFile("utf-8"));Standard usage. Read the file content through the handle. Nothing scope-related here.
Now comes the final step, and this is where it gets interesting. We have two exit paths, and we must close the scope on both of them.
On the error path, we close the scope with a failure exit before returning the error:
yield * Scope.close(scope, Exit.fail("error"));return ( yield * Effect.fail( new UnsupportedFormatError("Failed to process: unsupported format"), ));On the success path, we close the scope with a success exit before returning the content:
yield * Scope.close(scope, Exit.void);return content;Scope.close takes two arguments:
- The scope to close: the one we created in Step 1.
- An
Exitvalue describing how things ended. We passExit.fail("error")on the error path andExit.voidon the success path.
When called, the scope runs every registered finalizer in reverse order of registration (last registered, first run). In our case there is only one finalizer, so order does not matter yet. Each finalizer receives the Exit value you passed, so it could branch on success vs failure if needed.
After closing, the scope is done.
Run the file. You will see the following output in the terminal:
Call 1: error caught and "handled" Handles opened: 1 | Handles closed: 1 | Handles leaked: 0
Call 2: error caught and "handled" Handles opened: 2 | Handles closed: 2 | Handles leaked: 0
Call 3: error caught and "handled" Handles opened: 3 | Handles closed: 3 | Handles leaked: 0
Call 4: error caught and "handled" Handles opened: 4 | Handles closed: 4 | Handles leaked: 0
Call 5: error caught and "handled" Handles opened: 5 | Handles closed: 5 | Handles leaked: 0Zero leaked handles. Every file that was opened was also closed, even though every call hit the error path. The scope’s finalizer took care of it.
But look at how many places we had to remember to call Scope.close:
- Before the
Effect.failon the error path - At the end on the success path
And this is a simple example with just one branching point. In real code you might have three, four, five different exit paths. Miss even one and you leak the resource. We have essentially recreated the same problem as the original try/finally version, just in Effect syntax.
Effect provides another approach, Effect.acquireRelease and Effect.scoped, to solve this issue. They take the three manual steps we performed:
- Create a scope
- Register a finalizer
- Close the scope on every exit path
…and bundle them into a single declaration where the acquire and release are defined together, and the scope is created and closed automatically. You never manually call Scope.close. The framework does it for you on every possible exit path: success, failure and even interruption.
In the next chapter, we will rewrite this demo using Effect.acquireRelease and Effect.scoped, and you will see how much simpler it gets.