Skip to content
LogoLogo

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)
  • fork starts a fiber and gives its Fiber. The form names the owner, which is what interrupts the fiber. Each form is exact sugar for Effect's function, used inside an effect function or block.
  • fork keeps 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.name shape, like new.target and import.meta.
  • Ambiguity: none. fork and join are contextual keywords whose operand must be on the same line. Bare modifier words were rejected because fork scoped(x) would have two readings.
  • Amended 2026-10-29: reserved in effect code. fork and join are reserved words in effect code exactly as await is in an async function body: they can't name a variable, parameter or label there, and where an expression can start they begin a fork or join expression, so join(parts) joins. In an IdentifierName position (parts.join(","), { join: 1 }) they stay ordinary names, as await does. 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. ForkOperand is a UnaryExpression, await's operand. A binding named fork or join outside effect code gets no extra warning, as a binding named await gets 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.in evaluates scope first. Operands evaluate in source order: fork.in(scope) e is const $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: fork and join operands are checked. A fork, fork.in(s), fork.scoped or fork.detached operand must pass the operand test of try (assignable to Effect.Effect<any, any, any>); otherwise EK1703. A join operand must be assignable to Fiber.Fiber<any, any>; otherwise EK1704. 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: fork and join heads before catch. join f catch h and fork e catch h are EK0505, as try x catch h is (VIP-5); the quick fixes are Fiber.join(f) catch h and fork Effect.catch(e, ($e) => Effect.succeed(h($e))), or fork Effect.catch(e, h) for an effect handler, so e stays on the caller and the handler runs on the forked fiber; fork (e catch h) runs e here and forks its result, EK1703 unless that result is an Effect. Corrected 2026-10-30: the quick fix was fork (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 not EK0505; (fork e) catch h is still EK0506, as a Fiber is 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 wrapped e in an effect block, which moved e onto the forked fiber, so a failure of try id() in e stopped being the caller's. fork e evaluates e on the caller, as const 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 async arrow head inherits the enclosing fork/join reservation through bindings, defaults and destructuring, including in an Effect callable's parameters. Its body has its own callable context; an ordinary async body uses native await and does not reserve fork/join. A nested ordinary function resets its head too. Outside an enclosing Effect reservation these names remain ordinary identifiers, including ordinary async parameters: native async's own await reservation adds no global ban on them. See the source grammar.

  • Amended 2026-10-30: service default initializer context. The special service static readonly defaultValue initializer has the source class-field reset: it inherits neither enclosing Effect execution nor fork/join reservation. 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 effect function or producer expression's own name is reserved as its body is, as ECMAScript parses an async function expression's name with +Await: (effect function join() { }) is EK1702, as (async function await() { }) is a SyntaxError. A declaration's name takes the enclosing context, so export effect function fork() { } declares fork in plain code, and a method name is never reserved: { effect join() { } } is valid. Decided by the agent on 2026-10-31 from the "exactly as await" amendment above.

Prior art

Languagechild of currentgiven scopedetached
Swiftasync letgroup.addTaskTask.detached
Kotlinlaunchscope.launchGlobalScope.launch
Trio / Eio—nursery.start_soon / Fiber.fork ~swFiber.fork_daemon
ZIO / Effectfork / forkChildforkInforkDaemon / 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"