VIP-17: fork and join
Status: accepted by Will, 2026-10-28.
Decision
const f = fork worker() // yield* Effect.forkChild(worker()) owned by the current fiber
const f = fork.in(scope) worker() // yield* Effect.forkIn(worker(), scope) owned by the given scope
const f = fork.scoped worker() // yield* Effect.forkScoped(worker()) owned by the current Scope (adds Scope to R)
const f = fork.detached worker() // yield* Effect.forkDetach(worker()) no owner
const r = join f // yield* Fiber.join(f)forkstarts a fiber and gives itsFiber. The form names the owner, which is what interrupts the fiber. Each form is exact sugar for Effect's function, used inside aneffectfunction or block.forkkeeps Effect's meaning: a child is interrupted when its parent ends. It is not awaited at the end of a scope, as Swift's and Kotlin's structured children are.- The owner forms use JavaScript's
keyword.nameshape, likenew.targetandimport.meta. - Ambiguity: none.
forkandjoinare contextual keywords whose operand must be on the same line. Bare modifier words were rejected becausefork scoped(x)would have two readings. - Amended 2026-10-29: reserved in effect code.
forkandjoinare reserved words in effect code exactly asawaitis in anasyncfunction body: they can't name a variable, parameter or label there, and where an expression can start they begin aforkorjoinexpression, sojoin(parts)joins. In anIdentifierNameposition (parts.join(","),{ join: 1 }) they stay ordinary names, asawaitdoes. Outside effect code they are identifiers. This replaces the contextual-keyword test above. Decided by Will on factory.vibelang.sh, 2026-10-29 ~14:20–14:25 ("revisit-r5", option 3A accepted). - Amended 2026-10-29: the operand and bindings.
ForkOperandis aUnaryExpression,await's operand. A binding namedforkorjoinoutside effect code gets no extra warning, as a binding namedawaitgets none. Decided by the agent on 2026-10-29 under Will's rule "when I state a principle, the agent derives the concrete rule and acts; I can override later" (decided detail 65). - Amended 2026-10-29:
fork.inevaluatesscopefirst. Operands evaluate in source order:fork.in(scope) eisconst $s = scope; yield* Effect.forkIn(e, $s). Decided by the agent on 2026-10-29 under Will's rule "when I state a principle, the agent derives the concrete rule and acts; I can override later" (decided detail 84). - Amended 2026-10-29:
forkandjoinoperands are checked. Afork,fork.in(s),fork.scopedorfork.detachedoperand must pass the operand test oftry(assignable toEffect.Effect<any, any, any>); otherwiseEK1703. Ajoinoperand must be assignable toFiber.Fiber<any, any>; otherwiseEK1704. Decided by the agent on 2026-10-29 under Will's rule "when I state a principle, the agent derives the concrete rule and acts; I can override later" (decided detail 88). - Amended 2026-10-29:
forkandjoinheads beforecatch.join f catch handfork e catch hareEK0505, astry x catch his (VIP-5); the quick fixes areFiber.join(f) catch handfork Effect.catch(e, ($e) => Effect.succeed(h($e))), orfork Effect.catch(e, h)for aneffecthandler, soestays on the caller and the handler runs on the forked fiber;fork (e catch h)runsehere and forks its result,EK1703unless that result is an Effect. Corrected 2026-10-30: the quick fix wasfork (e catch h), which contradicted decided detail 99's own Effect TypeScript,Effect.forkChild(Effect.catch(e, …))(found by the round-19 Fable review). The parenthesized forms are notEK0505;(fork e) catch his stillEK0506, as aFiberis not an Effect in Effect 4.1.1. Decided by the agent on 2026-10-29 under Will's rule "when I state a principle, the agent derives the concrete rule and acts; I can override later" (decided detail 99). Corrected again 2026-10-30: the fix wrappedein aneffectblock, which movedeonto the forked fiber, so a failure oftry id()inestopped being the caller's.fork eevaluateseon the caller, asconst p = work(await id())does.
Amendment: initializer boundaries and static reservation
Class field and enum member initializers reset the enclosing Effect context, so a source fork(...) or join(...) there can call an ordinary identifier. A recognized execution operator, such as try e or an unambiguous fiber-operation form, still receives its outside-effect diagnostic (EK0501/EK1701); initialization is ordinary code. Heritage and computed class element names retain the enclosing context, while a nested explicit Effect callable or block creates its own normal context. These boundaries follow TypeScript’s field/enum parsing and native async boundaries.
Static blocks cannot execute Effects. They inherit the enclosing Effect reservation: inside that reserved context, bare fork/join are recognized operations and receive EK1701. A plain class’s static block keeps ordinary identifier calls under the outside-code identifier promise. This is deliberately contextual, unlike ECMAScript’s unconditional [+Await] static-block grammar; module status does not globally reserve VibeLang’s two words. Appendix A states reservation and execution permission separately.
-
Amended 2026-10-30: ordinary arrow heads inherit the source reservation. A plain or
asyncarrow head inherits the enclosingfork/joinreservation through bindings, defaults and destructuring, including in an Effect callable's parameters. Its body has its own callable context; an ordinaryasyncbody uses nativeawaitand does not reservefork/join. A nested ordinary function resets its head too. Outside an enclosing Effect reservation these names remain ordinary identifiers, including ordinaryasyncparameters: native async's ownawaitreservation adds no global ban on them. See the source grammar. -
Amended 2026-10-30: service default initializer context. The special service
static readonly defaultValueinitializer has the source class-field reset: it inherits neither enclosing Effect execution norfork/joinreservation.join(...)can be an ordinary user helper there; explicit fiber execution retains the existing outside-effect diagnostic. Nested explicit Effect callables keep their own context, and initializer timing, throw checks and the service factory type remain unchanged. -
Amended 2026-10-31: an Effect function expression's own name. An
effectfunction or producer expression's own name is reserved as its body is, as ECMAScript parses anasyncfunction expression's name with+Await:(effect function join() { })isEK1702, as(async function await() { })is a SyntaxError. A declaration's name takes the enclosing context, soexport effect function fork() { }declaresforkin plain code, and a method name is never reserved:{ effect join() { } }is valid. Decided by the agent on 2026-10-31 from the "exactly asawait" amendment above.
Prior art
| Language | child of current | given scope | detached |
|---|---|---|---|
| Swift | async let | group.addTask | Task.detached |
| Kotlin | launch | scope.launch | GlobalScope.launch |
| Trio / Eio | — | nursery.start_soon / Fiber.fork ~sw | Fiber.fork_daemon |
| ZIO / Effect | fork / forkChild | forkIn | forkDaemon / forkDetach |
| Go | — | — | go |
Will's words
"thsi seems so fundamental to effect and the runtime it seems like it should have syntax. But we should handle all these forks not just one."
"Ok now we are cooking I think this is right now"