Effects Get the Full Return

· 8 min read

Three days ago, Effects That Resume You ended with an honest frontier. One of its two pinned reds was this:

Multi-arm resume. Today an effect’s output is one anonymous -> T. The proposed generalization makes the output a tree of named arms, and the handler resumes by selecting one. (400_114 — PROPOSED, currently red.)

That test is green now. This post is the payoff — and a correction to its own framing, which is half the fun of building a language in public.

The correction first

The old pin called this “effects having effects.” That framing is wrong, and the feature itself proves it: nesting a ! under a ! is a compile error, pinned as 210_136. There is no effect-inside-effect here, no session tree, no recursion of yields.

What actually happened is simpler and, we think, better. Koru events have always had a rich return side: an event finishes into a union of named continuation branches, each carrying a typed payload, each tracked by the obligation system. Effects — the ! arms that fire mid-flight — had a rich yield side but a thin return: one anonymous -> T, or nothing.

Now the resume side of an effect has everything the return side of an event has:

  • a typed return value — that was -> T, already shipped;
  • named continuation branches — the new thing, a flat sum of arms;
  • obligation tracking — per arm, same directionality rules as before.

Symmetry, not nesting.

The shape

You attach | arms to a ! — indented exactly one level under it:

~pub event request { payload: i32 }
! ask i32
    | halved i32
    | timeout
| done i32
— `210_092_effect_multi_arm_decl_indented`

or riding the ! line, when the sum is short:

~pub event request { payload: i32 }
! ask i32 | halved i32 | timeout
| done i32
— `210_093_effect_multi_arm_decl_single_line`

Read the first one as: ask yields an i32 out to the handler, and the handler resumes by answering with one of halved i32 or timeout. The | done i32 at base indent is unchanged — that’s the event’s terminal branch, not part of ask’s sum. Indentation is the discriminator, and it only ever goes one level deep: the sum is flat by construction, because ! never nests (210_136).

Both spellings collapse to the same AST. That isn’t a style note — the two pins’ expected snapshots have byte-identical branch structures, so “same AST” is machine-checked, not asserted in a comment.

One level, both directions: arms don’t get a second level of indent (210_134), and an effect can’t declare both -> T and arms — the arms are the resume (210_135).

Selecting an arm

The handler resumes by constructing the arm it chooses, with =>:

~request(payload: 20)
! ask n => halved @divTrunc(n, 2)
| done r |> std/io:print.ln("{{ r:d }}")
— `400_114_effect_multi_arm_output` → prints `10`

This completes the glyph triangle from the parent post, and it’s now enforced symmetrically in both directions:

  • -> produces the single anonymous resume value. On an effect that declares arms, it’s a compile error — there is no anonymous value to produce (400_125, KORU102).
  • => constructs a named arm. On an effect declared -> T, it’s a compile error — there are no arms to construct (400_122, KORU102).
  • Naming an arm that isn’t in the sum is a compile error that lists the legal arms (400_126, KORU021).

Without those three walls, every one of these mistakes would sail through to a raw Zig error in the generated code — or worse, silently emit something that looks right. The declaration decides which glyph is legal, and the compiler holds the line at the koru level.

The proc just gets a tagged union

The open design question in the parent post was how the proc consumes a sum resume. The answer turned out to be: the same way it binds a scalar today.

~proc request|zig {
    const r = ask(payload);
    return switch (r) {
        .halved => |v| .{ .done = v },
        .timeout => .{ .done = -1 },
    };
}
— from `400_114`; `r` is the synthesized resume union: `halved: i32` or `timeout`

const r = ask(payload) binds the union; an ordinary Zig switch dispatches it. No new consumption mechanism, no boxing, no callback plumbing — the compiler synthesizes the union type once, in the handler’s signature, and type inference carries it into the proc. The “undesigned frontier” dissolved into the existing calling convention.

Nothing is spelled ()

While building this we asked: should a payload-less effect with arms be spelled ! ask ()? The answer the grammar already knew: no. Emptiness in Koru declarations is spelled by omission, everywhere — ! pong is a void effect, | timeout is a void arm, | done is a void terminal. So the payload-less sum is just:

~pub event request { payload: i32 }
! ask | halved i32 | timeout
| done i32
— `400_128_effect_payloadless_multi_arm` → the handler is `! ask => halved 7`, nothing to bind

The unit exists only on the value side: the proc calls ask() positionally. And because every ML or Rust hand will reach for () at least once, writing it is a guided parse error — “an empty payload is spelled by omission” — instead of a misleading type error three stages later (210_137).

Arms carry records

An arm’s payload isn’t limited to a scalar:

! ask i32 | result { halved: i32, quadrupled: i64 } | timeout
— `400_129_effect_record_payload_arm`

The synthesized union field becomes an inline struct (result: struct { halved: i32, quadrupled: i64 }), the handler constructs it with the ordinary field form —

! ask n => result { halved: @divTrunc(n, 2), quadrupled: @as(i64, n) * 4 }

— and the proc reads it like any other tagged union: switch, then field access. 400_129 runs this end to end.

Obligations ride each arm

The parent post’s proudest rule was signature-level obligation checking on the resume: a ! arm fires 0-to-N times, so a resume may discharge an obligation but may never issue one — issuing per firing would leak by construction, and it’s rejected without reading a single line of the body.

That rule now applies per arm, because each arm is a resume:

! lend usize
    | granted *Resource<active!>     // issue — forbidden
    | denied
— `400_124_effect_arm_cannot_issue_obligation` (MUST_FAIL) → `KORU027: resume arm 'granted' cannot issue an obligation`

while the discharge direction stays legal and emits clean host code — the phantom is stripped from the synthesized union, granted: *Resource, exactly as the single-resume -> *R<!active> behaves (400_127, the arm sibling of 400_106).

Where it stops now

Same discipline as the parent post: the frontier is named, not hidden.

  • Flow-level accounting for arm discharges. 400_127 proves the emit is correct; the phantom checker honoring an arm’s discharge in the flow’s obligation ledger is the remaining depth.
  • Subflow-implemented effects (400_115) — landed the very next day, along with eight sibling pins: effect firings checked at the flow level instead of trusted to opaque host code, with zero new grammar. See Firing Is a Call.

The tests are the spec. If this post and the compiler ever disagree, the compiler wins and this post is the bug.