Compile-Time Resource Tracking in Rust: From Runtime Brackets to Type-Level Safety
Source: Dev.to
Originally published on Entropic Drift
The Problem: Runtime Resource Leaks
Resource leaks are insidious. A forgotten close(), a missing commit(), an exception path that skips cleanup. Your code compiles. It runs. It even works until the connection pool exhausts, the file handles run out, or a transaction holds a lock forever.
The traditional Rust approach uses RAII: wrap resources in structs that clean up in Drop. This works well for ownership‑based patterns, but falls short when:
- Resources are passed through async boundaries.
- Effects need to be composed and chained.
- You want to express protocols (e.g.,
begin → query → commit/rollback). - Clean‑up logic itself can fail and needs handling.
What if the type system could enforce that every resource acquired is eventually released before you run the code?
The Foundation: Runtime Brackets
Stillwater has provided runtime resource safety since the early versions through the bracket pattern.
The bracket function guarantees that cleanup runs even when errors occur:
use stillwater::effect::prelude::*;
let result = bracket(
open_connection(), // Acquire
|conn| async move { conn.close().await }, // Release (always runs)
|conn| fetch_data(conn), // Use
)
.run(&env)
.await;
The release function always runs, regardless of whether the use phase succeeds, fails, or panics.
Bracket Variants
| Variant | Description |
|---|---|
bracket | Basic acquire → use → release with guaranteed cleanup |
bracket2, bracket3 | Manage multiple resources; cleanup follows LIFO order |
bracket_full | Returns a BracketError that contains explicit error information for both the use and cleanup phases |
acquiring | Fluent builder for composing several resources together |
Fluent Builder Example
// Multiple resources with the fluent builder
let result = acquiring(open_conn(), |c| async move { c.close().await })
.and(open_file(path), |f| async move { f.close().await })
.with_flat2(|conn, file| process(conn, file))
.run(&env)
.await;
The code works, and cleanup happens automatically.
However, because this safety is enforced at runtime, you won’t know until the program executes that your brackets are correctly balanced.
Stillwater 0.14.0 – Type‑Level Resource Tracking
Stillwater 0.14.0 builds on the runtime bracket foundation with compile‑time resource tracking. The compiler can now prove that your resources are balanced before the code runs.
use stillwater::effect::resource::*;
use stillwater::pure;
// The TYPE says: this acquires a `FileRes`
fn open_file(path: &str) -> impl ResourceEffect<Acquires = FileRes, Releases = Empty> {
pure::(format!("handle:{}", path)).acquires::()
}
// The TYPE says: this releases a `FileRes`
fn close_file(handle: String) -> impl ResourceEffect {
pure::(()).releases::()
}
The ResourceEffect trait extends Effect with two associated types:
Acquires– the resources this effect creates.Releases– the resources this effect consumes.
These associated types serve as documentation that the compiler can verify.
The Bracket Pattern: Guaranteed Resource Neutrality
The real power comes from resource_bracket. It enforces that an operation acquires a resource, uses it, and releases it:
fn read_file_safely(path: &str) -> impl ResourceEffect {
bracket::()
.acquire(open_file(path))
.release(|handle| async move { close_file(handle).run(&()).await })
.use_fn(|handle| read_contents(handle))
}
bracket::()captures the resource type once, then infers everything else from the chained method calls.- The return type shows
Acquires = Empty, Releases = Empty, meaning the function is resource‑neutral. - If the bracket is mismatched (e.g., the acquire does not match the release), the code will fail to compile.
Protocol Enforcement: Database Transactions
Consider database transactions. A transaction must be opened, used, and then either committed or rolled back. Missing the final step is a bug. Let’s make it a compile‑time error:
fn begin_tx() -> impl ResourceEffect {
pure::("tx_12345".to_string()).acquires::()
}
fn commit(tx: String) -> impl ResourceEffect {
pure::(()).releases::()
}
fn rollback(tx: String) -> impl ResourceEffect {
pure::(()).releases::()
}
fn execute_query(tx: &str, query: &str) -> impl ResourceEffect {
// Queries are resource‑neutral
pure::(vec!["row1".to_string()]).neutral()
}
Now a transaction operation that doesn’t close is a type error:
// This function signature promises resource neutrality
fn transfer_funds() -> impl ResourceEffect {
bracket::()
.acquire(begin_tx())
.release(|tx| async move { commit(tx).run(&()).await })
.use_fn(|tx| {
execute_query(tx, "UPDATE accounts SET balance = balance - 100 WHERE id = 1");
execute_query(tx, "UPDATE accounts SET balance = balance + 100 WHERE id = 2");
pure::("transferred".to_string())
})
}
Enforcing Proper Transaction Closure
The type signature enforces that transactions are properly closed. If you try to return begin_tx() without a matching release, the code won’t compile.
Tracking Multiple Resources
Real systems juggle multiple resource types. The tracking composes:
// Acquire both a file and a database connection
let effect = pure::(42)
.acquires::()
.also_acquires::();
// Release both
let cleanup = pure::(())
.releases::()
.also_releases::();
The type system tracks Has<…> as a type‑level set. Union operations combine sets from chained effects.
Compile‑Time Assertions
For critical code paths, assert resource neutrality explicitly:
fn safe_operation() -> impl ResourceEffect {
let effect = bracket::()
.acquire(open_file("data.txt"))
.release(|h| async move { close_file(h).run(&()).await })
.use_fn(|h| read_contents(h));
// This is a compile‑time check, not a runtime assert
assert_resource_neutral(effect)
}
Note: If
effectisn’t actually resource‑neutral, this fails at compile time.
The assertion incurs no runtime cost because it operates purely at the type level.
Custom Resource Kinds
Define your own resource markers for domain‑specific tracking:
struct ConnectionPoolRes;
impl ResourceKind for ConnectionPoolRes {
const NAME: &'static str = "ConnectionPool";
}
fn acquire_connection() -> impl ResourceEffect {
pure::("conn_42".to_string()).acquires()
}
fn release_connection(conn: String) -> impl ResourceEffect {
pure::(()).releases()
}
The built‑in markers (FileRes, DbRes, LockRes, TxRes, SocketRes) cover common cases, but you’re not limited to them.
Zero Runtime Overhead
This is the crucial point: all tracking happens at compile time. The implementation uses:
PhantomDatafor type‑level annotations (zero‑sized)- Associated types for resource‑set tracking (computed at compile time)
The Tracked wrapper delegates directly to the inner effect:
use std::marker::PhantomData;
pub struct Tracked<Eff> {
inner: Eff,
_phantom: PhantomData<()>, // Zero bytes
}
impl<Eff> Effect for Tracked<Eff>
where
Eff: Effect,
{
type Env = Eff::Env;
type Output = Eff::Output;
type Error = Eff::Error;
async fn run(self, env: &Self::Env) -> Result<Self::Output, Self::Error> {
self.inner.run(env).await // Just delegates
}
}
There are no runtime checks, no allocations, and no indirection. The tracking is purely for the type checker.
Comparison: RAII vs Bracket vs Type‑Level Tracking
| Approach | Leak Detection | Async‑Safe | Protocol Enforcement | Runtime Cost |
|---|---|---|---|---|
RAII (Drop) | Runtime | Limited | No | Minimal |
Stillwater bracket | Runtime | Yes | No | Minimal |
Stillwater bracket::() | Compile‑time | Yes | Yes | Zero |
- RAII works when you own the resource directly.
bracket()(runtime) guarantees that cleanup code always runs—ideal for simple acquire/use/release patterns.bracket::()(type‑level) goes further: the acquire‑use‑release protocol is encoded in the type signature and verified at compile time.
How to Use Them Together
- Use runtime brackets for guaranteed cleanup in asynchronous contexts.
- Add type‑level tracking to get compile‑time verification of more complex protocols.
This combination gives you both safety (no leaks) and confidence (protocol correctness).
When to Use This
Type‑level resource tracking shines when:
- Resource leaks are high‑severity bugs (e.g., connection pools, file systems, critical sections)
- Protocols must be followed (e.g.,
begin → work → commit/rollback) - Effects are composed across function boundaries
- You want documentation that can’t lie – types are always current
For simple, single‑owner resources, RAII remains the right choice. For complex effect pipelines where resource safety is critical, type‑level tracking catches bugs that runtime checks would miss.
Getting Started
Add Stillwater 0.14.0 to your Cargo.toml:
[dependencies]
stillwater = "0.14"
Import the resource‑tracking module:
use stillwater::effect::resource::*;
use stillwater::pure;
// Start annotating your effects
fn my_acquire() -> impl ResourceEffect {
pure::("handle".to_string()).acquires()
}
The existing Effect API continues to work unchanged. Resource tracking is purely additive and opt‑in.
Summary
Stillwater’s resource‑management story now has two complementary layers:
| Layer | Purpose |
|---|---|
Runtime brackets (bracket, bracket2, acquiring) | Guarantee cleanup always runs, even on errors or panics |
Compile‑time tracking (bracket::() builder, ResourceEffect) | Prove resource protocols are balanced before the code runs |
Together they provide defense in depth:
- Runtime brackets ensure cleanup happens in production.
- Type‑level tracking catches protocol violations during development.
- Ergonomic API via a builder pattern (single type parameter).
- Zero runtime overhead via
PhantomData. - Composable across effect chains and function boundaries.
Resources are too important to leave to runtime chance. Start with brackets for guaranteed cleanup, then add type‑level tracking when protocols matter.
About Stillwater
Stillwater is a Rust library for validation, effect composition, and functional‑programming patterns. Version 0.14.0 adds compile‑time resource tracking as a type‑level layer on top of its existing runtime bracket patterns.
Follow & Explore
Want more content like this?
- Follow me on Dev.to
- Subscribe to Entropic Drift for posts on AI‑powered development workflows, Rust tooling, and technical‑debt management.
Open‑source projects: