Event Continuations: Typed Control Flow and Structural Resource Safety
L. Thomas Denstad and Claude (Anthropic)
January 2026
Notes
This is a draft, it contains some errors and omissions. It is not ready for public consumption at large. The errors are mostly cosmetic, but some of the examples are inaccurate. The main contribution is the idea of event continuations and phantom obligations. For 100% correct, running examples, see the Koru compiler website where this paper is currently hosted.
Abstract
Modern programming languages have embraced the principle that types make illegal states unrepresentable. This principle has driven decades of progress in static verification. However, we observe that most languages leave a critical dimension untyped: control flow. While types constrain what values can exist, they do not constrain what can happen—which paths through a program are legal, which temporal transitions are valid, which continuations may be invoked.
We present event continuations, a language mechanism that makes control flow explicit and typed. In a language with event continuations, every branch point declares its possible outcomes, and every invocation site must handle all outcomes exhaustively. Illegal control flow paths become structurally unrepresentable.
We then show that resource safety emerges as a consequence. When control flow is explicit, the compiler knows all legal paths and all legal termination points. Resource lifetimes become properties of the flow graph, not conventions layered on top of it. We introduce phantom obligations—a compile-time mechanism where resource acquisition creates obligations that must be discharged along all paths. Unlike traditional RAII, phantom obligations are semantic: the compiler knows not just that a resource must be released, but how (commit vs. rollback, flush vs. discard).
We have implemented these ideas in Koru, a compiled language targeting Zig as its intermediate representation. The compiler comprises approximately 49,000 lines of Zig, with a test suite of 420 regression tests. Benchmarks demonstrate that the abstractions are zero-cost: Koru matches hand-optimized Zig within measurement noise, while providing static guarantees that are impossible in most systems languages.
1. Introduction
There is a well-known mantra in programming language design:
Types make illegal states unrepresentable.
This principle has proven remarkably productive. From ML’s algebraic data types to Rust’s ownership system, the insight that the compiler should reject programs that could enter invalid states has driven decades of progress in static verification.
But state is only half the story.
Real programs are not just about what exists, but about what happens next. Order matters. Resumption points matter. “What is allowed to happen now?” matters just as much as “what values exist now?”
Yet in most languages, control flow is implicit. We rely on conventions, discipline, comments, and tests to ensure that:
- Events arrive in the right order
- Callbacks resume in the right context
- Required steps aren’t skipped
- Handling doesn’t happen twice
- “Impossible” paths remain impossible
When these invariants break, the bugs are familiar: race conditions, invalid re-entry, double-handling, out-of-order effects. These are not state bugs. They are flow bugs. And they exist because illegal paths are representable.
This paper introduces event continuations: typed suspension points where control flow branches, each branch must be handled, and the compiler enforces that every path through the program is explicitly accounted for. They are not callbacks, not promises, not algebraic effects—they are a new primitive that makes control flow as explicit and checkable as data types.
1.1 Contributions
This paper makes the following contributions:
We identify flow as a missing dimension in the type-theoretic treatment of program correctness. While types constrain state, control flow remains largely untyped in mainstream languages.
We present event continuations, a language mechanism that makes control flow explicit and typed. Every branch point declares its outcomes; every invocation must handle all outcomes exhaustively.
We show that resource safety emerges structurally from explicit flow. When the compiler knows all legal paths, resource lifetimes become derivable properties rather than manual obligations.
We introduce phantom obligations, a zero-cost compile-time mechanism for semantic resource management. Unlike RAII, phantom obligations distinguish between disposal modes (commit/rollback, flush/discard).
We provide evidence through a working compiler (49,000 lines of Zig), a comprehensive test suite (420 tests), and benchmarks demonstrating zero runtime overhead.
1.2 The Core Insight
Our central observation can be stated simply:
Types constrain what can exist. Event continuations constrain what can happen.
Or equivalently:
Legal logic is downstream from legal state and legal flow.
When both dimensions are typed, entire classes of bugs disappear—not because programmers became more careful, but because the compiler refuses to express invalid programs.
2. Motivation: The Implicit Control Flow Problem
Consider a common pattern: fetching data that might fail.
2.1 The Go Approach
data, err := fetch(url)
if err != nil {
// Hope the developer remembers this
}
process(data) // Runs even if err != nil This code represents an illegal path. The programmer can write process(data) even when err != nil—the language does nothing to prevent it. The path exists in the program text; only discipline prevents its execution.
2.2 The Rust Approach
let data = fetch(url)?;
process(data); Rust’s ? operator is better: it forces acknowledgment of the error case. But the improvement is partial. The Result type ensures you cannot ignore the possibility of failure, but it does not ensure you handle it correctly. You can still:
let data = fetch(url).unwrap(); // Illegal path via panic
process(data); The path to process(data) with invalid data is representable—it just panics at runtime instead of silently proceeding.
2.3 What We Actually Want
What we want is a language where the illegal path does not exist:
~fetch(url: "https://api.example.com")
| ok data |> process(data)
| error msg |> log(msg) Here, fetch declares two possible outcomes: ok and error. The shape checker verifies that both branches have continuations. If you omit the error branch:
// Compile error: missing required branch 'error'
~fetch(url: "https://api.example.com")
| ok data |> process(data) The illegal path (ignoring errors) is unrepresentable. Not discouraged, not caught at runtime—unrepresentable at the syntactic level.
3. Event Continuations
An event continuation is a typed control flow construct with the following properties:
- An event declares a shape: the set of possible outcomes
- Invoking an event requires handling all branches of its shape
- Each branch binds values and specifies a continuation
- The continuation may itself invoke events, creating a flow graph
3.1 Syntax
Events are declared with the ~event keyword:
~event fetch { url: []const u8 }
| ok []const u8
| error []const u8 This declares an event fetch that:
- Takes a
urlparameter - Has two branches:
ok,error, both carrying data
~fetch(url: "https://example.com")
| ok data |> transform(data)
| error msg |> fallback(msg) 3.2 Exhaustiveness
The shape checker enforces that every branch declared in the event’s shape has a corresponding handler. This is analogous to exhaustive pattern matching on algebraic data types, but applied to control flow rather than data.
// All branches handled
~fetch(url: endpoint)
| ok data |> process(data)
| error msg |> log(msg)
// Compile error: missing branch 'error'
~fetch(url: endpoint)
| ok data |> process(data) 3.3 Flow Composition
Continuations can themselves contain event invocations, creating structured flow graphs:
~fetch(url: endpoint)
| ok data |>
parse(data)
| valid record |> store(record)
| invalid reason |> log(reason)
| error msg |> retry(msg) The compiler verifies exhaustiveness at every level. There is no path through this code that doesn’t handle all possibilities.
3.4 The Key Distinction
Traditional control flow is implicit: the language provides primitives (if, match, try) and trusts the programmer to use them correctly.
Event continuations make control flow explicit: the possible paths are declared in the event’s shape, and the compiler enforces that all paths are handled.
This is the same shift that static typing brought to data: instead of trusting programmers to use values correctly, we declare what values are possible and let the compiler enforce consistency.
4. Phantom Obligations
Event continuations stand on their own as a mechanism for typed control flow. You can use them without obligations, and many programs do. But once control flow is explicit, something remarkable becomes possible: resource safety can be derived from the flow graph.
Phantom obligations were introduced to Koru after event continuations had been in use for weeks. They emerged from a practical observation: if the compiler knows every path through a program, it can also verify that certain actions (closing files, committing transactions) happen on every path. Obligations are a refinement layer—powerful, but optional.
4.1 The Problem with RAII
Resource Acquisition Is Initialization (RAII) ties resource lifetime to scope. When a variable goes out of scope, its destructor runs. This works well for simple cases but has limitations:
One cleanup path: RAII doesn’t distinguish how to clean up. A transaction might need to commit or rollback—both are “cleanup,” but they’re semantically different.
Scope doesn’t match lifetime: Sometimes a resource should outlive its lexical scope, or should be released before scope exit.
Manual threading: The programmer must ensure the resource reaches its disposal point along all paths.
4.2 Obligations as Flow Properties
In a language with explicit flow, we can do better. Consider this event declaration:
~event open { path: []const u8 }
| opened *File[opened!]
~event close { file: *File[!opened] }
| closed The notation [opened!] means: this branch produces an obligation. The notation [!opened] means: this event consumes (discharges) an obligation.
When you invoke open, you receive a file handle with an obligation attached. The compiler tracks this obligation through all control flow paths. If any path reaches a termination point without discharging the obligation, compilation fails:
~open(path: "config.txt")
| opened file |> process(file)
// ERROR: Flow ends with obligation [opened!] not discharged
// The file was opened but never closed. This is not a warning. This is not a runtime check. The program does not compile. The compiler has traced every path from open to termination and found one where close is never called.
The correct code:
~open(path: "config.txt")
| opened file |>
read(file)
| data content |>
close(file)
| closed |> process(content) 4.3 Semantic Disposal
Phantom obligations are semantic—they know not just that disposal is required, but what kind of disposal. Consider database transactions:
~event connect { url: []const u8 }
| connection { conn: *Connection[connected!] }
~event begin { conn: *Connection[connected] }
| transaction { tx: *Transaction[in_transaction!] }
~event commit { tx: *Transaction[!in_transaction] }
~event rollback { tx: *Transaction[!in_transaction] }
~event disconnect { conn: *Connection[!connected] } Here, connect produces the connected! obligation, which disconnect consumes. Separately, begin produces in_transaction!, which either commit or rollback consumes. These are independent obligations—both must be discharged:
~connect(url: "postgres://localhost/test")
| connection c |>
begin(conn: c.conn)
| transaction tx |>
commit(tx: tx.tx)
|> disconnect(conn: c.conn) // Original binding still accessible! Note that c.conn is still accessible after begin—Koru bindings persist through continuations, unlike Rust moves. The connected! obligation tracks that disconnect must eventually be called, regardless of how many other events use the connection in between.
However, once a binding is passed to a disposal event (one with [!state]), it becomes poisoned. Use-after-dispose is a compile error:
~connect(url: "postgres://localhost/test")
| connection c |>
disconnect(conn: c.conn)
|> query(conn: c.conn) // ERROR: c.conn was disposed! Bindings persist until disposal, then they’re gone. No dangling references, no use-after-free—enforced at compile time.
4.4 Auto-Dispose
When there is exactly one way to discharge an obligation, the compiler can synthesize the disposal automatically:
// File only has one disposal path: close
// Compiler inserts close() at scope boundaries automatically
~open(path: "config.txt")
| opened file |>
read(file)
| data content |> process(content)
// close(file) inserted here But when there are multiple disposal paths (commit/rollback), the programmer must choose explicitly. The compiler doesn’t guess at semantics.
4.5 Scope Awareness
The obligation system is scope-aware. Consider:
~open(path: "data.txt")
| opened file |>
for_each(items)
| item x |> write(file, x)
| done |> close(file) The compiler knows that file was opened outside the loop and must not be closed inside the loop. The obligation tracks where it was created, not just that it exists.
4.6 Obligation Escape
Obligations are not limited to local scopes—they can escape through return signatures, enabling whole-program tracking.
Consider a subflow that opens a file and returns it to the caller:
// Subflow that opens a file and returns it with obligation
~pub event my_subflow {}
| file_opened { file: *File[opened!] } // Obligation in signature!
~my_subflow = open(path: "data.txt")
| opened f |> file_opened { file: f.file } // Pass obligation to caller The [opened!] in the return signature declares that my_subflow produces an obligation it does not discharge. The caller inherits the obligation:
~my_subflow()
| file_opened f |> close(file: f.file) // Caller must discharge
| closed |> _ If the caller fails to discharge the obligation, compilation fails—even though the obligation originated in a different event. This enables:
- Factory patterns: Functions that create resources and return them
- Dependency injection: Passing open connections/handles to callees
- Resource pooling: Acquiring from a pool, returning later
The obligation graph spans the entire program. Every path through every call site must eventually discharge every obligation.
4.7 The Key Insight
Traditional typestate systems (Rust, ATS) encode state transitions in the type of the value. You carry a File<Open> and must transform it into a File<Closed>.
In Koru, the state machine lives in the flow graph. A value enters an event continuation in one state and emerges in another—not by calling a function that transforms it, but by traversing a legal path.
In Rust, typestate is carried by values. In Koru, state transitions are carried by time.
This is a fundamental difference. Rust typestate asks: “What is this thing?” Koru flow asks: “What just happened?”
5. Zero-Cost Abstraction
Event continuations and phantom obligations are purely compile-time concepts. They guide analysis, enforce correctness, and then vanish. The generated code is direct, linear execution.
5.1 Compilation Model
Koru compiles to Zig, which compiles to machine code via LLVM. The compilation process:
- Parse Koru source into an AST
- Shape checking: verify exhaustive branch handling
- Obligation analysis: verify all obligations discharged
- Flow checking: verify control flow validity
- Code generation: emit Zig source
- Zig compilation: produce native binary
Steps 2-4 exist only at compile time. No runtime data structures track obligations. No dispatch tables route continuations. The flow graph that guarantees correctness does not exist at runtime—only its consequence does: correct code that runs at full speed.
5.2 Benchmark Evidence
To validate the zero-cost claim, we compared Koru against hand-written Zig and idiomatic Haskell on a capture/fold benchmark.
| Implementation | Time (ms) | Relative |
|---|---|---|
| Koru | 17.0 | 1.00x |
| Hand-written Zig | 17.3 | 1.02x |
| Haskell (foldl’) | 74.1 | 4.36x |
Koru matches hand-optimized Zig within measurement noise. The safety guarantees cost nothing at runtime.
5.3 What Zero-Cost Means
“Zero-cost abstraction” is often claimed but rarely achieved. Here, we can be precise about what we mean:
- No runtime obligation tracking: Obligations exist only at compile time
- No continuation dispatch: Flow is compiled to direct jumps and calls
- No wrapper objects: Phantom types are erased completely
- Identical generated code: The Zig emitted for a Koru program is what a skilled Zig programmer would write by hand
The abstraction is zero-cost not because the compiler is clever about optimizing it away, but because there is nothing to optimize away. The safety mechanism exists in a different phase than execution.
6. Implementation
We have implemented event continuations and phantom obligations in the Koru compiler.
6.1 Compiler Statistics
- Source language: Zig
- Lines of code: ~49,000
- Test suite: 420 regression tests, 276 currently passing
- Architecture: Deeply metacircular
The compiler achieves metacircularity at two levels:
- The backend compiler is written in Koru. The compilation pipeline—parsing, shape checking, obligation analysis, code generation—is defined as Koru event flows in
compiler.kz. The coordinator chains passes like any other flow:
~compiler.coordinate.default =
compiler.context.create(program_ast: program_ast, allocator: allocator)
| created c0 |> compiler.coordinate.frontend(ctx: c0.ctx)
| continued c1 |> compiler.coordinate.analysis(ctx: c1.ctx)
| continued c2 |> compiler.coordinate.emission(ctx: c2.ctx)
| continued c3 |> ... - Language keywords are user-space libraries. Control flow constructs like
if,for, andcaptureare not built into the compiler—they are defined incontrol.kzas compile-time AST transforms:
~[keyword|comptime|transform]pub event if {
expr: Expression,
invocation: *const Invocation,
program: *const Program,
allocator: std.mem.Allocator
}
| transformed { program: *const Program } The [keyword] annotation makes if available as a keyword. The [comptime|transform] annotations mark it as a compile-time AST transform. The implementation is a Koru proc that rewrites ~if(cond) | then |> ... | else |> ... into a ConditionalNode in the AST.
This means language semantics are not special-cased in the compiler—they emerge from the same event continuation mechanism used by user code. The same infrastructure that enables typed control flow in applications enables the definition of control flow itself.
6.2 Key Components
The compiler includes dedicated passes for flow analysis:
shape_checker.zig: Verifies exhaustive branch handlingphantom_semantic_checker.zig: Tracks and verifies obligation dischargeauto_dispose_inserter.zig: Synthesizes disposal calls when unambiguousflow_checker.zig: Validates control flow graph structurecontinuation_codegen.zig: Emits code for event continuations
6.3 Test Categories
The test suite covers:
- Basic syntax and parsing
- Event and flow semantics
- Phantom type obligations
- Flow checking and validation
- Negative tests (verifying rejection of invalid programs)
- Performance benchmarks
- Integration with Zig ecosystem
6.4 Status
Koru is a working system, not a research prototype:
- Compiler: 49,000 lines of Zig, actively developed
- Test suite: 420 regression tests covering core language features
- Self-hosting: The compiler backend is written in Koru; keywords like
ifandforare user-space libraries - Target: Emits Zig source, which compiles to native code via LLVM
- Benchmarks: Matches hand-written Zig within measurement noise
The language has been used to build its own compiler infrastructure, including the shape checker, obligation analyzer, and code generator. This is not a toy implementation—it is the system we use daily.
7. Related Work
7.1 Typestate
Typestate systems track abstract state in types. Strom and Yemini introduced typestate for tracking initialization [1]. Rust’s ownership system is a form of typestate where the state is “owned,” “borrowed,” or “moved.”
Koru differs in where the state machine lives. In typestate, the programmer explicitly transforms values from one state to another. In Koru, state transitions occur by traversing the flow graph—the state machine is implicit in the shape of the code.
7.2 Session Types
Session types [2] describe communication protocols as types. A channel with type !Int.?Bool.end can send an integer, then receive a boolean, then terminate.
Event continuations share the concern with sequencing but differ in mechanism. Session types describe protocols between parties; event continuations describe local control flow. Session types are typically for concurrent/distributed systems; event continuations apply to sequential code.
7.3 Effect Systems
Effect systems (Koka [3], Eff [4]) track what effects a computation may perform. A function with type () -> <io, exn> Int may perform IO and may throw exceptions.
Effects describe what might happen. Event continuations describe what did happen and what happens next. They are complementary: one could imagine an effect system layered on top of event continuations.
7.4 Linear Types
Linear types [5] ensure values are used exactly once. This enables static tracking of resource usage.
Phantom obligations are related but distinct. A linear value must be consumed; an obligation must be discharged. The obligation tracks not the value itself but an abstract state created when the value was produced. This allows the same value to be used multiple times while still tracking that it must eventually be closed/committed/released.
7.5 Algebraic Effects and Handlers
Algebraic effects [6] model control flow as first-class values that can be handled by enclosing handlers.
Event continuations share the idea of explicit control flow but are more restrictive by design. Algebraic effects are maximally flexible—any handler can intercept any effect. Event continuations are deliberately constrained—the shape is fixed at declaration, not at handling site. This restriction enables the exhaustiveness checking that provides our safety guarantees.
7.6 Categorical Interpretation (Optional)
This section offers a theoretical lens for readers familiar with functional programming. It is not required to understand or use event continuations.
Event continuations can be understood as a zero-cost realization of free reader monads.
A free monad represents computation as data—an AST that can be inspected and interpreted. A reader monad threads an environment through a computation. Event continuations combine both: the flow structure is explicit and manipulable (free), and each continuation “reads from” the flow context—what branches are available, what obligations are active (reader).
The key difference is when this structure exists:
- In Haskell, free monads are runtime data structures with allocation and interpretation overhead
- In Koru, the free structure exists only at compile time
The “interpretation” of the free structure is code generation itself. By the time the program runs, there is no monad, no structure, no interpretation overhead—only the direct code that the structure described.
Practically, this is just compile-time flow graphs with zero runtime cost. The structure is not manipulated dynamically; composition is resolved statically through control-flow construction.
This positions event continuations as bringing the expressive power of free monads to systems programming, where runtime overhead is unacceptable.
8. Design Boundaries
One deliberate non-goal:
- First-class continuations: Event continuations cannot be captured, stored, or passed as values. This is deliberate—Koru values explicit control flow. If you need parameterized behavior, use code generation. Passing continuations around is antithetical to the design principle that all paths should be visible and analyzable at compile time.
9. Conclusion
We have presented event continuations, a language mechanism that makes control flow explicit and typed. Where types constrain what values can exist, event continuations constrain what can happen.
We showed that resource safety emerges as a structural consequence of explicit flow. When the compiler knows all legal paths, it can verify that obligations are discharged along every path. This is not a separate mechanism bolted onto the type system—it falls out of taking flow seriously as a first-class concept.
We implemented these ideas in Koru, a compiled language with a 49,000-line compiler and 420-test regression suite. Benchmarks confirm that the abstractions are truly zero-cost: Koru matches hand-optimized Zig while providing guarantees impossible in most systems languages.
The deeper lesson is methodological. For decades, we have asked programmers to maintain flow invariants manually—ensuring events arrive in order, resources are released, paths are valid. We stopped asking them to maintain type invariants manually; we should stop asking them to maintain flow invariants too.
Stop asking humans to be compilers—for control flow.
References
[1] R. E. Strom and S. Yemini. “Typestate: A Programming Language Concept for Enhancing Software Reliability.” IEEE TSE, 1986.
[2] K. Honda. “Types for Dyadic Interaction.” CONCUR, 1993.
[3] D. Leijen. “Koka: Programming with Row Polymorphic Effect Types.” MSFP, 2014.
[4] A. Bauer and M. Pretnar. “Programming with Algebraic Effects and Handlers.” JLAMP, 2015.
[5] P. Wadler. “Linear Types Can Change the World!” IFIP TC, 1990.
[6] G. Plotkin and M. Pretnar. “Handlers of Algebraic Effects.” ESOP, 2009.
Acknowledgments
We thank OpenAI’s ChatGPT (GPT-5.2) for editorial feedback on structure and clarity during the drafting of this paper.
Implementation available at: https://korulang.org
Compiler source: https://github.com/korulang/koru