Effect Branches Have Return Values

· 7 min read

An effect branch is a typed channel between a producer and a consumer where values travel in both directions. The producer suspends, hands a value out, gets a value back, resumes. That’s the whole primitive — and the part that matters most is gets a value back. Most languages don’t give you a return path; the effect-branch declaration makes one structural.

The shape

Here’s an effect branch in its smallest interesting form:

~pub event prompt_user { question: []const u8 }
! ask []const u8 -> []const u8
| done []const u8

Three lines:

  • ~pub event prompt_user { question: []const u8 } — the event takes one input field, question.
  • ! ask []const u8 -> []const u8 — the effect: it carries a []const u8 OUT (the thing being asked) and returns a []const u8 BACK (the answer). The arrow is the type-level commitment to the second channel.
  • | done []const u8 — the terminal: when the producer finishes, this is what the consumer’s flow gets.

The producer is a Zig proc, but it uses the effect as if it were a normal function call:

~proc prompt_user|zig {
    const reply = ask(question);
    std.debug.print("Hello, {s}!\n", .{reply});
    return .{ .done = reply };
}

ask(question) suspends the producer, hands question to the consumer’s handler, and the value the handler returns becomes reply. From inside the proc body it reads like a function call, and that’s basically what it is — except the body of the function is supplied by the caller, comptime, per call site.

The consumer side:

~prompt_user(question: "What's your name?")
! ask _ |> "Alice"
| done r |> std.io:print.ln(r)

! ask _ |> "Alice" is the handler. The body is a single expression — "Alice" — and that expression IS the return value. No driver loop. No state. No closure capture. The expression is the response.

Run it and you get:

What's your name?
Hello, Alice!
Alice

The producer printed the question. The consumer’s expression supplied “Alice”. The producer received “Alice” and used it. The terminal handler printed “Alice” once more. The conversation ran in both directions and the source code reads in the order you’d narrate it.

What the second channel changes

A one-way yield only lets you stream values out. The patterns it enables are stream-shaped: iterate, map, filter, collect. Useful, bounded.

A two-way channel lets the consumer participate in the producer’s computation. The patterns multiply:

Consumer-provided fold logic

The producer iterates; the consumer’s handler IS the combine function. The accumulator threads through the resume value:

~pub event sum_range { n: u64 }
! v { item: u64, acc: u64 } -> u64
| done u64

~proc sum_range|zig {
    var acc: u64 = 0;
    for (0..n) |i| {
        acc = v(.{ .item = i, .acc = acc });
    }
    return .{ .done = acc };
}

~sum_range(n: 1000)
! v p |> p.acc + p.item
| done r |> std.io:print.ln("sum = {{ r:d }}")

The producer doesn’t know what “combine” means. It threads. The consumer’s p.acc + p.item is the combine. Swap it for if (p.item > p.acc) p.item else p.acc and now it’s a max. The producer is reusable across reducers; the consumer composes its own logic.

Cooperative resumption without function coloring

A producer can fire ! await_ready -> Status and the consumer’s handler answers either “ready, here’s the value” or “pending, here’s a wake token.” The producer doesn’t care which — it just resumes on whatever the handler returns. The producer body never had to be marked async. The same primitive that gives you generators gives you async/await; the second channel is what makes the pause-and-resume bidirectional enough to model both.

Resumable exceptions

An effect like ! divide_by_zero -> u64 lets the consumer choose whether to provide a substitute value (resume the producer with a fallback) or simply not return (abandon execution at the call site). One-way generators can’t express this — there’s no return path for the substitute. Exception handling becomes a special case of the general primitive.

Interactive interpreters

An interpreter (producer) fires ! eval Expr -> Value for each expression it encounters. The host (consumer) evaluates and answers. The interpreter loop has no knowledge of how evaluation works; it just asks. Try expressing this with one-way iterators and you end up flattening the entire interaction into a tagged event stream that the host has to switch on. With effect branches, it reads like a function call.

Demand-driven pull

A producer fires ! next_chunk u32 -> ChunkOrEof. The consumer sees the requested size as the payload, decides what to deliver, and returns either a chunk or EOF. One-way iterators commit to a chunk size at construction; the second channel lets pressure flow back.

Why this stays fast

The consumer’s handler is a comptime-known struct method, passed to the producer as a type parameter. From the compiler’s view, the producer’s ask(question) is a direct call into a fully-visible function — not a virtual dispatch, not a heap object, not a state machine that opens a black box. The optimizer sees the producer, the handler, and the consumer’s flow as one continuous expression.

Concretely, that means:

  • Zero allocation per call. No generator object, no closure box, no enumerator instance. The handler is part of the producer’s stack frame.
  • No vtable, no dynamic dispatch. The handler is comptime-resolved.
  • Full inlining and fusion. When the consumer’s handler is a simple expression like p.acc + p.item, LLVM inlines it into the producer’s loop and treats the whole thing as a fused operation. Loops can vectorize. Whole iterations can sometimes collapse to closed-form constants when the dataflow is legible enough.

The ergonomic surface — handler is an expression, producer is a normal proc body — and the performance surface — comptime-specialized, fully visible to the optimizer — fall out of the same design choice. They aren’t traded off.

The commitment is in the type

The arrow in the declaration — ! ask T -> U — is where the second channel becomes structural. It’s not a runtime convention or a library wrapper or an opt-in API on top of a one-way base. The type signature of the effect commits the language to running values in both directions.

Generators have outward yield. Effect branches have outward yield and inward reply. That’s the difference, in one shape, in the type. The conversation goes both ways because the type says so.