Skip to content
LogoLogo

Running Effects

Sources: VIP-4, VIP-5, VIP-2, VIP-17, VIP-18, VIP-19, VIP-20, VIP-21, VIP-22, VIP-23.

Calling an effect function constructs its cold Effect. Calls retain ordinary JavaScript evaluation, including receivers, arguments and parameter initialization; a plain factory can perform JavaScript work while constructing its result. Returning an Effect does not itself execute it. The table lists language execution sites in effect code (an effect function, a producer or an effect block); native Effect APIs and explicit written runners keep their own call behavior:

ConstructRuns
try eone Effect, giving its success
e catch (err) => yone Effect, handling its failure
fork e and its formsone Effect, in a new fiber
join fFiber.join(f), giving the fiber's success or propagating its failure
throw eEffect.fail(e), failing the enclosing Effect with e as its error value
try.all, try.race, try.any, try.allSettledseveral Effects at once
for concurrentone Effect per item, at once
for tryone Effect per Stream element, in order
provide, uninterruptible, interruptible, restorea block of statements (Services for provide)
yield x in a producerone Queue.offer, waiting for buffer room

"Effect code" means the body of an effect function, a producer or an effect block, not counting nested functions, class field or enum member initializers or static blocks, as await is not allowed there in an async function. The special static readonly defaultValue initializer of a service has that same source class-field boundary, including the reset of enclosing fork/join reservation (Service defaults). Its emitted options-object position does not grant source Effect execution. A computed class key and an extends clause are effect code, as await is allowed there: in effect function f() { class C { [try key()]() { } } }, try key() runs. It is the code Appendix A parses with the grammar's Effect parameter, minus parameter lists and static blocks, which inherit its reservation without permission to run an Effect; class field and enum member initializers parse without it. Parentheses, do and match are not boundaries; an async match is one, as a nested async function is (Pattern matching), so try inside it is an error. Outside effect code a parser still parses these constructs, as TypeScript parses await x outside an async function, and reports the construct's "outside effect code" error. Where the Effect parameter is off, tokens that are also valid TypeScript, such as fork(x) or join(x), keep their TypeScript meaning. A parameter list or static block that inherits the reservation still parses them as fiber operations, so effect function f(p: Fiber.Fiber<number>, a = join(p)) { } is EK1701, as async function f(p, a = await(p)) { } is a SyntaxError.

Every operand is evaluated once, left to right as written, with two exceptions: a catch handler is evaluated only on failure, and the body of an effect block or function is evaluated on each run. Where Effect's argument order differs from source order, the source order still governs: fork.in(scope) e evaluates scope before e, and for concurrent(n) (… of xs) evaluates n before xs. Each operand retains its source lexical environment, including the loop head scope described below.

Inferred Effect arguments

In effect code, a plain call whose own inferred type argument is an Effect reports EK4802 at each argument that uses that type parameter outside a declared Effect position. Inspect the resolved signature's unsubstituted parameter types, following unions, intersections, container type arguments such as T[] and Promise<T>, object properties, and callback parameter and result types; stop at a declared Effect position such as Effect.Effect<T>. This includes overloads, methods and T extends unknown. A signature with a declared Effect result is excluded, as are arguments already run with try, explicit call type arguments and explicitly instantiated callees. Only ordinary call expressions are inspected, not the execution syntax nodes try e, try.all, try.race, try.any, try.allSettled, fork and its scoped forms, join, catch, for try, for concurrent, provide, uninterruptible, interruptible or restore. Ordinary calls inside their operands, heads and bodies are still inspected when they are in effect code. A VIP-40 module name { … } declaration body is TypeScript code and is excluded. Nested plain or async functions keep TypeScript's inference; a nested effect body is inspected independently outside a module declaration body. Composition calls such as pipe(e, Effect.map(f)) and e.pipe(Effect.map(f)) follow the same argument-position test, including callback contracts; pipe has no name-based exemption. This rule follows VIP-1’s dated amendment; EK4802 is advisory, keeps its configured severity and changes neither inference nor execution. An ignored warning or "off" leaves the argument unrun; only a project-wide "error" setting requires a repair.

Writing the call's type arguments explicitly opts out: expect<Effect.Effect<number>>(e) passes the program as data. An explicitly instantiated callee, such as const checkProgram = expect<Effect.Effect<number>>, also keeps the program. A declared Effect.Effect<T> parameter accepts a program without explicit type arguments. To pass a result instead, write expect(try e). The editor offers try for a direct Effect-valued argument when the enclosing written failure and requirement contract accepts it; this action checks those channels, not the edited call's overloads, other arguments or expected result type. A container or callback needs its Effect run where constructed, or the editor action to write explicit type arguments; a non-Effect callback never receives try. Batch --fix rechecks the edited program and refuses a try repair that adds an error, including a broken pipe callback contract, and never chooses that opt-out.

try

Syntax

TryExpression.

Static rules

RuleDiagnostic
try outside effect code; the same rule applies to try.all, try.race, try.any or try.allSettledEK0501
try e where e is not an Effect. A call that may throw, and a Promise, must first be wrapped with Effect.try, tryPromise or promiseEK0502
a try statement (try { } catch { }, try { } finally { }) anywhere in effect code. Handle failures with catch or match, and clean up with ensuring or onExit: try ensuring(write(x), close())EK0503
an unparenthesized try, try.all, try.race, try.any, try.allSettled, fork, fork.in(s), fork.scoped, fork.detached or join expression on the left of **: try a ** b, fork.scoped e ** 2, as ECMAScript rejects await a ** b; write (try a) ** bEK0510

Operand test. e is an Effect when its type is assignable to Effect.Effect<any, any, any>. For example, in Effect 4.1.1 an Exit, a Config, a Context.Service key, a Context.Reference and an error instance (Data.TaggedError or Schema.TaggedError) pass this structural test; the list is not exhaustive. So try new NotFound({ id }) fails like throw. Option, Result, Stream, Layer and Fiber do not pass the Effect operand test. An Effect that can't fail is still run with try. never and any pass this test, never as the success of an Effect that only fails; unknown does not, as TypeScript doesn't assign it to Effect.Effect<any, any, any>; it is the operand test, not Failures' test of which callables are effectful.

try works like await: it is a unary operator with await's precedence, so try a.b() runs a.b() and try f() + 1 adds 1 to the success. As with await, the operand may start on the next line. At the start of a statement, try followed by {, on the same line or not, begins a try statement, as in JavaScript, and that is an error in effect code.

Meaning

try e is yield* e. It runs e and gives its success value. A failure propagates to the enclosing Effect. The static operand test adds no runtime Effect guard: an unchecked any follows native delegation, including its iterator errors, rather than Promise adoption or identity on a non-Promise value. A noniterable such as 5, undefined or an ordinary Promise produces a native TypeError defect; an unchecked empty iterable can complete with undefined, and an ordinary iterator can delegate Effects or return a value. This does not admit typed arrays or generators as Effect operands. See the checked runtime witness.

VibeLang
// compiler-gap: pending VIP-5
effect function load(id: string) {
  const u = try fetchUser(id)
  try sleep("1 second")
  return u
}
Effect
// fragment
function load(id: string) {
  return Effect.gen(function* () {
    const u = yield* fetchUser(id)
    yield* Effect.sleep("1 second")
    return u
  })
}

The structural test admits both values below; running operands() gives Success(15):

VibeLang
// compiler-gap: pending VIP-5
import { Config, Exit } from "effect"
effect function operands() {
  const a = try Exit.succeed(8)
  const b = try Config.succeed(7)
  return a + b
}
Effect
import { Config, Effect, Exit } from "effect"
 
function operands() {
  return Effect.gen(function* () {
    const a = yield* Exit.succeed(8)
    const b = yield* Config.succeed(7)
    return a + b
  })
}
// operands succeeds with 15.

catch

Syntax

CatchExpression with a CatchHandler. catch sits beside ?? in ECMAScript's precedence chain: it has the precedence of ?? and is left-associative, so a catch (() => b) catch (() => c) parses as (a catch (() => b)) catch (() => c), which type-checks only when the inner catch gives an Effect (below), and x = a catch () => b assigns the result. As with ??, mixing it with ??, || or && needs parentheses; without them it is a syntax error (EK0512).

The handler is always a function literal, and its syntax alone decides its kind (VIP-5, amended 2026-10-29). Either kind may be parenthesized:

HandlerKindOn fail("bad") gives
an effect arrow or effect function expressioneffect functionwhat the function's Effect succeeds with; a throw in it fails again, typed
an arrow or function expressionplain functionwhat the function returns

Anything else after catch is a syntax error (EK0509): a name, a call, any other value, an effect { … } block, a class expression, an async arrow or function expression, or a generator or async generator expression. The quick fix wraps a value in a function literal, so the handler's kind stays visible in the source, and removes async or * from a function:

WrittenKindOn fail("bad") gives
x catch (e) => 7, x catch ((e) => 7)plain function7
x catch () => guestplain functionguest
x catch function (e) { return 7 }plain function7
x catch effect (e) => 7, x catch (effect function (e) { return 7 })effect function7
x catch (e) => { throw e }plain functiona compile error, EK0401; write x catch effect (e) => { throw e }
x catch guestsyntax errorquick fix: x catch () => guest
x catch onErr (onErr a function)syntax errorquick fix: x catch (e) => onErr(e)
x catch effect { return 7 }syntax errorquick fix: x catch effect () => { return 7 }
x catch async (e) => 7syntax errorquick fix: x catch (e) => 7
x catch function* (e) { }syntax errorquick fix: x catch function (e) { }
try x catch (e) { … }syntax error EK0509quick fix: x catch effect (e) => { … }

An expression-bodied arrow handler takes an AssignmentExpression as its body, as in (e) => a ?? b: a catch () => b ? c : d means a catch (() => (b ? c : d)), and a catch () => b |> % + 1 means a catch (() => (b |> % + 1)): with a succeeding with 5 and b equal to 0 it gives 5, and (a catch () => b) |> % + 1 gives 6. So a catch effect (e) => b catch () => c means a catch (effect (e) => (b catch (() => c))). With a plain arrow, b catch () => c is in a plain function, so a catch (e) => b catch () => c is the error "catch outside effect code". Parenthesizing the arrow, a catch ((e) => b) catch ((e) => c), parses, but the outer catch then needs an Effect operand (below).

In (a catch h) catch k, the outer catch runs the value that a catch h gives, so a's success (never, if a only fails, passes the operand test above) and h's result must both be Effects. To try fallbacks in turn, nest them in an effect handler: a catch effect () => b catch () => c.

Effect
// fragment
const chained = Effect.gen(function* () {
  // (a catch h) catch k, where a's success and h's result are both Effects
  return yield* Effect.catch(yield* Effect.catch(a, (e) => Effect.succeed(h(e))), (e) => Effect.succeed(k(e)))
})
const nested = Effect.gen(function* () {
  // a catch effect () => b catch () => c
  return yield* Effect.catch(a, () => Effect.gen(function* () { return yield* Effect.catch(b, () => Effect.succeed(c)) }))
})

After a parenthesized handler, a function expression handler or a block-bodied arrow handler, another catch or a lower-precedence operator may follow, but a member access or call can't attach to the handler, so a catch function () { }.call(o) is a syntax error (EK0512). Parenthesize the whole catch expression first: (a catch function () { }).pipe(f).

A block-bodied arrow closes at its }; a following conditional tests the recovered value, and a following catch runs that value if it is an Effect. The plain handler in closedCatchChain deliberately returns an unrun Effect (EK0508 warning); the second catch runs it.

VibeLang
effect function closedCatchConditional() {
  return fail("e") catch () => { return true } ? 1 : 2
}
effect function closedCatchChain() {
  return fail("e") catch () => { return succeed(7) } catch () => 9
}

These have Success(1) and Success(7) respectively:

Effect
import { Effect } from "effect"
 
const closedCatchConditional = Effect.gen(function* () {
  return (yield* Effect.catch(Effect.fail("e"), () => Effect.succeed(true))) ? 1 : 2
})
const closedCatchChain = Effect.gen(function* () {
  const recovered = yield* Effect.catch(Effect.fail("e"), () => Effect.succeed(Effect.succeed(7)))
  return yield* Effect.catch(recovered, () => Effect.succeed(9))
})
if (Effect.runSync(closedCatchConditional) !== 1 || Effect.runSync(closedCatchChain) !== 7) {
  throw new Error("closed catch handlers must leave following operators outside the body")
}

Static rules

RuleDiagnostic
catch outside effect codeEK0504
try x catch h: write try x or x catch h, never both. It fires when a try or try.all is reachable from the head through unary and binary operator nodes only, ignoring the erased TypeScript wrappers described below: try a + b catch h, -try a catch h and x + try a catch h are each EK0505; the quick fix removes try. One inside arguments, an array or object literal, a template substitution, parentheses, a function body or a match arm does not count, so g(try a) catch h catches g's failure. try.all x catch h and the other three operations get this error too, not the one below. A fork, fork.in(s), fork.scoped, fork.detached or join head gets it by the same rule, since each runs an Effect as try does: join f catch h (quick fix Fiber.join(f) catch h, which handles the fiber's failure) and fork e catch h (quick fix fork Effect.catch(e, ($e) => Effect.succeed(h($e))), or fork Effect.catch(e, h) for an effect handler: e stays on the caller and the handler runs on the forked fiber, as fork's operand says). fork (e catch h) runs e here and forks its result: EK1703 unless the result is an Effect. (try x) catch h and (join f) catch h are allowed: each catches the Effect its parenthesized operand succeeds with. (fork e) catch h is not EK0505, but its head is a Fiber, which is not an Effect in Effect 4.1.1, so it is EK0506EK0505
e catch h where e is not an EffectEK0506
a non-function handler, including a parameter list followed directly by a block, or an async, generator or async generator handler (table above)EK0509
catch mixed with ??, || or && without parentheses (parenthesize the mixed operands), or a member access or call after any closed handler (a parenthesized handler, a function-expression handler or a block-bodied arrow handler); parenthesize the whole catch expressionEK0512
a plain handler whose result type is a Promise or thenable (x catch (e) => queryDb(e)): the returned Promise or thenable is not awaited or assimilated; any already-started Promise work continues, as for an unwrapped Promise in effect code (Failures)EK0507
a throw in a plain handler, or a call known to throw in its parameters or body; the old lint warning EK0511 retiresEK0401

For a malformed handler consisting of a parameter list followed directly by a block, as in try x catch (e) { B }, recovery reports EK0509 and does not additionally report EK0505 for that same expression. The handler has no admitted function literal yet; this priority does not suppress independent source-context or separate syntax diagnostics. A repaired, well-formed try x catch (e) => B still gets EK0505. Write x catch effect (e) => { B } for an Effect handler, or x catch (e) => { B } for a plain fallback. Their throw and result rules remain distinct. Offer a mechanical repair only when the recovered parameters, body and source jump targets form the selected valid function literal; do not move outward jumps, invent bindings or implicitly run a plain handler's returned Effect.

VibeLang
// compiler-gap: pending VIP-5
effect function malformedCatchHandler() {
  return try fail("x") catch (e) { return 0 } // EK0509, without a redundant EK0505
}
effect function repairedCatchHandler() {
  return fail("x") catch effect (e) => { return 0 }
}
effect function repairedPlainCatchHandler() {
  return fail("x") catch (e) => { return 0 }
}

Both explicit repairs have Success(0) in these counterparts. The malformed source has no runtime Exit.

Effect
import { Effect } from "effect"
 
function repairedCatchHandler() {
  return Effect.gen(function* () {
    return yield* Effect.catch(Effect.fail("x"), (e) => Effect.gen(function* () { return 0 }))
  })
}
function repairedPlainCatchHandler() {
  return Effect.gen(function* () {
    return yield* Effect.catch(Effect.fail("x"), (e) => Effect.succeed(0))
  })
}

For EK0505's head search, runtime-erased TypeScript expression wrappers are transparent along the unary/binary path: as, angle-bracket assertions where admitted, satisfies, non-null ! and type-only instantiation expressions that add no call. Actual source parentheses still stop the search; grouping added by emission is not a source grouping choice. Ordinary TypeScript checks on each wrapper still apply, and calls, literals, source functions and the other existing barriers retain their rules.

VibeLang
// compiler-gap: pending VIP-5
import { Effect } from "effect"
type Nested = Effect.Effect<Effect.Effect<number, string>, string>
effect function rejectedCatchHeads(nested: Nested, genericEffect: (<T>() => T) & Nested) {
  try nested as Effect.Effect<number, string> catch () => 7; // EK0505
  <Effect.Effect<number, string>>try nested catch () => 7   // EK0505
  try nested satisfies Effect.Effect<number, string> catch () => 7 // EK0505
  try nested! catch () => 7                                // EK0505
  try genericEffect<number> catch () => 7                  // EK0505
}
effect function groupedHead(nested: Nested) {
  return (try nested) as Effect.Effect<number, string> catch () => 7
} // Success(7) for an inner failure; an outer failure propagates
Effect
import { Effect } from "effect"
 
type Nested = Effect.Effect<Effect.Effect<number, string>, string>
function groupedHead(nested: Nested) {
  return Effect.gen(function* () {
    return yield* Effect.catch((yield* nested) as Effect.Effect<number, string>, () => Effect.succeed(7))
  })
}
// The parentheses expose the inner Effect to the handler, not the outer run.

Result tests. EK0103 and EK0508 ask: may this value be an unrun Effect? A type answers yes when it is not never, any or unknown and at least one union member is an Effect. EK0404 and EK0507 ask whether a Promise or thenable may be left unhandled, using the same type predicate with PromiseLike in place of Effect, as typescript-eslint's no-floating-promises does. Effectful callables keep their every-member test, because they ask the opposite question. The operand test of try (EK0502), catch (EK0506) and fork (EK1703) uses assignability to Effect.Effect<any, any, any>. The four concurrent array operations require assignability to ReadonlyArray<Effect.Effect<any, any, any>> (EK1801); for concurrent requires Iterable<unknown> (EK1901), and for try requires Stream.Stream<any, any, any> (EK2004). These are ordinary TypeScript assignability tests, with the project's compiler options; any and never pass. Every other chapter names its test.

VibeLang
// compiler-gap: pending VIP-5
// fragment
async function a(x: any) { return await x }                         // ok: any fails the result test
async function b(e: Effect.Effect<number> | undefined) { return await e }   // EK0103: e may be an Effect
effect function c() { const p = maybeQuery() }                      // EK0404: Promise<number> | undefined

Meaning

e catch h runs e. On a failure in its error channel it calls h with the error. h runs only when the Cause holds no defect: a Cause with both a failure and a defect, such as one a .ts function builds with Effect.failCause, passes through with its failures re-raised as defects, as Effect.catch does in Effect 4.1.1. The handler's kind decides whether its result runs Effects, as for any function. h is evaluated only on failure. An effect block is an ordinary operand, so effect { try write(x); try close() } catch effect (e) => try log(e) replaces the banned try { } catch { } statement:

WrittenEffect TypeScript
x catch h, h a plain function: x catch (e) => f(e) is (e) => Effect.succeed(f(e)), and x catch () => guest is () => Effect.succeed(guest). A throw inside is EK0401; write catch effect (e) => to fail again, typedyield* Effect.catch(x, ($e) => Effect.succeed(h($e)))
x catch h, h an effect function: it runs its Effects, and throw fails again, typedyield* Effect.catch(x, ($e) => h($e)); for an arrow, (e) => Effect.gen(…)
(try x) catch (e) => y, x succeeding with an Effectyield* Effect.catch(yield* x, (e) => Effect.succeed(y))
VibeLang
// compiler-gap: pending VIP-5
effect function load(id: string) {
  const g = fetchUser(id) catch () => guest
  const h = fetchUser(id) catch effect (e) => match (e) {
    when NotFound: guest;
    when DbError: throw e;         // fails again, typed (VIP-4)
  }
  return [g, h]
}
Effect
// fragment
function load(id: string) {
  return Effect.gen(function* () {
    const g = yield* Effect.catch(fetchUser(id), () => Effect.succeed(guest))
    const h = yield* Effect.catch(fetchUser(id), (e) => Effect.gen(function* () {
      if (e._tag === "NotFound") return guest              // NotFound's matcher tests _tag
      if (e._tag === "DbError") return yield* Effect.fail(e)
      throw new TypeError()                                // no arm matched (pinned snapshot): a defect
    }))
    return [g, h]
  })
}

The known-throwing-call check covers a plain handler's eager parameter/default/destructuring initialization and computed binding keys as well as its body (Calls in effect code). Use an effect handler with nonthrowing parameters and move the throwing work into its body behind an explicit bridge; a try in its parameter list is still forbidden, as await is.

A handler's parameter may have a type annotation, checked as for a TypeScript arrow parameter: the error type must be assignable to it, so x catch (e: NotFound) => guest is an error when x can also fail with DbError (VIP-5, amended).

A plain handler whose result type is an Effect gets a VIP-38 lint warning (EK0508), with the quick fix catch effect (e) => try …:

VibeLang
// compiler-gap: pending VIP-38
// fragment: inside an effect function
fetchUser(id) catch (e) => fetchUser("guest")              // warning: the fallback is an unrun Effect
fetchUser(id) catch effect (e) => try fetchUser("guest")   // the fix: Effect.catch(fetchUser(id), () => fetchUser("guest"))

A throw in a plain handler is EK0401, with the quick fix catch effect (e) => { … }, which makes the throw a typed failure.

VibeLang
// compiler-gap: pending VIP-4, VIP-5
// fragment: inside an effect function
fetchUser(id) catch (e) => { throw new Wrapped(e) }                          // EK0401: use catch effect
fetchUser(id) catch effect (e) => { throw new Wrapped(e) }                   // the fix: fails with Wrapped
fetchUser(id) catch (e) => { try { throw e } catch { return 0 } }            // EK0401: even when a try statement catches it
fetchUser(id) catch (e) => () => { throw e }                                 // EK0401: the nested arrow is plain code too

await

await keeps exactly JavaScript's meaning and belongs to async functions and the module top level. await, for await and await using inside effect code are errors (EK0102), regardless of whether the operand is a Promise, Effect, fiber, Deferred or ordinary value, and so is await on an Effect inside an async function (Functions). await fiber and await deferred keep TypeScript's native await behavior and get VIP-38 lint warnings (EK4609 and EK4610). In the existing async context, suggest await runPromise(Fiber.join(fiber)) or await runPromise(Deferred.await(deferred)), using the explicit Promise bridge from VIP-1 and VIP-5. The Effect-body forms join fiber and try Deferred.await(deferred) do not run in an ordinary async body; suggesting them there would introduce EK1701 or EK0501. Reporting the warning does not run either object or change the original result.

VibeLang
// compiler-gap: pending VIP-5
async function asyncFiberValue(fiber: Fiber.Fiber<number>) {
  return await runPromise(Fiber.join(fiber))
}
async function asyncDeferredValue(deferred: Deferred.Deferred<number>) {
  return await runPromise(Deferred.await(deferred))
}

These existing bridges obtain the completion values. With a fiber completing with 7 and a Deferred completed with 8, the observation below is Success([7, 8]). In this example, native await alone retains the unmodified Fiber and Deferred objects.

Effect
import { Effect, Fiber, Deferred } from "effect"
 
async function asyncFiberValue(fiber: Fiber.Fiber<number>) {
  return await Effect.runPromise(Fiber.join(fiber))
}
async function asyncDeferredValue(deferred: Deferred.Deferred<number>) {
  return await Effect.runPromise(Deferred.await(deferred))
}
const fiber = Effect.runFork(Effect.succeed(7))
const deferred = Effect.runSync(Deferred.make<number>())
Effect.runSync(Deferred.succeed(deferred, 8))
const nativeFiber = await fiber
const nativeDeferred = await deferred
const observed = await Effect.runPromiseExit(Effect.promise(async () => [
  await asyncFiberValue(fiber), await asyncDeferredValue(deferred)
]))

try e is allowed, forbidden and ordered exactly where await e is, and every rule that differs is listed here with its reason. The columns use their corresponding execution contexts; transparent expressions inherit that context:

Positionawaittry
the body of the corresponding function (async for await, effect for try), and a do, match arm/guard or parentheses inside that contextallowedallowed
a computed class key, an extends clause, a decorator expression such as @(try d)allowedallowed
a parameter default, a class field initializer, a static blockSyntaxErrorerror
a nested plain functionSyntaxErrorerror
the module top levelallowederror: the top level is not effect code, and nothing runs an Effect implicitly (VIP-26). Write await runPromise(e)

The other constructs follow the Promise they mirror: effect { } builds a cold Effect much as (async () => { })() constructs a Promise, fork e is starting a Promise without awaiting it, e catch h is await e.catch(h), and a failure is a rejection. They differ where an Effect isn't a Promise:

PromiseEffectWhy
await accepts non-Promise operands, including 1 and a possibly undefined optional-call resultA statically non-Effect or possibly undefined operand is EK0502VIP-5 runs an Effect; the operand test requires Effect assignability, independently of optional-call syntax
await p.catch(h) awaits the handled Promise's resulttry e catch h is EK0505 under the head rule; write e catch hVIP-5's catch already runs its operand; explicit source parentheses retain the separate rule for catching an Effect returned by another run
A statement beginning await { value: 1 } awaits an object expressionA statement-start try followed by { begins JavaScript's try statement; a complete try { } finally { } in effect code is EK0503The existing JavaScript statement grammar keeps priority over the unary expression
for await (x of values) may assign an existing binding, and for await accepts var, using and await using headsfor try with any head other than a const or let declaration is EK2005: for try (x of stream); write for try (const v of stream) { x = v }VIP-20 requires a ForDeclaration head, LetOrConst ForBinding; assignment remains available in the body
A for await item may return or jump to an outer labelAn outward return is EK2002, and a labeled jump out is EK2003, in for try head binding initialization or the bodyVIP-20 runs each item in Stream.runForEachWhile's callback; nested functions retain their own targets
for await accepts AsyncIterables and synchronous Iterables, including arraysfor try requires a Stream (EK2004); convert an AsyncIterable with Stream.fromAsyncIterableVIP-20 makes Stream consumption explicit in syntax
An async function* advances only when its consumer pullsA sequential producer may run up to two elements ahead; unbounded concurrent yields have no constant global lead boundVIP-21 uses Stream.callback with one buffered element and pending offers, rather than a continuation runtime
An async function* may return value, which its iterator exposes on completionA producer permits bare return, but return value is EK2128VIP-21 returns a Stream, which has no iterator return value
Even an already completed value resumes await on a later Promise jobtry can complete without suspending; cooperative scheduling can still run other fibersyield* follows Effect's scheduler, with no added Promise job or implicit yieldNow
A rejected await resumes the function with a throw, so using, iterator closing and finally runA failed try never resumes the generator, so none of them runAbandonment: try e is yield* e
A thrown error and a rejection are one channel, and .catch sees bothA failure and a defect are two channels, and catch sees failures onlyEffect's typed error channel
A Promise starts when it is created and runs onceAn Effect is cold, and each try e runs it againAn Effect is a value (VIP-1)
await v adopts a Promise or gives a non-Promise vtry v with v: any uses native delegation: a noniterable defects with TypeError; an unchecked iterable may complete normallytry is yield* v, with no dynamic Effect guard
An async return p assimilates a returned Promise or thenableAn effect return e keeps e as its success value, including an inner Effect left unrun; write return try e to run itEffect.gen returns its value without flattening it (VIP-1/VIP-5)
Nothing cancels a PromiseInterruption ends a fiber: the generator is abandoned and Effect's finalizers runPromises have no counterpart
A function's body starts at the callA forked fiber starts later, and a child that never got a step is interrupted when its parent endsFibers are scheduled and owned

The await analogy specifies source placement, precedence and evaluation order, not Promise-job scheduling. Use explicit try yieldNow to request a cooperative yield; it gives no global ordering guarantee for unrelated fibers. The short default-scheduler reference below observes different orders; synchronous work can also reach Effect's cooperative scheduling budget.

VibeLang
// compiler-gap: pending VIP-5, VIP-17
effect function effectOrder() {
  const events: string[] = []
  fork effect { events.push("child") }
  try succeed(1)
  events.push("after completed")
  try yieldNow
  events.push("after yield")
  return events
}
// Short reference: Success(["after completed", "child", "after yield"]).
Effect
import { Effect } from "effect"
 
function effectOrder() {
  return Effect.gen(function* () {
    const events: string[] = []
    yield* Effect.forkChild(Effect.sync(() => { events.push("child") }))
    yield* Effect.succeed(1)
    events.push("after completed")
    yield* Effect.yieldNow
    events.push("after yield")
    return events
  })
}
async function promiseOrder() {
  const events: string[] = []
  void Promise.resolve().then(() => { events.push("child") })
  await Promise.resolve(1)
  events.push("after completed")
  await Promise.resolve()
  events.push("after yield")
  return events
}
// Short default-scheduler reference: effectOrder succeeds with
// ["after completed", "child", "after yield"]; promiseOrder resolves to
// ["child", "after completed", "after yield"]. This is no global scheduling guarantee.

Within the Effect, a successful try gives the immediate success value unchanged: it neither recursively runs an inner Effect nor assimilates a Promise or thenable stored in that value. An effect function's return and a plain catch handler's result use the same data rule. Explicit Promise runners retain native Promise resolution.

The imported returnEffectInGen lint reports returned Effects in generators under EK38103, with the pinned eligibility, configuration and default "suggestion" category (imported lint codes; VIP-38). Returning an Effect is not a separate EK4802 trigger; that code covers inferred Effect arguments. A lint’s configured severity does not change the returned value.

VibeLang
// compiler-gap: pending VIP-1
effect function boxedReturn(inner: Effect.Effect<number>) { return inner }
effect function unboxedReturn(inner: Effect.Effect<number>) { return try inner }
Effect
import { Effect } from "effect"
function boxedReturn(inner: Effect.Effect<number>) {
  return Effect.gen(function* () { return inner })
}
function unboxedReturn(inner: Effect.Effect<number>) {
  return Effect.gen(function* () { return yield* inner })
}

For inner = succeed(7), running boxedReturn(inner) gives Success(inner), with the inner Effect unrun; running unboxedReturn(inner) gives Success(7).

effect blocks

Syntax

EffectBlock, an expression.

Meaning

effect { … } is an expression. It builds an Effect from the block without running it. Each time the Effect runs, the block runs again. It is Effect.gen(function* () { … }), and the same as (effect () => { … })(). Inside it, try runs Effects. Its this, super and new.target are the enclosing function's, passed in as for an effect arrow (Functions).

VibeLang
// compiler-gap: pending VIP-2
effect function demo() {
  const p = effect { try Console.log("hi"); return 1 }   // an Effect, not yet run
  try p                                              // prints "hi", gives 1
  try p                                              // prints "hi" again
  return try retry(effect { try save(try fetchUser("1")) }, policy)
}
const UsersLive = Layer.effect(Users, effect { return makeUsers() })
Effect
// fragment
function demo() {
  return Effect.gen(function* () {
    const p = Effect.gen(function* () { yield* Console.log("hi"); return 1 })
    yield* p
    yield* p
    return yield* Effect.retry(Effect.gen(function* () { yield* save(yield* fetchUser("1")) }), policy)
  })
}
const UsersLive = Layer.effect(Users, Effect.gen(function* () { return makeUsers() }))

An effect { … } statement builds an Effect and discards it, as a call statement f() does; the Effect language service's imported floatingEffect rule, run by VIP-38, reports it under its EK38xx code (Effect's type-aware lint). Write try effect { … } to run it.

No other syntax builds an Effect. A function literal never becomes an Effect because the parameter it's passed to expects one.

Jumps. A break or continue inside an effect { … } block whose target lies outside the block is EK0202: the block builds its own Effect, which runs later, so a loop or switch outside it is not in reach. A jump to a target inside the block, and a return from the block, remain allowed.

do expressions

do { … } is exactly TC39's do expression, pinned to a dated snapshot of the proposal, everywhere, with no exceptions. It runs now and gives its source block's normal completion value. Its type is the union of the values reaching normal completion under ECMAScript's UpdateEmpty flow, including consumed local jumps. Only a final empty normal completion becomes undefined; an empty completion that inherits an earlier value keeps that value. Paths leaving outward by return, break, continue or throw supply no normal result, and a do expression with no normal path has type never. Their outward returns and typed failures are still checked at the enclosing activation.

A do in the right operand of &&, || or ?? keeps TypeScript’s control-flow facts for that source branch, as its immediate-arrow counterpart does when the body has no outward jumps. For example, x !== undefined && do { x.length } accepts x: string | undefined. The left operand is evaluated once; assignments in the body invalidate narrowing normally.

A do expression keeps ordinary expression fresh literal treatment: const exact = do { 1 } has type 1, while let ordinary = do { 1 } widens to number; a written annotation such as const checked: number = do { 1 } follows TypeScript as usual. const chosen = do { if (c) 1; else "a" } has type 1 | "a"; do { } gives undefined, while const inherited = do { 7; {} } keeps 7. Contextual typing reaches only the expression alternatives contributing to the normal result; discarded intermediate expressions and outward returns retain their own source contexts. Declaration output and overload selection use this source type, independent of generated function or temporary types.

It is not a function: as TC39 specifies, await inside do belongs to the enclosing function, and so does try, so a try in a do or match suspends the enclosing generator.

// fragment: static counterparts; choose has no runtime implementation here
import type { Effect } from "effect"
 
const literalSource = 1
const exact: 1 = literalSource
let ordinaryLet = 1
// @ts-expect-error: an ordinary let initializer widens
const letIsNotExact: 1 = ordinaryLet
const annotatedSource: number = 1
// @ts-expect-error: the written type governs
const annotationIsNotExact: 1 = annotatedSource
const returnedTemporary = (() => 1)()
// @ts-expect-error: function-return widening is different
const returnIsNotExact: 1 = returnedTemporary
const onlyAbrupt = () => { throw 7 }
const noNormalValue: never = onlyAbrupt()
declare const condition: boolean
const alternatives = condition ? 1 : "a"
const alternativeTypes: 1 | "a" = alternatives
declare function choose(value: 1): Effect.Effect<number>
declare function choose(value: number): Promise<number>
const effectOverload: Effect.Effect<number> = choose(literalSource)
const promiseOverload: Promise<number> = choose(ordinaryLet)
const contextual: { value: 1 } = condition ? { value: 1 } : { value: 1 }
VibeLang
// compiler-gap: pending VIP-1, VIP-2
effect function leaveDo() {
  const neverValue = do { return 7 }
  return neverValue // unreachable; neverValue has type never
} // Success(7), with no normal value from the do expression
Effect
import { Effect } from "effect"
 
function leaveDo() { return Effect.gen(function* () { return 7 }) }

Static predicates. For EndsInIterationOrBareIfOrDeclaration, CaseBlock : { } returns false for either value of isLast and any labelSet, including when the switch is nonfinal. Every ForTry and ForConcurrent production, with or without a concurrency limit, uses the native iteration rule: isLast equal to true gives true; otherwise a body passing IsBreak with that label set gives true; otherwise apply the predicate to the body with the same labelSet and isLast. The loop statement itself has IsEmpty and IsBreak equal to false. Thus nonfinal loop bodies are still visited, and a final added loop is rejected like a final native loop. Ordinary switch completion and loop jump restrictions stay unchanged. The snapshot receipt and Appendix A record these cases.

For Statement : VariableStatement, the pinned predicate returns isLast. A final var declaration is therefore EK0201, including when only empty statements or empty blocks follow it. A nonfinal var is still the legacy-JavaScript compile error (Legacy JavaScript): var is banned in all .vibe code (VIP-4, amended). The separate parameter-list restriction remains EK0203. For the same var declaration, EK0201 and EK0203 replace EK0107, following VIP-34's specific-diagnostic rule. When both specific rules apply, report both EK0201 and EK0203: ending in a declaration and declaring var in a parameter list are separate early errors. A nonfinal var outside a parameter list remains EK0107.

VibeLang
// expect-error: EK0203
function nonfinalParameterVar(x = do { var y = 1; 2 }) {} // EK0203
VibeLang
// expect-error: EK0201
function finalBodyVar() { return do { var y = 1 } } // EK0201
VibeLang
// expect-error: EK0201 EK0203
function finalParameterVar(x = do { var y = 1 }) {} // EK0201, EK0203
VibeLang
// compiler-gap: pending VIP-2, VIP-19, VIP-20
const value = do { switch (0) {} 7 } // 7
const empty = do { switch (0) {} }   // undefined
effect function first(rows: Stream.Stream<number>) {
  return do { for try (const row of rows) {} 7 } // Success(7)
}
effect function second(rows: number[]) {
  return do { for concurrent (const row of rows) {} 7 } // Success(7)
}
// Ending either do with its for try or for concurrent instead of 7 is an early error.
Effect
// Do static-predicate controls
import { Effect, Stream } from "effect"
const value = Effect.gen(function* () { switch (0) {}; return 7 })
const empty = Effect.gen(function* () { switch (0) {}; return undefined })
const first = (rows: Stream.Stream<number>) => Effect.gen(function* () {
  yield* Stream.runForEachWhile(rows, () => Effect.succeed(true))
  return 7
})
const second = (rows: number[]) => Effect.gen(function* () {
  yield* Effect.forEach(rows, () => Effect.succeed(undefined), { concurrency: "unbounded", discard: true })
  return 7
})
console.log(JSON.stringify([
  Effect.runSync(value), Effect.runSync(empty) === undefined,
  Effect.runSync(first(Stream.make(1))), Effect.runSync(second([1]))
]))

The counterparts give Success(7), Success(undefined), Success(7) and Success(7) respectively.

Evaluation order. A do or match runs where ECMAScript evaluates the expression that holds it, never earlier or more often. Everything to its left runs first, in source order, fully evaluated as ECMA-262 specifies (callee and receiver read, spreads iterated, computed object-literal keys converted, earlier template substitutions stringified), so o.m(try a(), match (x) { … }) reads o.m, runs a(), runs the match, and calls the method with this as o. A computed key in an assignment target keeps the assignment operator's own conversion timing: o[k] = do { … } runs the do before converting k, and o[k] += do { … } converts k first. In a conditional position it runs only when ECMAScript evaluates that position. Calls keep ECMA-262's EvaluateCall and ArgumentListEvaluation, and lowering adds no observable helper lookup, temporary name (source names) or change to this and super() order. How the compiler keeps this is its choice. Destructuring and parameter defaults follow their own rule. As the snapshot's explainer says, an unlabeled break or continue in a do in a loop head is an early error.

Jumps from expressions. do gives an expression completions no other expression has: return, break and continue. Around it, each stays what it is. ECMA-262 and the pinned drafts say "abrupt" in steps written when an expression could only throw, and VibeLang reads those steps as "throw": FinishMatch, and the parameter steps of async functions and generators. The snapshot's README lists them.

VibeLang
// compiler-gap: pending VIP-2
const x = do { const a = 1; a + 1 }    // 2
effect function total(o: Calc) {
  return o.add(try a(), match (n()) { when 1: 10; default: 0; })
}
Effect
// fragment
function total(o: Calc) {
  return Effect.gen(function* () {
    return o.add(yield* a(), n() === 1 ? 10 : 0)
  })
}

The pinned snapshot is the vendored copy of tc39/proposal-do-expressions at commit 3ef6d463b8163cbf222846b40d17178cca36e9e1, the head on 2026-10-28 (UTC), as pattern matching is pinned.

Destructuring and parameter defaults

A destructuring or parameter default runs only when it applies. In effect code a destructuring pattern behaves exactly as the same pattern in JavaScript with the pinned do proposal: ECMA-262's BindingInitialization, IteratorBindingInitialization and DestructuringAssignmentEvaluation, with GetIterator and IteratorClose for array patterns. Evaluation order, getters, the iterator protocol, temporal dead zones, closing, and the name and message of every protocol error are that algorithm's on the same host, and the canonical Exit compares them (Runtime guarantees). How the compiler achieves it (native destructuring with an inline yield*, a runtime helper, or any other lowering) is its choice; the examples below, the VIP-31 differential suite and the fuzz suite check it.

Three things can happen in a default:

  • A jump. A return, break or continue in a default's do acts on the enclosing function or loop, as TC39 specifies. It leaves an array pattern as the same jump leaves a for…of body: ECMA-262 gives IteratorClose the jump's completion, so the iterator closes unless it is done, and an error from closing replaces the jump.
  • A suspension. A try in a destructuring default in effect code runs in the enclosing effect function, as await in one suspends the enclosing async function; in a producer, a yield there emits from it. A parameter default can't suspend (below).
  • A failure. A try that fails, dies or is interrupted, or a throw, abandons the generator, so nothing closes. A JavaScript throw unwinds the generator under the native destructuring algorithm: IteratorClose runs only when that algorithm requires it, a pending throw wins over an error from closing, and the result becomes a defect.

Parameter defaults. A parameter default can't run an Effect, as async function g(x = await p) is a SyntaxError. Directly evaluated formal-parameter initialization of an Effect function, arrow, method or producer rejects await with EK0102 and yield with EK2104, as native async and generator parameters cannot suspend. This includes destructuring defaults and computed binding keys, and stops at nested source callable or context boundaries; it does not extend effect code into a parameter list. A try there remains EK0501.

// compiler-gap: pending VIP-1, VIP-21
effect function badParameterAwait(value = await Promise.resolve(7)) { return 0 } // EK0102
effect function* badParameterYield(value = yield 7) { } // EK2104
effect function badParameterTry(value = try succeed(7)) { return 0 } // EK0501
effect function parameterBoundaryWitness(
  make = function* () { yield 7 },
  later = async () => await Promise.resolve(8)
) { return 0 }
Effect
import { Effect } from "effect"
 
function parameterBoundaryWitness(
  make = function* () { yield 7 },
  later = async () => await Promise.resolve(8)
) {
  return Effect.gen(function* () { return 0 })
}

parameterBoundaryWitness() succeeds with 0. Its parameter defaults create functions; the nested async and generator bodies have their own native execution contexts.

Everything else JavaScript with the do proposal allows there stays, including a return in a default's do. The pinned proposal allows it and stops there, so it acts as return at the top of the body, whatever the kind of function:

Functionx = do { return 7 }
plain function, arrow, methodthe call gives 7
asyncthe Promise fulfills with 7
effectthe call gives an Effect that succeeds with 7
effect function*a bare return gives an empty Stream; return 7 is EK2128
function*, async function*the default runs at the call and the body never starts: the call gives a generator that finishes with 7, so its first next() gives { done: true, value: 7 }

Parameters are bound at the call, left to right, so earlier defaults have run, later ones don't run, and length is unchanged. A default sees the parameter scope, not the body's bindings: in function g(a = do { return 0 }, b = () => y) { const y = "body"; return b() }, b reads the outer y. The return adds the type of 7 to the function's result, as any return does. A throw in a default follows the kind of function: an async function rejects; an implicit throw makes a plain function's call throw, and an effect function's call give an Effect that dies; a written throw outside an async function is EK0401 (Code outside the generator).

VibeLang
// compiler-gap: pending VIP-2
effect function f() { const { x = do { return 9 } } = {}; return 0 }        // Success(9)
effect function g() { const { x = do { try succeed(7) } } = {}; return x }  // Success(7)
effect function e(x = do { return null }) { return 1 }                      // e() gives Success(null)
effect function k(xs: Iterable<number | undefined>, bad: { value: number }) {
  const [x = do { try succeed(7); bad.value }] = xs
  return x
}
effect function h(xs: Iterable<number | undefined>, b: { value: number }) {
  const [x = do { const v = b.value; try succeed(7); v }] = xs
  return x
}
effect function j(xs: Iterable<number | undefined>) {
  const [x = do { return 9 }] = xs
  return 0
}
effect function i(xs: Iterable<number | undefined>) {
  const [x = do { try fail("f"); 1 }] = xs
  return 0
}

Each row runs the function with the iterable xs described; unless the row says otherwise, xs yields undefined first and its return returns an object. The Exit is the spec's expected result and equals the Effect TypeScript program with the same pattern written natively. j has no native form, because return is not an expression, so its rows equal the same return in a for…of body.

Functionxs and bExit
kreturn throws "close"; the getter bad.value throws "body"Die("body"): the pending throw wins
kreturn returns 1; bad.value returnsDie(TypeError): the close result is not an object
hb.value is 7; return calls a closure that reads xSuccess(7); the closure reads 7
hb.value is 7; before the pattern, globalThis.Symbol is replaced by a Proxy of Symbol whose iterator is undefinedSuccess(7): GetIterator uses %Symbol.iterator%
hxs[Symbol.iterator]() returns 1 or nullDie(TypeError) with native GetIterator's message
has above, returning 1, with Number.prototype.next defined by a foreign fixture to return { done: true }Die(TypeError), not Success(7): the object check comes before next is read
hthe iterator's next is 1Die(TypeError) with native IteratorStep's message
hthe iterator's return is 1; b.value is 7Die(TypeError) with native IteratorClose's message
hxs is 5Die(TypeError) with the message native const [x] = xs gives, naming xs as the host does
jreturn returns an objectSuccess(9); return is called once
jreturn throws "close"Die("close"): the error replaces the jump, as in for…of
jreturn returns 1Die(TypeError) with native IteratorClose's message
jxs is emptySuccess(9); return is not called
ireturn returns an objectFailure("f"); return is not called: the generator is abandoned

Diagnostics. A do block whose last statement violates TC39's EndsInIterationOrBareIfOrDeclaration early error, ending in a declaration, a loop or an if without an else, is EK0201. var in a do expression in a parameter list is EK0203. A break or continue in a loop head's do that leaves it unlabeled or targets that loop is EK0205. Every label in a contiguous chain around that loop names the same loop, so outer: inner: while (do { continue outer; true }) {} is also EK0205. A jump to a label on a different enclosing loop remains exempt from this early error. EK0204 is a compiler gap, not a language rule: where the compiler still compiles a do to a function, it rejects an outward completion (return, var, a jump, await or yield) that VIP-2 keeps legal, such as the parameter-default return in the table above (transitional diagnostics).

fork and join

Syntax

ForkExpression and JoinExpression. fork and join are reserved wherever Appendix A parses [+Effect], including parameter lists and static blocks that inherit that reservation. They cannot name a variable, parameter or label there; in an expression they begin a fiber operation. As property names they stay ordinary names. Class field and enum member initializers reset to [~Effect], so ordinary helper calls to fork or join remain calls. A static block in a plain context also keeps those identifiers under VIP-17’s outside-code promise; a static block inheriting Effect reservation receives EK1701 for the recognized operation because it cannot execute Effects. So an imported function named join can't be called in effect code; rename it at the import: import { join as joinText } from "./strings.vibe". The owner forms fork.in(…), fork.scoped and fork.detached are productions in JavaScript's keyword.name shape, like new.target and import.meta, not property reads. Their tokens may be separated as new . target's may (TryAll). As with await, the operand may start on the next line. A ForkOperand never contains a top-level comma, so const f = fork a(), g = fork b() declares two fibers.

VibeLang
// compiler-gap: pending VIP-17
const sep = (xs: string[]) => xs.join("/")      // outside effect code: a method named join
effect function names(parts: Fiber.Fiber<string[]>) {
  const xs = join(parts)             // joins the fiber parts
  const s = xs.join(",")             // the method join
  const t = sep(xs)
  return [s, t]
}
effect function bad(join: number) { }  // EK1702: join is reserved here, as await is in async code

The inherited reservation covers the whole head, including defaults and destructuring; it does not turn an ordinary async arrow into an Effect arrow. Its body can declare fork or join and uses native await. Outside an enclosing Effect reservation those two names also remain ordinary parameters, as VIP-17 requires.

VibeLang
// compiler-gap: pending VIP-17
effect function badArrowHead() {
  const f = async (join: number) => 1 // EK1702
  return 0
}
const ordinary = async (join: number) => join // ordinary context: allowed
effect function arrowHeadNames() {
  function plain(join: number) { return join } // a plain function resets the head
  const later = async (value: number) => { const join = value; return join }
  return plain(7)
}

The admitted arrowHeadNames() has this counterpart and Success(7); the rejected head has no runtime Exit.

Effect
import { Effect } from "effect"
 
const ordinary = async (join: number) => join
function arrowHeadNames() {
  return Effect.gen(function* () {
    function plain(join: number) { return join }
    const later = async (value: number) => { const join = value; return join }
    return plain(7)
  })
}

A line break after fork or join changes nothing, as after await: join then f on the next line joins f. After join, / begins a regular expression and - 1 is a negation, as after await (VIP-17, amended).

Static rules

RuleDiagnostic
fork or join outside effect codeEK1701
a fork or join naming a variable, parameter or label under [+Effect], including parameter lists and static blocks inheriting Effect reservation, including a plain or async arrow head inheriting that reservation, even in an Effect callable's parameters: both const k = (join) => 1 and const k = async (join) => 1 are errors in effect code, as their await counterparts are in an async function; the arrow body has its own context, and a nested function h(join) { } resets its head too. A reserved fork or join as a shorthand property, { join }, is the same error, as { await } is in an async function; a property key or access, { join: f } or o.join, stays a nameEK1702
a fork, fork.in(s), fork.scoped or fork.detached operand that fails the operand test: fork n with n: number; write fork effect { … } or call the functionEK1703
a join operand not assignable to Fiber.Fiber<any, any>: join p with p: Promise<number>; join the value fork gaveEK1704
a fork. owner other than in(s), scoped or detached: fork.other e; write one of the three, or fork eEK1705
a fork.in(s) scope operand not assignable to Scope.Scope: fork.in(5) e; pass a Scope, as Effect.forkIn requiresEK1706

Meaning

fork starts a fiber and gives its Fiber. The form names the owner, which interrupts the fiber when it ends. A child is not awaited at the end of a scope. A fork child whose parent ends first exits with an interruption; a fork.detached child runs to its own end.

fork e evaluates e now, on the caller, as const p = work(await id()) calls id and work now, and runs only the Effect that e gives on the new fiber. So a failure of id in fork worker(try id()) is the caller's, as a rejection of await id() is. Handling a failure on the child keeps that split, as .catch(h) does on a Promise:

VibeLang
// compiler-gap: pending VIP-17
effect function start(id: Effect.Effect<number, string>) {
  const child = fork Effect.catch(worker(try id), (e) => Effect.succeed(recover(e)))
  return "started"
}
Effect
import { Effect } from "effect"
 
const worker = (n: number) => Effect.fail(`worker ${n}`)
const recover = (e: string) => `recovered ${e}`
function start(id: Effect.Effect<number, string>) {
  return Effect.gen(function* () {
    const child = yield* Effect.forkChild(Effect.catch(worker(yield* id), (e) => Effect.succeed(recover(e))))
    return "started"
  })
}
if (Effect.runSync(start(Effect.succeed(1))) !== "started") throw new Error("the operand was lost")
const failed = Effect.runSyncExit(start(Effect.fail("no id")))
if (failed._tag !== "Failure") throw new Error("the operand left the caller")
idExit of startRuns on the child
succeedsSuccess("started")worker's Effect and the handler
fails with "no id"Failure("no id"), the caller'snothing: no fiber starts
VibeLangEffect TypeScriptOwner
fork worker()yield* Effect.forkChild(worker())the current fiber
fork.in(scope) worker()const $s = scope; yield* Effect.forkIn(worker(), $s)the given scope; scope is evaluated before worker(), in source order
fork.scoped worker()yield* Effect.forkScoped(worker())the current Scope; adds Scope to R
fork.detached worker()yield* Effect.forkDetach(worker())none
join fyield* Fiber.join(f)
VibeLang
// compiler-gap: pending VIP-17
effect function both() {
  const f = fork worker()
  const mine = try other()
  return [mine, join f]
}
effect function owners(scope: Scope.Scope) {
  const a = fork.in(scope) worker()
  const b = fork.scoped worker()
  const c = fork.detached worker()
  return [a, b, c]
}
Effect
// fragment
function both() {
  return Effect.gen(function* () {
    const f = yield* Effect.forkChild(worker())
    const mine = yield* other()
    return [mine, yield* Fiber.join(f)]
  })
}
function owners(scope: Scope.Scope) {
  return Effect.gen(function* () {
    const $s = scope
    const a = yield* Effect.forkIn(worker(), $s)
    const b = yield* Effect.forkScoped(worker())
    const c = yield* Effect.forkDetach(worker())
    return [a, b, c]
  })
}

fork and join have await's precedence: ForkOperand is a UnaryExpression, as for try (VIP-17, amended: "exactly as await"). So fork a() |> f(%) forks a() and pipes the Fiber, and join f + 1 adds 1 to the joined value.

Running Effects concurrently

Syntax

TryAll. try is a reserved word, so try. can't start anything else. Its tokens may be separated as new . target's may. The operand has try's precedence, so try.all [a(), b()] and try.all ids.map(load) each have one reading. The operand is any expression whose type is an array, readonly array or tuple of Effects, not only an array literal (VIP-18, amended).

Static rules

RuleDiagnostic
try.all, try.race, try.any or try.allSettled outside effect codeEK0501
an operand that is not an array of Effects, using assignability to ReadonlyArray<Effect.Effect<any, any, any>>, for try.all, try.race, try.any or try.allSettled, try.all [1, a()]; an object of Effects uses the all global insteadEK1801
a retired await.all, await.race, await.any or await.allSettled; vibelang --fix writes the try operationEK1802
a name after try. other than all, race, any or allSettled: try.map x; run the Effect with try and apply the function to its result, f(try x), or use one of the fourEK1803

Meaning

These are TC39's await operations, run with try instead of await. The names keep JavaScript's choice of result: try.all starts everything at once, like Promise.all, even though Effect.all defaults to one at a time. Unlike Promises, the Effects that lose are interrupted, and an empty operand to try.race or try.any never completes, where Promise.any([]) rejects.

VibeLangEffect TypeScriptGives
try.all [a(), b()]yield* Effect.all([a(), b()], { concurrency: "unbounded" })every success, in operand order; the first failure fails the whole and interrupts the rest. Operands start in order, and one that fails before it suspends stops later ones from starting, as Effect.all does: try.all [fail(e), log("hi")] doesn't log
try.race [a(), b()]yield* Effect.raceAllFirst([a(), b()])the first to finish: success, failure or defect; the losers are interrupted
try.any [a(), b()]yield* Effect.raceAll([a(), b()])the first to succeed; the losers are interrupted. A defect does not end it; it waits for the others, as for a failure. If none succeeds and none dies, the whole fails with all their failures in its Cause, in the order they occurred, and catch receives the first to occur, not the first operand's. If any operand dies, the whole dies: the Cause holds every failure and defect, in order, all as defects, and catch sees nothing
try.allSettled [a(), b()]yield* Effect.all([Effect.exit(a()), Effect.exit(b())], { concurrency: "unbounded" }); any other operand xs: const $xs = xs; yield* Effect.all($xs.map(Effect.exit), { concurrency: "unbounded" })every element's Exit (success, failure, defect or interruption), a tuple for an array literal, as Promise.allSettled keeps one; the whole never fails, as Promise.allSettled never rejects; it can still be interrupted from outside. A non-literal tuple whose elements differ in type is TypeScript's error there, as in Effect TypeScript; write the elements out: try.allSettled [t[0], t[1]]. A literal with a spread element is built once, natively, in element order, as [a(), ...xs, b()] is: each spread uses its value's own iterator, so any iterable works and an array's own Symbol.iterator decides its elements. The counterpart then wraps that one array by index, const $source = [a(), ...xs, b()]; yield* Effect.all($source.map(Effect.exit), { concurrency: "unbounded" }), and iterates no array the compiler creates, so a changed Array.prototype[Symbol.iterator] sees exactly the calls [a(), ...xs, b()] makes; try.allSettled [...new Set([a()])] gives [Exit] for a(). TypeScript types the spread part as an array, not a tuple; the .map describes evaluation and widens no literal's type
VibeLang
// compiler-gap: pending VIP-18
effect function page(id: string) {
  const [u, o] = try.all [fetchUser(id), fetchOrders(id)]
  const both = try all({ u: fetchUser(id), o: fetchOrders(id) }, { concurrency: "unbounded" })   // an object: the all global
  const first = try.race [fetchUser(id), fetchUser("cache")]
  const ok = try.any [fetchUser(id), fetchUser("cache")]
  const exits = try.allSettled [fetchUser(id), fetchOrders(id)]   // [Exit<User, NotFound>, Exit<Order[], DbError>]
  return [u, o, both, first, ok, exits]
}
Effect
// fragment
function page(id: string) {
  return Effect.gen(function* () {
    const [u, o] = yield* Effect.all([fetchUser(id), fetchOrders(id)], { concurrency: "unbounded" })
    const both = yield* Effect.all({ u: fetchUser(id), o: fetchOrders(id) }, { concurrency: "unbounded" })
    const first = yield* Effect.raceAllFirst([fetchUser(id), fetchUser("cache")])
    const ok = yield* Effect.raceAll([fetchUser(id), fetchUser("cache")])
    const exits = yield* Effect.all([Effect.exit(fetchUser(id)), Effect.exit(fetchOrders(id))], { concurrency: "unbounded" })
    return [u, o, both, first, ok, exits]
  })
}

for concurrent

Syntax

ForConcurrent. JavaScript's for can only be followed by ( or await, so for concurrent has one reading, and Appendix A says when the first parenthesized group is the limit: for concurrent (const x of xs) (try f(x)); has no limit.

Static rules

RuleDiagnostic
for concurrent outside effect codeEK1902
break that would leave head binding initialization or the body: for concurrent (const x of xs) { break }EK1903
return targeting the enclosing function in head binding initialization or the body: for concurrent (const x of xs) { return x }EK1904
a labeled continue in head binding initialization or the body naming a statement that encloses the for concurrentEK1905
an operand that is not an Iterable, for concurrent (const x of 5) { }; the test is assignability to Iterable<unknown>; a Stream uses for tryEK1901
a head other than a const or let declaration: var, using, await using or an assignment head, for concurrent (x of xs)EK1906
a limit not assignable to Effect's Types.Concurrency, number | "unbounded": for concurrent("fast") (const x of xs) { }EK1907

continue, and a labeled continue naming the for concurrent itself, end that one item: each is return from the item's generator, as the example shows. The others are errors because the body runs in Effect.forEach's callback, not in the enclosing loop or function (VIP-19). A loop or switch nested in the body keeps its own break and continue.

Meaning

Items run concurrently by default. With a positive integer n, for concurrent(n) runs at most n items at once; under Effect 4.1.1, numeric limits below 1 run every item sequentially as 1 does. Other numbers retain Effect's native behavior. n is any Effect Concurrency, which in Effect 4.1.1 is a number or "unbounded", passed unchanged to concurrency, without range checks or rounding (VIP-19, amended). Evaluate the limit once before entering the operand's head environment. Whether an item gets its own fiber is Effect.forEach's choice. Effect 4.1.1 resolves a numeric limit n to Math.max(1, n) and takes its sequential branch exactly when that is 1: every limit at or below 1 runs the items in order on the enclosing fiber. Every other limit, NaN and "unbounded" included, takes its parallel branch, which forks one fiber per item and starts another item only while fewer are running than the resolved limit: at most ceil(n) at once, and one at a time for NaN. So a fork child in the body is owned by the item's fiber, and interrupted when the item ends, in the parallel branch, and by the enclosing fiber under for concurrent(1). All items join before the loop ends, and one failure interrupts the rest. Results are discarded; try.all ids.map(…) keeps them.

VibeLang
// compiler-gap: pending VIP-19
effect function numericLimit(limit: number | "unbounded") {
  let active = 0
  let maximum = 0
  const seen: number[] = []
  for concurrent(limit) (const id of [1, 2, 3]) {
    active++
    maximum = Math.max(maximum, active)
    try sleep("1 millis")
    seen.push(id)
    active--
  }
  return { seen, maximum }
}
// At 0, -1, 0.5 or 1: Success({ seen: [1, 2, 3], maximum: 1 }).
Effect
import { Effect, type Types } from "effect"
 
function numericLimit(limit: Types.Concurrency) {
  return Effect.gen(function* () {
    let active = 0
    let maximum = 0
    const seen: number[] = []
    yield* Effect.forEach([1, 2, 3], id => Effect.gen(function* () {
      active++
      maximum = Math.max(maximum, active)
      yield* Effect.sleep("1 millis")
      seen.push(id)
      active--
    }), { concurrency: limit, discard: true })
    return { seen, maximum }
  })
}
// On Effect4.1.1, 0, -1, 0.5 and 1 each succeed after visiting all items with maximum 1.
// 2 yields maximum 2; "unbounded" yields maximum 3 for this three-item fixture.

Pulling and iterator closing are also those of pinned Effect.forEach, not native for…of. At effective concurrency 1, it pulls incrementally and does not call the iterator's return() merely because an item Effect fails. In the parallel branch, which every limit not resolving to 1 takes, NaN included, Array.fromIterable materializes a non-array iterable before any item Effect starts; an existing array is reused by reference, with later indexed reads and without invoking its custom iterator. A limit bounds running items, not pulling. An endless non-array iterable cannot finish that initial parallel materialization, so a would-be item failure cannot stop it. The omitted limit is "unbounded"; numeric limits retain Effect's own behavior without a new validation or normalization rule. No additional IteratorClose, streaming prefetch or snapshot copy is introduced. Native iteration errors retain their defect behavior. See the finite lifecycle witness.

VibeLang
// compiler-gap: pending VIP-19
effect function syncAll(ids: string[]) {
  for concurrent (const id of ids) { if (id === "") continue; try syncOne(id) }
  for concurrent(limit()) (const id of ids) { if (id === "") continue; try syncOne(id) }   // limit() first
  for concurrent("unbounded") (const id of ids) { try syncOne(id) }
}
effect function run(ids: string[]) {
  for concurrent(1) (const id of ids) { fork sync(() => id) }   // each child is owned by run's fiber
  for concurrent(2) (const id of ids) { fork sync(() => id) }   // each child is interrupted when its item ends
  for concurrent(NaN) (const id of ids) { fork sync(() => id) } // one item at a time, each on its own fiber, as at 2
}
Effect
// fragment
function syncAll(ids: string[]) {
  return Effect.gen(function* () {
    yield* Effect.forEach(ids, ($item) => Effect.gen(function* () { const id = $item; { if (id === "") return; yield* syncOne(id) } }), { concurrency: "unbounded", discard: true })
    const $n = limit(), $xs = ids
    yield* Effect.forEach($xs, ($item) => Effect.gen(function* () { const id = $item; { if (id === "") return; yield* syncOne(id) } }), { concurrency: $n, discard: true })
    yield* Effect.forEach(ids, ($item) => Effect.gen(function* () { const id = $item; { yield* syncOne(id) } }), { concurrency: "unbounded", discard: true })
  })
}
 
function run(ids: string[]) {
  return Effect.gen(function* () {
    yield* Effect.forEach(ids, id => Effect.gen(function* () { yield* Effect.forkChild(Effect.sync(() => id)) }), { concurrency: 1, discard: true })
    yield* Effect.forEach(ids, id => Effect.gen(function* () { yield* Effect.forkChild(Effect.sync(() => id)) }), { concurrency: 2, discard: true })
    yield* Effect.forEach(ids, id => Effect.gen(function* () { yield* Effect.forkChild(Effect.sync(() => id)) }), { concurrency: NaN, discard: true })
  })
}
// run succeeds with undefined; the limits keep their existing child ownership.

The head is a const or let declaration, destructuring included: ECMAScript's ForDeclaration, LetOrConst ForBinding (VIP-19, amended). var and an assignment head, for concurrent (x of xs), are syntax errors (EK1906). In both for concurrent and for try, head bindings are uninitialized in a separate lexical environment while evaluating the right-hand operand, as in ECMA-262's ForIn/OfHeadEvaluation. In for concurrent(n), the limit is evaluated before entering this head environment. A closure created there captures those uninitialized cells, which are distinct from the bindings initialized for each item; calling it after the loop still encounters their temporal dead zone. The item arrives under a fresh parameter, and the head's declaration runs first in the item's generator, so a default or computed key in it may run Effects and a failure there fails that item. The body keeps its own block scope:

VibeLang
// compiler-gap: pending VIP-19
effect function saveAll(entries: Array<[string, number]>) {
  for concurrent (let [k, v] of entries) { try save(k, v) }
}
Effect
// fragment
function saveAll(entries: Array<[string, number]>) {
  return Effect.gen(function* () {
    yield* Effect.forEach(entries, ($item) => Effect.gen(function* () {
      let [k, v] = $item
      { yield* save(k, v) }
    }), { concurrency: "unbounded", discard: true })
  })
}
VibeLang
// compiler-gap: pending VIP-19, VIP-20
effect function headScope() {
  let read = (): number => 0
  let x = 42
  for concurrent (let x of (read = (): number => x, [1])) { x = 7 }
  return read() // Die(ReferenceError); the RHS closure did not capture the outer or item x
}
// The same head in for try, with Stream.make(1) as its operand, has the same head scope.

The limit in for concurrent(x) (const x of [2]) { } reads the outer x before the head environment exists, so with outer x = 1 it selects concurrency 1.

Effect
import { Effect } from "effect"
 
const limitBeforeHead = Effect.sync(() => {
  const x = 1
  const limit = x
  for (const x of [2]) { /* the separate item binding does not shadow the earlier limit */ }
  return limit
})
const concurrentHeadScope = Effect.sync(() => {
  let read = (): number => 0
  let x = 42
  for (let x of (read = (): number => x, [1])) { x = 7 }
  return read()
})
const streamHeadScope = Effect.promise(async () => {
  let read = (): number => 0
  let x = 42
  for await (let x of (read = (): number => x, [1])) { x = 7 }
  return read()
})
// Both Die(ReferenceError): RHS closures keep the uninitialized head binding.
function limitReturn() { return Effect.gen(function* () { return 7 }) } // Success(7)

for try

Syntax

ForTry.

Static rules

RuleDiagnostic
for try outside effect codeEK2001
return targeting the enclosing function in head binding initialization or the body: for try (const x of s) { return x }EK2002
a labeled break or continue in head binding initialization or the body naming a statement that encloses the for try: outer: for (const s of streams) { for try (const x of s) { continue outer } }EK2003
an operand that is not a Stream, for try (const x of [1, 2]) { }; the test is assignability to Stream.Stream<any, any, any>; convert an AsyncIterable with Stream.fromAsyncIterable firstEK2004
a head other than a const or let declaration: var, using, await using or an assignment head, for try (x of s); write for try (const v of s) { x = v }EK2005

Meaning

Native for...of and for await keep TypeScript's protocol checks and JavaScript meaning, even when their operand also implements Stream. A synchronous native loop over Stream.Stream<number> & Iterable<number> uses its iterator, not the Stream; an async native loop uses its async iterator or synchronous fallback. for await in effect code remains EK0102. Valid native iteration is never rejected, or converted to for try, because its operand is a Stream. A for try repair of a native diagnostic is offered only where context, head, jumps and evaluation order preserve the program's meaning.

for try (const x of stream) consumes a Stream, pulling one element at a time, in order. It mirrors for await over AsyncIterables, which keeps its JavaScript meaning. continue skips one element, break stops consuming and closes the stream's scope, so a producer still running is interrupted and its finalizers run, and a failure in the body fails the loop.

The item callback runs on the current consuming fiber; an item or source callable activation creates no new fiber owner. A plain fork child may outlive the item and successful loop completion while its owning fiber continues, is not implicitly joined, and is interrupted when that owner terminates. The consuming fiber can itself be a concurrent item's fiber or another explicitly created child. fork.in, fork.scoped and fork.detached retain their own native owners; Stream finalization and the loop's break and failure behavior are unchanged. This ownership rule adds no child-start ordering or per-item scope. See the gated ownership witness.

VibeLang
// compiler-gap: pending VIP-20
effect function saveAll(rows: Stream.Stream<Row>) {
  for try (const row of rows) {
    if (row.skip) continue
    if (row.last) break
    try save(row)
  }
}
Effect
// fragment
function saveAll(rows: Stream.Stream<Row>) {
  return Effect.gen(function* () {
    yield* Stream.runForEachWhile(rows, ($item) => Effect.gen(function* () {
      const row = $item
      {
        if (row.skip) return true
        if (row.last) return false
        yield* save(row)
      }
      return true
    }))
  })
}

A break or continue naming the for try itself means the unlabeled one, as in JavaScript and as for concurrent rules:

VibeLang
// compiler-gap: pending VIP-20
effect function first(rows: Stream.Stream<number>) {
  scan: for try (const row of rows) {
    if (row === 0) break scan   // return false
    continue scan               // return true
  }
}

The head and its lowering are as for for concurrent (VIP-20, amended): a const or let declaration, run first in the element's generator.

VibeLang
// compiler-gap: pending VIP-20
effect function first() {
  let out = 0
  for try (const [n = try succeed(7)] of Stream.make([undefined] as const)) { out = n }   // one element, the tuple [undefined]
  return out                                         // 7; a failing default fails the loop
}
Effect
// fragment
function first() {
  return Effect.gen(function* () {
    let out = 0
    yield* Stream.runForEachWhile(Stream.make([undefined] as const), ($item) => Effect.gen(function* () {
      const [n = yield* Effect.succeed(7)] = $item
      { out = n }
      return true
    }))
    return out
  })
}

Producers

Syntax

Producer and ProducerMethod, with TypeScript's class-member Modifiers, as for an async method. The shorthand effect *name() exists only for methods, as async *gen() does in TypeScript; at top level it could parse as multiplication. A plain function* stays a JavaScript generator.

Static rules

RuleDiagnostic
return value in a producer, effect function* p() { return 1 } (a bare return is allowed and ends the stream)EK2128
a written return type incompatible with the producer's Stream result, effect function* p(): number[] { }; annotation compatibility follows TypeScript's ordinary return checkingEK2102
yield* in a producer, effect function* p() { yield* other() }: no VIP specifies delegationEK2103
yield in nonproducer effect code (an effect function, arrow or block), whether or not an outer producer exists, or in directly evaluated formal-parameter initialization of an Effect callable or producerEK2104

EK2104 does not require an outer producer:

// compiler-gap: pending VIP-1, VIP-21
effect function invalid() { yield 1 } // EK2104: no producer body owns this yield
effect function* outer() {
  const p = effect { yield 1 }       // EK2104: the block is a separate activation
  yield 2                            // belongs to outer
}
effect function permitted() {
  function* native() { yield 1 }     // belongs to the native generator
  return 7
}

Meaning

effect function* returns a Stream, as async function* returns an AsyncIterable. Its body is effect code: yield x emits x, and try and the other constructs run Effects. A yield expression belongs to a producer or native generator body of its own activation, as in JavaScript: a nested callable cannot yield through an outer producer. In an effect function, effect arrow or effect block that is not itself a producer, it is EK2104, whether or not an outer producer exists. yield in a nested effect block, an effect arrow or an effect function handler is EK2104; each of those bodies is a nonproducer activation. A nested native generator retains its own TypeScript yield; a plain nongenerator function or arrow retains TypeScript’s own restrictions and diagnostics. A provide block, an interruption block, a for try body and a for concurrent body are not producers: yield x there is yield* Queue.offer(queue, x) on the enclosing producer's queue, as yield in a nested block of an async function* yields from that function. In a for concurrent body, each item's fiber offers its own elements, so elements arrive in offer order. It compiles to Stream.callback with one element of buffer. For one sequential emitter, the producer may run up to two elements ahead of its consumer: one in the buffer and one waiting to be offered. With concurrent yields, each active item fiber can evaluate a value before waiting in Queue.offer; the one-element buffer does not bound all pending offers or upstream work. Unbounded concurrency has no constant global lead bound. A finite concurrency limit bounds active pending emitters in addition to the buffered element and work already consumed. Exact pull semantics are not provided. yield x evaluates to undefined, as in an async function* consumed by for await: it is (yield* Queue.offer(queue, x), undefined). A yield with no operand is yield undefined, so it offers undefined. A failure run by try in the body fails the stream. A written Stream.Stream<A, E, R> return contract checks each yield x against A, and the escaping errors and supplied requirements against E and R. Annotation compatibility is as in TypeScript on the Stream.callback counterpart: aliases, any, unknown and compatible unions are admitted; an incompatible annotation, including never for a returning producer, is EK2102. A union within a Stream channel is an ordinary channel type. Where the annotation supplies no single Stream channel contract, infer the body channels and check the resulting Stream against the annotation. When it is not written, A is the union of the yield operands' types, widened as for an async function*, or never with no yield; E and the body requirements RBody are inferred as for an effect function (Functions); the resulting Stream requires Exclude<RBody, Scope.Scope> because Stream.callback supplies its own Scope.Scope. All other service requirements remain. Written Stream annotations keep their ordinary contract checks; this subtraction does not apply to ordinary Effect functions, foreign Stream values or values of type Scope.Scope. So effect function* f() { yield 1; yield "a" } returns Stream.Stream<number | string>. The callback takes the body types as Stream.callback<A, E, RBody>; its resulting environment is Exclude<RBody, Scope.Scope>. Effect 4.1.1 needs these type arguments because it cannot infer them from the callback. The generator gets the function's this, super and new.target as an effect function's does (Functions); how the compiler passes these bindings is its choice.

VibeLang
// compiler-gap: pending VIP-21
effect function* ticks() {
  yield 1
  try sleep("1 second")
  yield 2
}
effect function consume() { for try (const t of ticks()) { try log(t) } }
class Feed {
  effect *items(ids: string[]) {
    for concurrent (const id of ids) { yield try fetchUser(id) }
  }
}
Effect
// fragment
function ticks() {
  return Stream.callback<number, never, never>((queue) => Effect.gen(function* () {
    yield* Queue.offer(queue, 1)
    yield* Effect.sleep("1 second")
    yield* Queue.offer(queue, 2)
  }).pipe(Effect.andThen(Queue.end(queue))), { bufferSize: 1, strategy: "suspend" })
}
function consume() {
  return Effect.gen(function* () {
    yield* Stream.runForEachWhile(ticks(), ($item) => Effect.gen(function* () { const t = $item; { yield* Effect.log(t) }; return true }))
  })
}
class Feed {
  items(ids: string[]) {
    return Stream.callback<User, NotFound, never>((queue) => Effect.gen(function* () {
      yield* Effect.forEach(ids, ($item) => Effect.gen(function* () {
        const id = $item
        { yield* Queue.offer(queue, yield* fetchUser(id)) }
      }), { concurrency: "unbounded", discard: true })
    }).pipe(Effect.andThen(Queue.end(queue))), { bufferSize: 1, strategy: "suspend" })
  }
}

Interruption blocks

Syntax

Uninterruptible, Restore and Interruptible: contextual keywords followed by { on the same line, and statements in effect code.

uninterruptible { } is always Effect.uninterruptibleMask(($restore) => Effect.gen(…)), as VIP-22 writes it, whether or not a restore block is inside it. The callback's parameter has a fresh name, so a user variable restore stays visible. restore selects the innermost enclosing uninterruptible block in the same source activation. Its entry state may already be uninterruptible, so restoring does not necessarily enable interruption. Intervening restore, interruptible or provide blocks do not rebind that capability.

Static rules

RuleDiagnostic
uninterruptible { }, interruptible { } or restore { } outside effect code, function f() { uninterruptible { } }; EK2201 fires, not EK2111EK2201
restore { } outside an uninterruptible block, effect function f() { restore { } }; one inside an interruptible block nested in it is inside, as $restore is in scope thereEK2111
restore { } that captures an outer mask across an independent effect function declaration/expression, arrow, method, producer or block, as in uninterruptible { const e = effect { restore { try poll() } }; try e }, or inside any for concurrent head binding initialization or body: the nested Effect, or each item's Effect, is outside the enclosing mask's control. Mask per item with Effect.uninterruptibleMask inside the body, or use a plain forEK2202

Meaning

VibeLang
// compiler-gap: pending VIP-22
effect function pay(card: Card, user: User) {
  uninterruptible {
    const receipt = try charge(card)
    restore { try notify(user) }        // the interruptibility from before the mask
    try record(receipt)
  }
  interruptible { try poll() }
}
Effect
// fragment
function pay(card: Card, user: User) {
  return Effect.gen(function* () {
    yield* Effect.uninterruptibleMask(($restore) => Effect.gen(function* () {
      const receipt = yield* charge(card)
      yield* $restore(Effect.gen(function* () { yield* notify(user) }))
      yield* record(receipt)
    }))
    yield* Effect.interruptible(Effect.gen(function* () { yield* poll() }))
  })
}

restore inside a provide block, an interruptible block or a for try body applies, because those run on the masked fiber.

Resolve restore in the same source activation as its lexical mask. A nested effect callable or effect block may introduce its own uninterruptible mask and restore that one. Generated callbacks for transparent regions and for try do not create a source activation boundary; the separate for concurrent ban above remains unchanged.

VibeLang
// compiler-gap: pending VIP-22
effect function invalidCapture() {
  uninterruptible {
    effect function later() { restore { return 7 } } // EK2202: outer mask
  }
}
effect function ownMask() { uninterruptible { restore { return 7 } } } // Success(7)
const delayedOwnMask = effect { uninterruptible { return ownMask } }
Effect
import { Effect } from "effect"
 
function ownMask() {
  return Effect.uninterruptibleMask(restore => Effect.gen(function* () {
    return yield* restore(Effect.succeed(7))
  }))
}
// Success(7), including when this function is called after another mask has ended.
const delayedOwnMask = Effect.uninterruptibleMask(() => Effect.succeed(ownMask))

The following reference enters interruptibly and requests interruption while both masks are active. The inner restore keeps its masked entry state, so the two recorded statements run; leaving the outer mask delivers the pending interruption. The fixture observes both the interruption Exit and the named events trace.

VibeLang
// compiler-gap: pending VIP-22
effect function nestedRestore(events: string[]) {
  const self = try Effect.withFiberSucceed(fiber => fiber)
  uninterruptible {
    uninterruptible {
      try orDie(Effect.try(() => self.interruptUnsafe()))
      restore { events.push("inner") }
    }
    events.push("after inner")
  }
  events.push("after outer") // not reached with interruptible entry
}
// With interruptible entry: interruption Exit; events is ["inner", "after inner"].
Effect
import { Effect } from "effect"
 
function nestedRestore(events: string[]) {
  return Effect.gen(function* () {
    const self = yield* Effect.withFiberSucceed(fiber => fiber)
    yield* Effect.uninterruptibleMask(restoreOuter => Effect.gen(function* () {
      yield* Effect.uninterruptibleMask(restoreInner => Effect.gen(function* () {
        yield* Effect.orDie(Effect.try(() => { self.interruptUnsafe() }))
        yield* restoreInner(Effect.sync(() => { events.push("inner") }))
      }))
      events.push("after inner")
    }))
    events.push("after outer")
  })
}
// Under interruptible entry, the pending interruption yields an interruption Exit.
// The inner restore keeps its uninterruptible entry state: events is ["inner", "after inner"].
// The interruption is delivered when the outer mask restores interruptible entry.

The keywords remove the globals of the same name (VIP-6), so Effect's functions are written Effect.uninterruptible and Effect.interruptible.

Region bodies

A region body is source code that the lowering moves into a generated callback: the block of provide, uninterruptible, restore and interruptible, and head binding initialization and the body of for try and for concurrent. Its names and jumps mean what they mean in the source, as ECMAScript defines them: provide and interruption blocks are ordinary blocks, so return, break and continue inside them reach the enclosing function, loop, switch or label, as in any TypeScript block (VIP-12 and VIP-22, amended). This section is the one lowering rule for both; Services, Functions and Runtime guarantees refer to it.

For either loop, its whole item region includes head binding initialization and the body, excluding nested source functions. The existing outward-jump restrictions cover that whole region. Limit and iterable/Stream operand evaluation remain in the enclosing activation before item work; a return in a do there keeps its ordinary enclosing target. The pinned do proposal's independent early errors for break and continue in heads still apply.

VibeLang
// compiler-gap: pending VIP-19, VIP-20
effect function rejectedHeads(rows: Array<{ n?: number }>, stream: Stream.Stream<{ n?: number }>) {
  for concurrent (const { n = do { return 7 } } of rows) {} // EK1904
  for try (const { n = do { return 7 } } of stream) {}       // EK2002
}
effect function limitReturn() {
  for concurrent(do { return 7 }) (const n of [1]) {}
} // Success(7), before any item starts

Bindings. Every name is resolved on the source before lowering. var is a compile error in .vibe (Legacy JavaScript), so every declaration in a region body (let, const, class, a function declaration) is scoped to its block, as in strict module code, and stays in it; the callback closes over every outer binding, so an assignment to one is seen outside. this, super and new.target are the source function's, passed in as for an effect arrow (Functions).

Jumps. Across provide, uninterruptible, restore and interruptible, a statement retains its ECMAScript completion kind, value (including empty) and target. A provide or interruption block has its source block's normal completion value, observable from do; local labels and loops consume jumps with their source values under UpdateEmpty. Label targets are empty when unlabeled. LabelledEvaluation decides which statement consumes a break or continue: an unlabeled break is consumed by the innermost iteration or switch statement, an unlabeled continue by the innermost iteration statement, and a labeled one by the statement whose label set holds the label (any statement for break, an iteration statement for continue). A return is consumed by the function. The compiler preserves that behavior; how it transports completions is its choice:

  • A completion consumed inside the region body stays as written.
  • A normally completed provide or interruption block contributes its source block's normal value or empty completion. An outward return, break or continue keeps its kind, value and target, including a value inherited before a jump. An empty completion remains distinct from an explicit undefined.
  • A completion continues from the source region's position to the same consumer, even across further regions. A consumed jump can therefore supply a do result; crossing a generated callback does not discard that value.
  • The item protocol remains the API's: a for try consumes continue, or a continue naming it, as return true and break, or a break naming it, as return false; a for concurrent consumes its own continue by ending that item. Nested source functions keep their own completion boundaries.
  • A for concurrent head's binding initialization or body can't produce a break it doesn't consume, a return or a continue naming an enclosing statement, and a for try head's binding initialization or body can't produce a return or a labeled break or continue naming a statement that encloses it: their static rules make these errors, because the body runs in Effect.forEach's or Stream.runForEachWhile's callback, not in the enclosing loop or function (VIP-19, VIP-20).
  • A completion transfers to the surrounding source expression only after the region Effect and its finalizers succeed. A failure, defect or interruption stays in Effect's Cause. The region's own finalizers, such as releasing a provide's layers, run before the jump, because Effect.provide and the masks complete before yield* returns.

Syntax-directed do checks traverse these transparent source blocks as ordinary BlockStatements, respecting source activation boundaries rather than generated callbacks.

Effect observers. Source completion transport is private. At every observable Effect or Scope boundary, provide and interruption regions keep the accepted generator's success payload:

Source completion leaving the regionPrimitive Effect success payload
normal, whether its source value is empty, undefined or another valueundefined
return valuethat value, with its identity preserved
bare returnundefined
outward break or continue, including a labeled jump with an inherited completion valueundefined

The source completion, including its value and target, remains separate from that payload. Thus a normal region ending in 7 can supply a do result of 7 while its layer's finalizer sees Exit.succeed(undefined). A return crossing nested transparent regions supplies its value at each boundary, after inner finalizers succeed. Compiler control records never appear in the primitive success channel, and a real returned record-shaped object or Effect remains ordinary data. Tracking is fresh per execution and item, preserving cold reruns and reentrancy. A body or finalizer that fails, dies or is interrupted transfers no pending source completion; Effect decides the resulting Exit and cause combination. An inner transparent region finishes before an enclosing item's separate for try or for concurrent callback protocol runs.

VibeLang
// compiler-gap: pending VIP-2, VIP-12, VIP-22
effect function regionValues() {
  const provided = do { provide Layer.empty { 7 } }
  const masked = do { uninterruptible { 7 } }
  const restored = do { uninterruptible { restore { 7 } } }
  const interruptibleValue = do { interruptible { 7 } }
  const inherited = do { 5; provide Layer.empty {} }
  const jumped = do { label: { provide Layer.empty { 7; break label; } 99; } }
  const undefinedValue = do { 5; uninterruptible { undefined } }
  return [provided, masked, restored, interruptibleValue, inherited, jumped, undefinedValue] as const
} // Success([7, 7, 7, 7, 5, 7, undefined])
const cleanupLayer = Layer.effectDiscard(addFinalizer(() => die("cleanup")))
effect function cleanupBeforeValue() { return do { provide cleanupLayer { 7 } } } // Die("cleanup")
Effect
import { Effect, Layer } from "effect"
 
const providedCompletion = Effect.gen(function* () {
  let value = 0
  yield* Effect.provide(Effect.gen(function* () { value = 7 }), Layer.empty)
  return value
})
const maskedCompletion = Effect.gen(function* () {
  yield* Effect.uninterruptibleMask(() => Effect.gen(function* () { 7 }))
  return 7
})
const restoredCompletion = Effect.gen(function* () {
  yield* Effect.uninterruptibleMask(restore => restore(Effect.gen(function* () { 7 })))
  return 7
})
const interruptibleCompletion = Effect.gen(function* () {
  yield* Effect.interruptible(Effect.gen(function* () { 7 }))
  return 7
})
const inheritedCompletion = Effect.gen(function* () {
  const earlier = 5
  yield* Effect.provide(Effect.void, Layer.empty)
  return earlier
})
const jumpedCompletion = Effect.gen(function* () {
  let value = 0
  label: {
    yield* Effect.provide(Effect.gen(function* () { value = 7 }), Layer.empty)
    break label
  }
  return value
})
const undefinedCompletion = Effect.uninterruptibleMask(() => Effect.void)
const cleanupLayer = Layer.effectDiscard(Effect.addFinalizer(() => Effect.die("cleanup")))
const cleanupBeforeValue = Effect.gen(function* () {
  yield* Effect.provide(Effect.gen(function* () { 7 }), cleanupLayer)
  return 7
})
const regionCompletions = Effect.gen(function* () {
  return [
    yield* providedCompletion, yield* maskedCompletion, yield* restoredCompletion,
    yield* interruptibleCompletion, yield* inheritedCompletion, yield* jumpedCompletion, yield* undefinedCompletion
  ] as const
})
// Success([7, 7, 7, 7, 5, 7, undefined]); cleanupBeforeValue dies with "cleanup".
VibeLang
// compiler-gap: pending VIP-2, VIP-12
effect function observedNormal() { return do { provide observed { 7 } } }
effect function observedReturn(value: unknown) { provide observed { return value } }
effect function observedBreak() { outer: while (true) { provide observed { break outer } }; return 7 }
// A layer finalizer sees Success(undefined), Success(value), Success(undefined), respectively.
Effect
import { Effect, Layer } from "effect"
 
const observingLayer = (seen: unknown[], label?: string) =>
  Layer.effectDiscard(Effect.addFinalizer(exit => Effect.sync(() => {
    const value = exit._tag === "Success" ? exit.value : exit
    seen.push(label === undefined ? value : [label, value])
  })))
function observedNormal(seen: unknown[]) {
  return Effect.gen(function* () {
    let value = 0
    yield* Effect.provide(Effect.gen(function* () { value = 7 }), observingLayer(seen))
    return value
  })
}
function observedReturn<A>(seen: unknown[], value: A) {
  return Effect.provide(Effect.gen(function* () { return value }), observingLayer(seen))
}
function observedBreak(seen: unknown[]) {
  return Effect.gen(function* () {
    let stopped = false
    outer: while (true) {
      yield* Effect.provide(Effect.gen(function* () { stopped = true }), observingLayer(seen))
      if (stopped) break outer
    }
    return 7
  })
}
function observedNested(seen: unknown[], returning: boolean) {
  return Effect.gen(function* () {
    let value = 0
    yield* Effect.provide(Effect.gen(function* () {
      const result = yield* Effect.provide(Effect.gen(function* () {
        value = 7
        if (returning) return value
      }), observingLayer(seen, "inner"))
      if (returning) return result
    }), observingLayer(seen, "outer"))
    return value
  })
}
const observerCleanupFailure = Effect.gen(function* () {
  yield* Effect.provide(Effect.gen(function* () { 7 }),
    Layer.effectDiscard(Effect.addFinalizer(() => Effect.die("cleanup"))))
  return 7
})
// observedNormal/observedBreak: outer Success(7), observer Success(undefined).
// observedReturn: both Exits carry the same value and identity.
// observedNested: inner then outer see undefined normally, or 7 on return.
// observerCleanupFailure dies with "cleanup" instead of transferring 7.
VibeLang
// compiler-gap: pending VIP-12
effect function find(ids: string[]) {
  outer: for (const id of ids) {
    provide Users = testUsers {
      if (id === "skip") continue outer
      if (id === "stop") break outer
      if (try (try Users).has(id)) return id
    }
  }
  return "none"
}
Effect
// fragment
function find(ids: string[]) {
  return Effect.gen(function* () {
    outer: for (const id of ids) {
      const $control: { kind: "Normal" | "Return" | "Break" | "Continue"; value: string } =
        { kind: "Normal", value: "" }
      yield* Effect.provide(Effect.gen(function* () {
        if (id === "skip") { $control.kind = "Continue"; return }
        if (id === "stop") { $control.kind = "Break"; return }
        if (yield* (yield* Users).has(id)) {
          $control.kind = "Return"
          $control.value = id
          return id
        }
      }), Layer.succeed(Users, testUsers))
      if ($control.kind === "Return") return $control.value
      if ($control.kind === "Break") break outer
      if ($control.kind === "Continue") continue outer
    }
    return "none"
  })
}
VibeLang
// compiler-gap: pending VIP-22
effect function f(xs: number[]) {
  let n = 0
  for (const x of xs) {
    switch (x) { case 1: uninterruptible { n = n + 10; break } }   // leaves the switch
    n++
  }
  return n                                      // [1, 2] gives 12
}
Effect
// fragment
function f(xs: number[]) {
  return Effect.gen(function* () {
    let n = 0
    for (const x of xs) {
      $s: switch (x) {
        case 1: {
          yield* Effect.uninterruptibleMask(($restore) => Effect.gen(function* () {
            n = n + 10
          }))
          break $s
        }
      }
      n++
    }
    return n
  })
}

The pipe operator

|> follows the pinned TC39 Hack pipe proposal with the snapshot reconciliations and effective PipeExpression and PipeBody. % is the topic: the left input value, evaluated once at its source position. The input is the declared ShortCircuitExpression, read with GetValue; the printed evaluator's ConditionalExpression is a stale reference, not a change of precedence.

The missing-topic early error applies ContainsOuterTopic to the body's AssignmentExpression child; the PipeBody boundary still returns false, so an inner topic cannot satisfy an outer body. A body whose whole unparenthesized expression is a pipe is also an early error, making a chain sequential and left-associated. Parenthesized nested pipes retain their separate topic scopes. The existing conditional, yield, assignment and arrow covering-production errors remain, and an EffectArrow is an arrow here. These adopted pipe grammar and early errors use EK2301 unless a more specific code applies; parenthesize a whole arrow body, x |> (() => %). A pipe input cannot end in an open arrow handler, so a |> after catch () => y belongs to the handler's body, at the right edge of a pipe body too (CatchExpression). GetPrimaryTopicValue reads topicEnv.[[TopicValues]], the environment it just acquired. Binary XOR ^ and XOR assignment ^= keep their TypeScript meanings; the snapshot's duplicated % does not remove them, and % remains the topic or modulo according to its syntactic position.

VibeLang
// compiler-gap: pending VIP-23
effect function run(id: string) {
  const n = fetchUser(id) |> retry(%, policy) |> timeout(%, "1 second")
  return try n
}
Effect
// fragment
function run(id: string) {
  return Effect.gen(function* () {
    const $t1 = fetchUser(id); const $t2 = Effect.retry($t1, policy); const n = Effect.timeout($t2, "1 second")
    return yield* n
  })
}
VibeLang
// compiler-gap: pending VIP-23
const one = 1 |> % + 1                     // 2
const chain = 1 |> % + 1 |> % * 2           // 4: left-associated
const nested = 1 |> (% + 1 |> % * 2)        // 4: explicit nesting
const a = true ? 1 : 2 |> % + 10            // 1: only the alternate is piped
const b = (true ? 1 : 2) |> % + 10          // 11: the whole conditional is piped
const value = 7 |> %                       // 7
const obj = {}; const same = obj |> % === obj // true
const xor = 1 ^ 3; let assigned = 1; assigned ^= 3 // both 2
const modulo = 7 |> % % 4                  // 3
const bad = 1 |> f()                       // EK2301: missing topic
const outer = 1 |> (2 |> % + 1)            // EK2301: outer body has no topic

An unparenthesized conditional at either end of a pipe is EK2301: x |> % ? 1 : 2 // EK2301; write (x |> %) ? 1 : 2 or x |> (% ? 1 : 2).

VibeLang
// compiler-gap: pending VIP-5, VIP-23
effect function rightEdge() {
  const x = succeed(5)
  const a = x |> % catch () => 0 |> % + 1            // 5: + 1 belongs to the handler
  const b = (x |> % catch () => 0) |> % + 1          // 6: parentheses close the handler
  const c = x |> % catch () => { return 0 } |> % + 1 // 6: a block closes the handler
  return [a, b, c]
}
const bare = 1 |> effect () => % |> [%]              // rejected: an arrow as a whole pipe body
const closed = 1 |> (effect () => %)                 // a parenthesized arrow, not run
Effect
import { Effect } from "effect"
 
const rightEdge = Effect.gen(function* () {
  const x = Effect.succeed(5)
  const a = yield* Effect.catch(x, () => Effect.succeed(0 + 1))
  const $t1 = yield* Effect.catch(x, () => Effect.succeed(0))
  const b = $t1 + 1
  const $t2 = yield* Effect.catch(x, () => { return Effect.succeed(0) })
  const c = $t2 + 1
  return [a, b, c]
})
const rightEdgeExit = Effect.runSyncExit(rightEdge) // Success([5, 6, 6])

The admitted controls have the following Effect TypeScript counterpart, with Success([2, 4, 4, 1, 11, 7, true, 2, 2, 3]):

Effect
// Pipe reconciliation controls
import * as Effect from "effect/Effect"
const controls = Effect.gen(function* () {
  const one = 1 + 1
  const first = 1 + 1; const chain = first * 2
  const nested = (1 + 1) * 2
  const a = true ? 1 : 2 + 10
  const b = (true ? 1 : 2) + 10
  const value = 7
  const obj = {}; const same = obj === obj
  const xor = 1 ^ 3; let assigned = 1; assigned ^= 3
  const modulo = 7 % 4
  return [one, chain, nested, a, b, value, same, xor, assigned, modulo]
})
console.log(JSON.stringify(Effect.runSync(controls)))

Effect's pipe(x, f, g) function stays available (VIP-14).

The pinned snapshot is the vendored copy of tc39/proposal-pipeline-operator at commit 5ac4dfd624e68b649e6f947885bf5ef67bac19f4, the head on 2026-10-28 (UTC), as pattern matching is pinned.