Resource — Safe Lifecycle Management
2.32 kB minified + gzipped
Managing resources like database connections, file handles, or mutex locks requires guaranteeing that cleanup logic runs even if operations fail or throw runtime errors:
This works for a single resource within a narrow scope. However, when workflows require multiple
resources — such as a database connection, a distributed lock, and a cache client — nested
try/finally blocks quickly become fragile. If the second resource fails to open or an operation
aborts midway, it is easy to leak an already-opened handle or release resources in the wrong order.
Resource<E, A> solves this structurally by packaging the potentially fallible acquire step (a
Task.Result<E, A>) and the infallible release step (a (value: A) => Task<unknown>) into a
composable lifecycle bracket — an abstraction pairing allocation with guaranteed cleanup:
flowchart TB
Start(["Resource.use(resource, work)"]) --> Acquire["Acquisition Phase (make, chain, or all)"]
Acquire -->|"All acquired successfully"| Work["Work Function (Task.Result)"]
Acquire -->|"Acquisition failure"| Rollback["Roll Back Acquired Finalizers (LIFO Order)"]
Rollback --> FailExit(["Return Acquire Error"])
Work -->|"Succeeds, fails, or throws"| Release["Release All Finalizers (LIFO Order)"]
Release --> WorkExit(["Return Work Result (Ok or Err)"])
By describing how to open and how to close a resource once, we delegate execution safety to
Resource.use, which guarantees that the cleanup step is always executed whenever acquisition
succeeds.
Creating a Resource
Section titled “Creating a Resource”We define a resource by supplying the actions to open and close it using Resource.make:
The release callback receives the exact value produced by the acquisition step. Even if a query
fails midway, Resource.use will execute connection.close() automatically.
Resources that cannot fail
Section titled “Resources that cannot fail”If acquiring the resource is guaranteed to succeed — such as acquiring an in-memory lock or starting
a local timer — we can use Resource.from.Task to skip error mapping:
The error parameter defaults to never to declare to the compiler that this resource cannot fail
during acquisition.
Running Actions with use
Section titled “Running Actions with use”To perform work with our resource, we pass our operational logic to Resource.use. The work
function receives the acquired value and returns a Task.Result:
Execution proceeds through four steps:
dbResourceexecutes itsacquiretask. If acquisition fails, execution halts and yields the error without invokingrelease.- Upon successful acquisition, the active connection is passed to the query function.
- Whether the query resolves to
Ok, returns anErr, or throws an uncaught exception, thereleasefinalizer (connection.close()) runs immediately. - The outcome of the query (
OkorErr) is returned to the caller.
Transforming Values with map
Section titled “Transforming Values with map”When we need to transform or project the acquired asset (for example, deriving a repository from a
connection pool or selecting an authenticated sub-client) while preserving guaranteed cleanup of the
original asset, we use Resource.map:
The consumer receives UserRepository directly in Resource.use, while Resource retains the
original pool internally to guarantee pool.drain() runs when the scope exits.
Dependent Acquisition with chain
Section titled “Dependent Acquisition with chain”When acquiring a second resource depends directly on the value of an earlier resource — such as
opening an SSH tunnel before connecting to a database, or connecting to a database before acquiring
an advisory lock — Resource.chain sequences them linearly:
Rollback on partial failure
Section titled “Rollback on partial failure”If acquiring the advisory lock fails with an error:
dbconnection pool is immediately closed.tunnelis immediately closed.- The lock acquisition error propagates to the caller.
When the work function inside Resource.use completes, cleanup executes in strict reverse
(last-in, first-out / LIFO) order: the advisory lock is released first, the database connection is
closed second, and the SSH tunnel is closed last.
Grouping Multiple Resources with all
Section titled “Grouping Multiple Resources with all”When an operation requires multiple distinct, independent resources — such as a database connection,
a Redis cache client, and an S3 storage bucket — Resource.all combines them into a single, unified
lifecycle bracket.
Named record acquisition
Section titled “Named record acquisition”Resource.all acquires db, redis, and s3 sequentially. If s3 fails to acquire, redis and
db are immediately released in reverse order. When the migration completes, all three resources
are released in reverse order (s3, then redis, then db).
Tuple acquisition
Section titled “Tuple acquisition”Resource.all also accepts a tuple of resources:
When pairing exactly two resources, Resource.combine(dbResource, cacheResource) pairs them into a
2-tuple.
Pipeline Composition
Section titled “Pipeline Composition”Here is an end-to-end audit log export pipeline. It allocates a temporary scratch directory on disk alongside a read-only database snapshot, dumps log records to a compressed archive, and uploads the resulting file to cloud storage. Whether queries fail, disk space runs out, or upload times out, the scratch directory is cleaned up and the database connection is closed in reverse order:
Problems it solves
Section titled “Problems it solves”- Vanilla
try/finallyblocks and nested callback pyramids (withConnection(c => withTx(t => ...))) drift rightward, tangling return values with teardown logic.Resourceturns acquisition and cleanup into first-class values that compose linearly withpipeandchain. - Partial setup failures frequently leave orphan connections in the background. If service 3 of 4
fails to open, manual code must track prior handles and unwind them without masking the setup
error.
Resource.allandResource.chainguarantee automatic rollback in reverse order upon any acquisition failure. - Dependent infrastructure requires strict reverse teardown. Terminating an SSH bastion tunnel
before disconnecting an active PostgreSQL client triggers socket errors or hangs.
Resourcemaintains an internal finalizer stack that guarantees teardown unwinds in exact reverse order of acquisition. - Cleanup runs reliably across every exit path. Whether a computation resolves to
Ok, returns an expectedErr, throws an unhandled runtime error, or aborts viaAbortSignal, finalizers execute without leaving lingering processes or open sockets. - Batch tools and scripts often create scratch directories, spawn child processes, or stream file
handles.
Resource.makebinds allocation directly to disposal, ensuring OS assets are released as soon as work leaves scope.