Scala 3 Capture Checking, Explained Without the Type Theory

Run Scala in production long enough and your team has probably shipped some version of the same bug. A database connection, a file handle, or a request context gets used after the code that owned it has already closed it, and the failure only shows up at runtime, often far from the line that caused it.

Scala 3 capture checking is the compiler feature built to catch that bug before the code ever runs. The research papers behind it are dense, but the part a working engineer needs fits in a few short sections, and you can decide whether it matters to your team without reading any type theory.

Below, we cover what capture checking does, the bug it prevents, the three ideas that make it work, the business case, what it costs a team today, and when it fits a codebase and when it does not.

TL;DR

Capture checking is an experimental Scala 3 compiler feature that tracks which outside values a function or object holds onto, and rejects code that lets a tracked value, like an open connection, outlive the scope that owns it. You turn it on per file with one import, and code that never uses tracked values compiles the same way it always has. As of the Scala 3.9 LTS release it is still experimental, and because it is opt-in per file, whether it fits depends on what a given part of the codebase does and how much change the team can absorb.

What is Scala 3 Capture Checking?

Scala 3 capture checking tracks which values a piece of code holds onto from outside itself. A closure that uses a connection defined elsewhere "captures" that connection, and an object that stores the connection in a field captures it too. Capturing is normal and usually harmless, since it is how closures and partial application work.

Capture checking only cares about values you mark as tracked. For those values, the compiler records everything that captures them and refuses to compile code where a tracked value could escape the scope it was meant to stay in. The rest of your program works the way it always has, which makes capture checking a natural extension of the type system features Scala teams already rely on.

The Bug That Capture Checking Is Built to Catch

The classic case is a helper that opens a resource, hands it to your code, and closes it afterward. The pattern is safe as long as your code finishes with the resource before the helper closes it.

scala
import language.experimental.captureChecking
 
class Connection:
  def users(): Iterator[User]^{this} = ???  // reads rows lazily
  def close(): Unit = ()
 
def withConnection[T](work: Connection^ => T): T =
  val conn = Connection()
  try work(conn)
  finally conn.close()
 
// Compiles: the rows are read into a List before the connection closes
val active = withConnection(conn => conn.users().filter(_.active).toList)
 
// Rejected under capture checking: the iterator still reads
// from conn after withConnection closes it
val pending = withConnection(conn => conn.users().filter(_.active))

Without capture checking, the last line compiles. It returns a lazy iterator that still reads from the connection, and the first time anyone loops over it, it reads from a connection that is already closed. With capture checking turned on, the compiler rejects the line, because the returned iterator captures a tracked value that cannot leave withConnection.

Resources are the textbook example, but the same rule covers any value that should not outlive a scope. A credential that should only exist during authentication, a request-scoped context in a web service, and a handle that lets code start new concurrent tasks all fit the pattern. Wrapping a tracked value in another object does not hide it from the compiler either, because the wrapper becomes tracked as well.

How Capture Checking Works in Three Ideas

A Caret, or Hat, Marks a Tracked Value

A caret after a type, as in Connection^, marks a value as tracked. Some Scala developers call the caret a "hat," and the mark tells the compiler to watch where that value goes. Types without the mark stay untracked, so capture checking only touches the parts of a codebase where someone chose to use it. A class can also be declared as a capability, which makes every instance of that class tracked automatically.

Function Types Say What They Capture

With capture checking enabled, Scala has three ways to write a function type, and each one makes a different promise:

  • A -> B is a pure function that captures nothing.
  • A ->{conn} B is a function that may capture the tracked value conn and no other tracked value.
  • A => B is a function that may capture anything, which is the type existing Scala code already uses.
scala
val conn: Connection^ = openConnection()
 
val double: Int -> Int = x => x * 2
val userCount: () ->{conn} Int = () => conn.users().size
val anyCapture: () => Int = userCount
 
// Rejected: claims to be pure but captures conn
val sneaky: () -> Int = () => conn.users().size

Keeping => as the most permissive function type is the design choice that keeps migration cheap. Higher-order functions like map already take => functions, so they accept both pure and capturing functions without anyone rewriting them.

Tracked Values Cannot Leave Their Scope

When a value tracked inside a scope shows up in the type of something returned from that scope, the compiler has no valid way to describe that type on the outside, so it reports an error. That check is what rejected the escaping iterator in the first example.

The Business Case for Scala 3 Capture Checking

The business value of Scala 3 capture checking comes from moving a class of failures out of production and into the build. Use-after-close bugs and leaked scopes tend to appear as intermittent errors on rare code paths, and tracing one back to the line that caused it can take days. A compile error that names the escaping value takes minutes to fix.

  • Fewer production incidents from resource misuse. Errors that would surface as failed requests or crashed jobs show up as build failures instead, before any customer sees them.
  • Lighter code review. Reviewers no longer have to trace by hand whether a connection, credential, or scope can outlive the code that owns it, because the compiler checks that on every build.
  • Stronger handling of sensitive values. A credential or session token passed in as a tracked value cannot be stored or returned past the scope meant to use it, a guarantee that otherwise rests on careful review.
  • Safer AI automation. Capture checking gives companies a compiler-enforced way to limit what agent-generated code can reach, which matters more as agents write and run more production code.

None of these benefits requires a large migration. The value concentrates in the small parts of a system that manage resources, credentials, and permissions, which are also the parts where a single mistake is often the most expensive.

What Capture Checking Costs a Team Today

The cost of trying capture checking is low, and five facts shape where it fits today:

  • Capture checking is opt-in per file. You enable it with import language.experimental.captureChecking or a compiler flag, and files without it are not checked.
  • Annotations stay sparse. The compiler infers capture sets, so annotations mostly appear at the boundaries where resources get handed out.
  • The standard library is already annotated. Since Scala 3.8, released in January 2026, the standard library carries capture annotations that only take effect when you enable the feature.
  • The feature is still experimental. Scala 3.9, the long-term support release published in September 2026, still lists capture checking as experimental, and names inside the feature have changed during 2026.
  • Rough edges remain. Error messages are long and technical, and open compiler issues still show gaps in what the checker catches.

Separation Checking Builds on Capture Checking

Capture checking tracks who can access a value, and separation checking, a second experimental feature built on top of it, tracks who can change one. Separation checking distinguishes read-only access from write access and rejects code where two parts of a program, such as two tasks running in parallel, could mutate the same value at once.

The goal resembles Rust's borrow checker, with one big difference. Scala keeps its garbage collector, so teams get compile-time rules about mutation and aliasing without managing ownership for every value in the program. Scala already pushes teams toward immutable data, and separation checking aims to make the mutable parts just as safe. Separation checking is earlier in development than capture checking, so it is a direction to watch more than a tool teams can adopt today.

Capture Checking Does Not Replace Cats Effect or ZIO

Capture checking and effect libraries like Cats Effect and ZIO tackle overlapping problems in different ways. Effect libraries describe side effects as values and manage resources through their own types, such as Resource in Cats Effect and Scope in ZIO. Capture checking works at the language level and supports a direct style of programming, where code calls methods normally and the compiler tracks which capabilities each part of the program uses.

The Scala community is openly divided on how far that direct style should shape the language. Supporters like enforcing pure functions in the type system itself, and critics worry about a steeper learning curve and another split in how Scala code gets written. For teams with a working Cats Effect or ZIO stack, capture checking adds less today, while teams starting new direct-style code or building libraries stand to gain the most.

How Scala 3 Safe Mode Uses Capture Checking for AI Agents

The AI agent case may be where capture checking proves its value first. Safe mode, added in Scala 3.8.3, is a capability-safe subset of the language aimed at agent-generated and other untrusted code, and it turns on capture checking and blocks escape hatches like unchecked casts and runtime reflection. That extends the argument from our post on when AI writes good Scala code, where the compiler is the strongest guardrail a team has, and we will cover the agent use case in depth in its own post.

When to Use Scala 3 Capture Checking, and When to Hold Off

Because capture checking is opt-in per file, the decision comes down to which parts of a codebase it fits today. That depends on what the code does and how much change a team can absorb while the feature is still moving.

When Capture Checking Is a Good Fit

  • Your code hands out scoped resources such as connections, transactions, file handles, or request contexts, and use-after-close bugs have already cost you time in production.
  • You maintain a library or internal platform whose APIs hand out resources or scopes, since library authors will feel the change first and their users benefit from every check they add.
  • Your team builds on direct-style Scala and capabilities, where capture checking is what makes capability-based designs safe to rely on.
  • Your team runs AI agents that write or execute Scala code, which is the case safe mode was built for.

When to Hold Off on Capture Checking

  • Your codebase depends on syntax and compiler behavior that stay the same between releases, which an experimental feature cannot promise.
  • Your team has no spare capacity to work through long, technical compiler errors, which still slow down even experienced Scala engineers.
  • Your resource handling leans heavily on inline methods, because an open compiler issue lets inline methods skip the check at the time of writing, which could give a false sense of safety.
  • Your resources already flow through Cats Effect Resource or ZIO Scope, which already prevent many of the same leaks, so the added protection is smaller.

Capture checking turns a whole class of runtime bugs, from resources used after they close to values that leak out of their scope, into compile errors, and that value should grow as the feature stabilizes.

Need senior Scala engineers who can put this to work?

Scala Teams provides senior Scala engineers and complete teams who can move your codebase to the Scala 3.9 LTS, harden the code that handles your connections and credentials, and judge which new compiler features belong in production. Tell us what you are building, and we will walk you through the team that fits it. Talk to a Scala expert.

Frequently Asked Questions

What is capture checking in Scala 3?

Capture checking is an experimental Scala 3 compiler feature that tracks which outside values a function or object holds onto. When a value is marked as tracked, the compiler rejects code that would let it escape its intended scope, such as a database connection used after it has been closed.

Is Scala 3 capture checking stable?

No. Capture checking is still an experimental feature in Scala 3.9, the long-term support release published in September 2026. Its syntax and naming have changed across recent releases, so it fits best in small, contained parts of a codebase where a syntax change is easy to absorb.

How do you enable capture checking in Scala 3?

Add import language.experimental.captureChecking at the top of a file, or pass -language:experimental.captureChecking to the compiler. Only code compiled with the feature enabled is checked, and the capture annotations in the standard library have no effect otherwise.

What is the difference between -> and => under capture checking?

With capture checking enabled, A -> B is a pure function that captures nothing, A ->{c} B is a function that may capture the tracked value c, and A => B is a function that may capture anything. Keeping => as the most permissive type lets existing code compile without changes.

Does capture checking replace Cats Effect or ZIO?

No. Capture checking is a compiler feature that supports a direct style of programming, while Cats Effect and ZIO are libraries that model effects as values. Teams already using Cats Effect or ZIO do not need to change anything because of capture checking.

How is capture checking different from Rust's borrow checker?

Rust's borrow checker governs ownership and borrowing for every value in a program, while Scala's capture checking only tracks the values you mark and leaves memory to the garbage collector. Separation checking, an experimental extension of capture checking, adds Rust-like rules about who can mutate shared data.

Next
Next

When AI Writes Good Scala Code, and When It Doesn't