DEV Community

Cover image for Compile-Time Resource Tracking in Rust: From Runtime Brackets to Type-Level Safety
Glen Baker
Glen Baker

Posted on • Originally published at entropicdrift.com

Compile-Time Resource Tracking in Rust: From Runtime Brackets to Type-Level Safety

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 earlier versions through the bracket pattern. The bracket function guarantees 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;
Enter fullscreen mode Exit fullscreen mode

This solves the "forgotten cleanup" problem at runtime. The release function always runs whether it has success or failure, panic or not. Stillwater provides variants for different needs:

  • bracket — Basic acquire/use/release with guaranteed cleanup
  • bracket2, bracket3 — Multiple resources with LIFO cleanup order
  • bracket_full — Returns BracketError with explicit error info for both use and cleanup failures
  • acquiring — Fluent builder for composing multiple resources
// 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;
Enter fullscreen mode Exit fullscreen mode

This works. Cleanup happens. But it's still runtime verification so you don't know until the code runs that your brackets are balanced.

Stillwater 0.14.0: Type-Level Resource Tracking

Stillwater 0.14.0 builds on the runtime bracket foundation with compile-time resource tracking. Now you can make the compiler prove 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<
    Output = String,
    Acquires = Has<FileRes>,
    Releases = Empty,
> {
    pure::<_, String, ()>(format!("handle:{}", path)).acquires::<FileRes>()
}

// The TYPE says: this releases a FileRes
fn close_file(handle: String) -> impl ResourceEffect<
    Output = (),
    Acquires = Empty,
    Releases = Has<FileRes>,
> {
    pure::<_, String, ()>(()).releases::<FileRes>()
}
Enter fullscreen mode Exit fullscreen mode

The ResourceEffect trait extends Effect with two associated types:

  • Acquires: what resources this effect creates
  • Releases: what resources this effect consumes

This is documentation that the compiler can check.

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<
    Output = String,
    Acquires = Empty,  // <-- Guaranteed by the type system
    Releases = Empty,  // <-- No leaks possible
> {
    bracket::<FileRes>()
        .acquire(open_file(path))
        .release(|handle| async move { close_file(handle).run(&()).await })
        .use_fn(|handle| read_contents(handle))
}
Enter fullscreen mode Exit fullscreen mode

The bracket::<FileRes>() builder captures the resource type once, then infers everything else from the chained method calls.

The return type says Acquires = Empty, Releases = Empty. This means the function is resource-neutral. If your bracket is wrong and the acquire doesn't match the release then it won't 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 error:

fn begin_tx() -> impl ResourceEffect<Acquires = Has<TxRes>> {
    pure::<_, String, ()>("tx_12345".to_string()).acquires::<TxRes>()
}

fn commit(tx: String) -> impl ResourceEffect<Releases = Has<TxRes>> {
    pure::<_, String, ()>(()).releases::<TxRes>()
}

fn rollback(tx: String) -> impl ResourceEffect<Releases = Has<TxRes>> {
    pure::<_, String, ()>(()).releases::<TxRes>()
}

fn execute_query(tx: &str, query: &str) -> impl ResourceEffect<
    Acquires = Empty,
    Releases = Empty,
> {
    // Queries are resource-neutral
    pure::<_, String, ()>(vec!["row1".to_string()]).neutral()
}
Enter fullscreen mode Exit fullscreen mode

Now a transaction operation that doesn't close is a type error:

// This function signature promises resource neutrality
fn transfer_funds() -> impl ResourceEffect<Acquires = Empty, Releases = Empty> {
    bracket::<TxRes>()
        .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::<_, String, ()>("transferred".to_string())
        })
}
Enter fullscreen mode Exit fullscreen mode

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 database connection
let effect = pure::<_, String, ()>(42)
    .acquires::<FileRes>()
    .also_acquires::<DbRes>();

// Release both
let cleanup = pure::<_, String, ()>(())
    .releases::<FileRes>()
    .also_releases::<DbRes>();
Enter fullscreen mode Exit fullscreen mode

The type system tracks Has<FileRes, Has<DbRes>> 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<Acquires = Empty, Releases = Empty> {
    let effect = bracket::<FileRes>()
        .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)
}
Enter fullscreen mode Exit fullscreen mode

If effect isn't actually resource-neutral, this fails at compile time. The assertion costs nothing at runtime since it's purely 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<Acquires = Has<ConnectionPoolRes>> {
    pure::<_, String, ()>("conn_42".to_string()).acquires::<ConnectionPoolRes>()
}

fn release_connection(conn: String) -> impl ResourceEffect<Releases = Has<ConnectionPoolRes>> {
    pure::<_, String, ()>(()).releases::<ConnectionPoolRes>()
}
Enter fullscreen mode Exit fullscreen mode

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:

  • PhantomData for type-level annotations (zero-sized)
  • Associated types for resource set tracking (computed at compile time)
  • The Tracked wrapper delegates directly to the inner effect
pub struct Tracked<Eff, Acq: ResourceSet = Empty, Rel: ResourceSet = Empty> {
    inner: Eff,
    _phantom: PhantomData<(Acq, Rel)>,  // Zero bytes
}

impl<Eff: Effect, Acq: ResourceSet, Rel: ResourceSet> Effect for Tracked<Eff, Acq, Rel> {
    async fn run(self, env: &Self::Env) -> Result<Self::Output, Self::Error> {
        self.inner.run(env).await  // Just delegates
    }
}
Enter fullscreen mode Exit fullscreen mode

There are no runtime checks, no allocations, 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::<R>() Compile time Yes Yes Zero

RAII works when you own the resource directly. Stillwater's runtime bracket() ensures cleanup always runs. This is great for simple acquire/use/release patterns. The type-level bracket::<R>() builder goes further: it makes the protocol—acquire, use, release—visible in the type signature and checked before the code runs.

Use them together: runtime brackets for guaranteed cleanup, type-level tracking for compile-time verification of complex protocols.

When to Use This

Type-level resource tracking shines when:

  1. Resource leaks are high-severity bugs (connection pools, file systems, critical sections)
  2. Protocols must be followed (begin → work → commit/rollback)
  3. Effects are composed across function boundaries
  4. 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"
Enter fullscreen mode Exit fullscreen mode

Import the resource tracking module:

use stillwater::effect::resource::*;
use stillwater::pure;

// Start annotating your effects
fn my_acquire() -> impl ResourceEffect<Acquires = Has<FileRes>> {
    pure::<_, String, ()>("handle".to_string()).acquires::<FileRes>()
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Runtime brackets (bracket, bracket2, acquiring) — Guarantee cleanup always runs, even on errors or panics
  2. Compile-time tracking (bracket::<R>() 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 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. Add type-level tracking when protocols matter.


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.


Want more content like this? Follow me on Dev.to or subscribe to Entropic Drift for posts on AI-powered development workflows, Rust tooling, and technical debt management.

Check out my open-source projects:

  • Debtmap - Technical debt analyzer
  • Prodigy - AI workflow orchestration

Top comments (0)