Automatic Resource Management with acquireRelease

Avatar of Hemanta SundarayHemanta Sundaray

In the previous chapter, we used Effect’s low-level Scope APIs to open and close a file handle. The resource was properly cleaned up, but we had to remember to call Scope.close on every exit path. Miss one path and the resource leaks. We traded one form of manual bookkeeping for another.

Effect provides two APIs that eliminate this problem entirely: Effect.acquireRelease and Effect.scoped. Together, they let you declare how a resource is opened and closed in one place, and Effect takes care of the rest.

Here is the same demo rewritten using these APIs. Replace the code inside resource-leak-demo.ts with the following:

resource-leak-demo.ts
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 content
const tmpDir = await mkdtemp(join(tmpdir(), "leak-demo-"));
const filePath = join(tmpDir, "data.txt");
await writeFile(filePath, "some important data", "utf-8");
// The file as a managed resource
const managedFile = Effect.acquireRelease(
// acquire: open the file
Effect.promise(async () => {
const fileHandle = await open(filePath, "r");
handlesOpened++;
return fileHandle;
}),
// release: close the file
(fileHandle) =>
Effect.promise(async () => {
await fileHandle.close();
handlesClosed++;
}),
);
// Read the data inside data.txt and throw an error if the data contains the term "important"
function readData() {
return Effect.scoped(
Effect.gen(function* () {
const fileHandle = yield* managedFile;
const content = yield* Effect.promise(() => fileHandle.readFile("utf-8"));
if (content.includes("important")) {
return yield* Effect.fail(
new UnsupportedFormatError("Failed to process: unsupported format"),
);
}
return content;
}),
);
}
// Run readData 5 times
async 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();

Notice that in readData(), there is no Scope.make(), no Scope.addFinalizer(), no Scope.close() on every exit path. Let’s understand how.

Effect.acquireRelease

const managedFile = Effect.acquireRelease(
// acquire: open the file
Effect.promise(async () => {
const fileHandle = await open(filePath, "r");
handlesOpened++;
return fileHandle;
}),
// release: close the file
(fileHandle) =>
Effect.promise(async () => {
await fileHandle.close();
handlesClosed++;
}),
);

Effect.acquireRelease takes two arguments:

  • The acquire effect: an Effect that opens or creates the resource. In our case, it opens the file and returns the file handle.
  • The release function: a function that receives the acquired resource and returns an Effect that cleans it up. In our case, it takes the file handle and closes it.

Effect.acquireRelease does not run anything immediately. It returns a new Effect that says: “When you run me inside a scope, I will acquire the resource and register the release as a finalizer in that scope.” The return type is Effect<FileHandle, never, Scope.Scope>. Notice the Scope.Scope in the requirements position. This tells the type system that this effect needs a scope to run.

The most important point to understand is that the acquire and release are declared together in one place. You cannot forget to register the cleanup because it is part of the same declaration as the acquisition. There is no gap between “open the file” and “register the close” where you could accidentally forget the second step.

Effect.scoped

function readData() {
return Effect.scoped(
Effect.gen(function* () {
const fileHandle = yield* managedFile;
// ... use the file handle ...
}),
);
}

Effect.scoped is the function that provides the scope. It does four things:

  • Creates a new scope.
  • Runs the effect inside that scope. When yield* managedFile executes, the file is opened and its cleanup is registered as a finalizer in the scope.
  • Closes the scope when the effect finishes. This happens regardless of whether the effect succeeded, failed, or was interrupted. Closing the scope runs all registered finalizers.
  • Removes the Scope.Scope requirement from the return type. Without Effect.scoped, the Effect.gen block produces an Effect<string, UnsupportedFormatError, Scope.Scope> because yield* managedFile requires a scope. After wrapping it with Effect.scoped, the return type becomes Effect<string, UnsupportedFormatError, never>. The Scope.Scope is gone because Effect.scoped already provided it.

This is why there is no Scope.close call anywhere in the function. Effect.scoped handles it for you on every possible exit path.

Walking Through the readData Function

Let’s trace through what happens when readData is called:

  1. Effect.scoped creates a new scope.
  2. yield* managedFile runs the acquire effect. The file is opened. The release function is registered as a finalizer in the scope. The file handle is returned.
  3. We read the file content.
  4. The content contains "important", so we hit Effect.fail.
  5. The generator short-circuits. Effect does not continue to the return content line.
  6. Effect.scoped sees that the effect has completed (with a failure). It closes the scope.
  7. The scope runs all registered finalizers in reverse order. Our release function runs: the file handle is closed and handlesClosed is incremented.
  8. The failure propagates out of Effect.scoped.

If the content did not contain "important", step 4 would not fail. The function would return the content. Effect.scoped would still close the scope (step 6), still run the finalizer (step 7), and the file handle would still be closed. The cleanup happens no matter what.

Run the file. You will see the following output in the terminal:

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: 0

Zero leaked handles. Same result, but with far less code and no manual scope management.

What About Multiple Resources?

Remember the nesting problem from Chapter 2? With acquireRelease, multiple resources compose flatly. If we had an input file and an output file, the code would look like this:

const managedInputFile = Effect.acquireRelease(
Effect.promise(async () => await open(inputPath, "r")),
(handle) => Effect.promise(async () => await handle.close()),
);
const managedOutputFile = Effect.acquireRelease(
Effect.promise(async () => await open(outputPath, "w")),
(handle) => Effect.promise(async () => await handle.close()),
);
const program = Effect.scoped(
Effect.gen(function* () {
const input = yield* managedInputFile;
const output = yield* managedOutputFile;
const content = yield* Effect.promise(() => input.readFile("utf-8"));
const processed = content.toUpperCase();
yield* Effect.promise(() => output.writeFile(processed, "utf-8"));
}),
);

No nesting. Both resources are acquired sequentially with yield*, and both are cleaned up when the scope closes. The cleanup happens in reverse order automatically: the output file is closed first (opened last), then the input file is closed (opened first). This is the correct release order, and you did not have to think about it. Effect handles it for you.

The manual Scope APIs (Scope.make, Scope.addFinalizer, Scope.close) are still available when you need fine-grained control over resource lifetimes. But for the vast majority of use cases, Effect.acquireRelease and Effect.scoped are all you need.

Sign in to save progress

Stay in the loop

Get notified when new Effect Atom related content is published.