Runtime guarantees
Source: VIP-31.
- No independent Effect runtime semantics. Every accepted construct is sugar for its specified counterpart. Failures, defects, interruption, finalizer order and the combination of causes are those of the pinned
effect4.1.1 (Configuration). Pattern matching follows VIP-7’s pinned TC39 snapshot and its explicit Effect-pattern adaptations, including its class tests. - Module boundaries. The source module classification is fixed before checking and survives JavaScript and declaration emission; a generated prelude import cannot change binding visibility or select different script-versus-module behavior. Execution still follows the configured output format and its ordinary host rules.
- The guarantee. Compiled output gives the same
Exitas the accepted VIP’s Effect TypeScript counterpart: success, failure, defect or interruption, with finalizers run in the same order. This specification spells out that counterpart. Running Effects specifies operand order: temporaries preserve source order wherever Effect's argument order differs,fork.in(scope) eincluded. Compare the canonicalExitand the finalizer trace. The canonical form isCanonicalVIPExitintypescript/tsc/internal/vibelangfuzz/vip.go: object keys are ordered;Cause.reasonsretain their order and each reason’s_taganderror,defectorfiberId; payloads recursively encode class name ($class), own enumerable string and symbol fields (symbol identity) and an error’snameandmessage, droppingstack; fiber IDs are renamed by first appearance. The encoded object’sfieldsdictionary treats source property names as data: they cannot select runtime tags or invoke inherited setters. Its encoded values still validate and normalize actual nested runtime values. Object identity is not compared: independently allocated errors with equal encoded fields agree. A fixture names any extra comparison it needs. - Interruptor identifier equality. Renaming preserves the native equality classes of
Cause.interruptors: repeatedNaNidentifies one interruptor,Infinityand-Infinityidentify different interruptors, and0and-0identify the same interruptor. For an interrupt-only failureExit, identifiers[NaN, Infinity, -Infinity, NaN]have the canonical identity sequence[0, 1, 2, 0]; identifiers[NaN, NaN, NaN, NaN]have[0, 0, 0, 0]. - Symbol identity in comparison. Under VIP-31's own-enumerable-field rule, generic payloads select keys with
Reflect.ownKeysand their own enumerable descriptors. String fields are sorted, omitting the stringstack; symbol entries keep native own-key order, each key before its recursively encoded value. A symbol described asstackis retained. Keys and symbol values share identity tokens throughout oneExit, including nested values and container entries: actual well-known symbols use their names, registry symbols their exact registry keys, and local symbols first-appearance ordinals and exact descriptions (undefineddiffers from""). Repeated local symbols reuse their ordinal; distinct symbols with equal descriptions get different ordinals. Each run starts a fresh table; hidden or omitted fields allocate no ordinals. Source property names remain data, and object allocation identity remains ignored. - How it is checked. Each VIP’s VibeLang and Effect TypeScript pair is a differential test: it compiles the VibeLang, runs both, and compares their
Exits and finalizer traces. The fuzz suite generates more pairs. It also compares recorded events at the function call, before running its returned Effect, and the complete ordered event trace after the run (VIPCompareintypescript/tsc/internal/vibelangfuzz/vip.go). EqualExits alone cannot distinguish an eager body from a cold one. Call-time events, including parameter-default evaluation, must match the counterpart; running the same returned Effect again reruns its body, without repeating the call or its defaults (VIP-1, VIP-2). Checking only the Effect TypeScript validates the counterpart, not compiler conformance; skipped pairs establish no conformance. - Encoded reason tags. An encoded cause reason's
_tagmust be the stringFail,DieorInterrupt, including in nested encodings; any other tag value fails the comparison with an encoding error. - Runtime helpers do not patch Effect (VIP-7). They implement only the specified adaptations: defining
Symbol.customMatcherwhere absent (below); generated error matchers; the class tests below and the pinned snapshot's extraction rules, without installing matcher methods on built-in globals; and the retained fixed checks for Effect'sOption,Exit,Resultand tagged errors (VIP-7). These checks follow resolved bindings, including aliases, not identifier spelling. The forty fixed tests and extracted lists are listed once in Retained fixed patterns. VIP-7 keeps today’s fixed checks for Effect’s tagged errors until Effect ships matchers; neither inherited instance matching nor a new universal_tagtest replaces them. - Matcher symbol initialization. When the host lacks
Symbol.customMatcher, runtime initialization defines an own property on the globalSymbolwith valueSymbol.for("Symbol.customMatcher"), non-writable, non-enumerable and non-configurable, like a well-known symbol. A pre-existing host symbol is preserved with its native identity. The runtime'scustomMatcherexport and every generated or user-written matcher use that actual key; no runtime copy creates a fresh fallback symbol. Emitted.vibeand.vibexmodules that use the key request the runtime initialization dependency first, before every other import or re-export dependency, independently of the Effect prelude switches. Initialization is synchronous and does not depend on application modules; under CommonJS it runs before any other emitted dependency load. This initializes the key before dependencies first reached through that module and before its body; it cannot undo evaluation through an earlier entry or import path. Source dependencies retain their relative order. A declaration type reference performs no initialization. TypeScript modules (.ts,.tsxand VIP-40 bodies) retain ordinary dependency order and arrange initialization before using the key, normally withimport "vibelang-runtime"first. Without prior host or runtime initialization,Symbol.customMatchercan beundefinedeven when its ambient type is visible. Cycles and top-levelawaitkeep their ordinary TypeScript meanings. Registry symbols retain the host's normal realm and agent boundaries; this adds no symbol transport across agents. See source and declaration types. - Class patterns have one definition, in Class patterns and Retained fixed patterns: a retained guard, an own
Symbol.customMatcher, a built-in test,instanceof, or a call, chosen in that order. This deliberately differs from the pinned draft's[[ConstructedBy]]and internal-slot tests, which JavaScript cannot read for untouched.tsand.jsobjects. No helper patchesFunction.prototypeor records construction. - Source bindings and control flow survive lowering. Bindings and jump targets are resolved on the source before any callback is introduced. Function bodies follow Functions (
this,super,new.target); region bodies follow Region bodies (source completions kept separately from primitive Effect success payloads); expressions follow the evaluation-order rule (call references, receivers, short-circuits); destructuring and parameter defaults behave as JavaScript with thedoproposal. The lowering is the implementation's choice, checked by those sections' examples and the fuzz suite. A nested source function keeps its own bindings. The legacy JavaScript uses listed in Legacy JavaScript are compile errors throughout.vibe, so no lowering has to preserve them. Type references such asFunctionand property names such aso.argumentskeep TypeScript’s meaning. - Source names survive captures. Generated pipe,
do/matchand completion-record temporaries preserve source naming context, including what class static initializers observe; a generated binding cannot add an anonymous name, erase an intended name, or repair it after creation. - Match cache and cleanup follow Pattern matching, including its explicit snapshot reconciliations and cleanup rule.
- Region observers.
provideand interruption regions preserve the original generator payload at every Effect/Scope boundary: normal completion and outwardbreak/continuesucceed withundefined, while sourcereturn valuesucceeds with that value, preserving identity. Source completion values and targets are carried separately and transferred only after successful finalization. Nested regions apply the rule independently; compiler control records never become finalizer payloads. See Region bodies. - Pattern binding lifetimes follow Pattern bindings; helper generation preserves the source lexical cells and the closures that capture them.
- Abandonment. A rejected
awaitresumes theasyncfunction with a throw. A failedtrynever resumes the generator.try eisyield* e, andEffect.gencalls only the generator'snext, only on success. So when ayield*in effect code fails, dies or is interrupted, no JavaScript after it runs: nousingdisposal, no destructuringIteratorClose, noFinishMatch.throw ein effect code isreturn yield* Effect.fail(e), so everythrowabandons the generator, wherever it stands: in an arm, a guard, or adoin either. A JavaScript exception is different: a getter that throws, the no-matchTypeErroror a call that throws unwinds the generator and then becomes a defect. Cleanup follows the applicable JavaScript or matching algorithm; iterator acquisition and advancement failures do not necessarily invokeIteratorClose. Useensuring,scopedoracquireReleasefor cleanup on every exit. - Effect handlers would need a continuation runtime. They are an open VIP (VIP-35), not part of the language.
The matcher-symbol shim has this TypeScript initialization counterpart. It runs in the synchronous initialization dependency described above, before other dependencies first reached through the importing VibeLang module:
const host = Symbol as SymbolConstructor & { readonly customMatcher?: symbol }
if (host.customMatcher === undefined) {
Object.defineProperty(host, "customMatcher", { value: Symbol.for("Symbol.customMatcher") })
}
const actualMatcherKey = host.customMatcher!The following comparator regression cases are pending VIP-31. In each row, a and b are distinct Symbol("same") values; each side is the success payload of an independent run. Symbol fields must be retained, and aliases must survive encoding.
| Left payload | Right payload | Canonical comparison |
|---|---|---|
{ [Symbol("a")]: 1 } | { [Symbol("a")]: 2 } | different |
{ first: a, second: a } | { first: a, second: b } | different |
{ [a]: a } | { [b]: b } | equal |
{ [a]: a } | { [a]: b } | different |
In plain functions, for…of keeps stock TypeScript's emitted behavior, including thrown-value identity. If the body throws, IteratorClose preserves that exact value even when the iterator's return throws. If the body leaves normally through break or return, an error from closing replaces that completion. Nested loops apply this rule at each close; lowering preserves every source label on its loop.
Native iterator completion determines cleanup. In this example all three runs end with Die(problem): an exception from next or a default after exhaustion has trace [], while a default exception before exhaustion closes the iterator, records ["close"] and wins over closeProblem. See the destructuring rule.
// Native iterator completion witness: VIP-2, VIP-31
import { Effect, Exit } from "effect"
const problem = { label: "source" }, closeProblem = { label: "close" }
const box = { get value(): number { throw problem } }
for (const phase of ["advance", "default", "exhausted"] as const) {
const trace: string[] = []
const input: Iterable<number | undefined> = {
[Symbol.iterator]() {
return {
next(): IteratorResult<number | undefined> {
if (phase === "advance") throw problem
if (phase === "exhausted") return { done: true, value: undefined }
return { done: false, value: undefined }
},
return(): IteratorResult<number | undefined> {
trace.push("close")
throw closeProblem
},
}
},
}
const exit = Effect.runSyncExit(Effect.gen(function* () {
const [value = box.value] = input
return value
}))
if (!Exit.isFailure(exit) || exit.cause.reasons.length !== 1 ||
exit.cause.reasons[0]._tag !== "Die" || exit.cause.reasons[0].defect !== problem ||
trace.join() !== (phase === "default" ? "close" : "")) throw new Error("native iterator completion changed")
}The call below evaluates its default once. Each run then succeeds with 7 and appends one body event:
// Coldness witness: VIP-1, VIP-2, VIP-31
import { Effect, Exit } from "effect"
const trace: string[] = []
function parameter() { trace.push("parameter"); return 7 }
function program(value = parameter()) {
return Effect.gen(function* () { trace.push("body"); return value })
}
const p = program()
const called = trace.slice()
if (called.includes("body")) throw new Error("body ran during construction")
if (called.join() !== "parameter") throw new Error("call-time default changed")
const first = Effect.runSyncExit(p)
const second = Effect.runSyncExit(p)
if (!Exit.isSuccess(first) || first.value !== 7 ||
!Exit.isSuccess(second) || second.value !== 7) throw new Error("Exit changed")
if (trace.join() !== "parameter,body,body") throw new Error("body did not rerun")// compiler-gap: pending VIP-7
const timeout = new Cause.TimeoutError()
const matched = match (timeout) {
when Cause.TimeoutError(let e): e;
default: undefined;
}import { Cause } from "effect"
const timeout = new Cause.TimeoutError()
const matched = Cause.isTimeoutError(timeout) ? timeout : undefined
if (matched !== timeout) throw new Error("extractor changed")
// A matching _tag alone does not satisfy the retained module guard.
if (Cause.isTimeoutError({ _tag: "TimeoutError" })) throw new Error("tag-only match")// compiler-gap: pending VIP-7
const isText = (x: unknown): x is string => typeof x === "string"
const textMatch = match ("ok" as unknown) { when isText: true; default: false; }// The pinned matcher call supplies the hint; a predicate may ignore it.
const isText = (x: unknown, _hint?: { matchType: "boolean" }): x is string => typeof x === "string"
const subject: unknown = "ok"
const textMatch = isText(subject, { matchType: "boolean" }) ? true : false
if (!textMatch) throw new Error("predicate match lost")A prototype change exposes the ordinary-class divergence (the shared fixture is foreign.ts):
// compiler-gap: pending VIP-7
import { Foreign, real, fake } from "./foreign.ts"
effect function classify(x: unknown) {
return match (x) { when Foreign: "yes"; default: "no"; }
}
// classify(real) succeeds with "no"; classify(fake) succeeds with "yes".import { Effect } from "effect"
// foreign.ts: untouched TypeScript objects, without construction instrumentation.
export class Foreign {}
export const real = new Foreign()
Object.setPrototypeOf(real, null)
export const fake: unknown = Object.create(Foreign.prototype)
function classify(x: unknown) {
return Effect.gen(function* () { return x instanceof Foreign ? "yes" : "no" })
}
if (Effect.runSync(classify(real)) !== "no") throw new Error("prototype change ignored")
if (Effect.runSync(classify(fake)) !== "yes") throw new Error("prototype match lost")// compiler-gap: pending VIP-7
const arrayMatch = match ([]) { when Array: true; default: false; }
const errorMatch = match (new TypeError("bad")) { when TypeError: true; default: false; }import { Effect } from "effect"
const arrayMatch = Array.isArray([])
const errorMatch = new TypeError("bad") instanceof TypeError
if (!Effect.runSync(Effect.succeed(arrayMatch && errorMatch))) throw new Error("built-in match lost")// compiler-gap: pending VIP-22
effect function binding() {
let x = 0
const read = () => x
uninterruptible { x = 1 }
return read() // 1: the block assigns the function's x
}import { Effect } from "effect"
function binding() {
return Effect.gen(function* () {
let x = 0
const read = () => x
yield* Effect.uninterruptibleMask(($restore) => Effect.gen(function* () { x = 1 }))
return read()
})
}
if (Effect.runSync(binding()) !== 1) throw new Error("binding changed")// compiler-gap: pending VIP-7
class Point { constructor(readonly x: number) {} }
const point: unknown = new Point(7)
// when Point(let x): is EK0704; use the class test and object pattern:
const coordinate = match (point) { when Point and { x: let x }: x; default: 0; }class Point { constructor(readonly x: number) {} }
const point: unknown = new Point(7)
const coordinate = point instanceof Point && "x" in point ? point.x : 0
if (coordinate !== 7) throw new Error("class test lost")A default's suspensions and jumps stay in the enclosing function:
// compiler-gap: pending VIP-2
effect function defaults() {
const { x = do { try succeed(7) } } = {}
const { y = do { return x } } = {}
return 0
}import { Effect } from "effect"
function defaults() {
return Effect.gen(function* () {
const { x = yield* Effect.succeed(7) } = {} as { x?: number }
const { y } = {} as { y?: number }
if (y === undefined) return x // the source's default returns from the function
return 0
})
}
if (Effect.runSync(defaults()) !== 7) throw new Error("default left its activation")// compiler-gap: pending VIP-2
effect function eager(x = do { return null }) { return 1 }
// eager() is an Effect that succeeds with null, built when eager() is calledimport { Effect } from "effect"
let built = 0
function eager(x: unknown = undefined): Effect.Effect<number | null> {
if (x === undefined) { built++; return Effect.succeed(null) } // runs at the call, before the generator
return Effect.gen(function* () { return 1 })
}
const e = eager()
if (built !== 1) throw new Error("parameter default became cold")
if (Effect.runSync(e) !== null) throw new Error("default return lost")
if (Effect.runSync(eager(0)) !== 1) throw new Error("body changed")A one-element producer buffer bounds queued elements, not concurrent emitter work. The producer bound of two elements applies to one sequential emitter. Each concurrent item can evaluate its value before waiting in Queue.offer; an unbounded loop has no constant global lead bound. This Effect TypeScript witness succeeds with { started: 8, consumed: 1 }, so seven values have been evaluated ahead of the consumer:
// Concurrent producer witness: VIP-19, VIP-21, VIP-31
import { Deferred, Effect, Exit, Queue, Stream } from "effect"
const inspect = Effect.gen(function* () {
const started: number[] = []
const ready = yield* Deferred.make<void>()
const stream = Stream.callback<number>(queue => Effect.gen(function* () {
yield* Effect.forEach([1, 2, 3, 4, 5, 6, 7, 8], n => Effect.gen(function* () {
started.push(n)
if (started.length === 8) yield* Deferred.succeed(ready, undefined)
yield* Queue.offer(queue, n)
}), { concurrency: "unbounded", discard: true })
}).pipe(Effect.andThen(Queue.end(queue))), { bufferSize: 1, strategy: "suspend" })
let consumed = 0
yield* Stream.runForEachWhile(stream, () => Effect.gen(function* () {
consumed++
yield* Deferred.await(ready)
return false
}))
return { started: started.length, consumed }
})
const outcome = await Effect.runPromiseExit(Effect.timeout(inspect, "2 seconds"))
if (!Exit.isSuccess(outcome)) throw new Error("concurrent producer stalled")
if (outcome.value.started !== 8 || outcome.value.consumed !== 1) {
throw new Error("concurrent producer lead changed")
}using keeps TypeScript’s meaning (VIP-8, denied). It disposes when JavaScript control leaves its scope, including by return or a JavaScript throw; on a success Exit, the disposer runs as in TypeScript. A non-success Exit of an Effect run by try abandons the generator without unwinding that scope. Use Effect’s scoped, acquireRelease, acquireDisposable or addFinalizer for cleanup on every Effect exit. using in effect code gets a warning (EK0801), with a quick fix to acquireDisposable inside scoped; the warning does not change disposal semantics.
// compiler-gap: pending VIP-34
// openFile is the plain helper in the shared TypeScript fixture below.
import { openFile } from "./resource.ts"
effect function unsafe(defect = false) {
using f = openFile() // warning: use acquireDisposable inside scoped
if (defect) try die("boom") // defect: disposer is also skipped
throw "bad" // typed failure: disposer is skipped
}
effect function safe() {
return try scoped(effect {
const f = try acquireDisposable(sync(() => openFile()))
throw "bad" // typed failure: scope disposes f
})
}Effect TypeScript (the exported fixture is resource.ts):
import { Effect, Exit } from "effect"
export const trace: string[] = []
/** @throws {never} */
export function openFile(): Disposable {
return { [Symbol.dispose]() { trace.push("dispose") } }
}
function unsafe(defect = false) {
return Effect.gen(function* () {
using f = openFile()
if (defect) yield* Effect.die("boom")
return yield* Effect.fail("bad")
})
}
function safe() {
return Effect.gen(function* () {
return yield* Effect.scoped(Effect.gen(function* () {
const f = yield* Effect.acquireDisposable(Effect.sync(() => openFile()))
return yield* Effect.fail("bad")
}))
})
}
// Run separately: both fail with "bad"; unsafe leaves trace empty, safe appends "dispose".
const unsafeExit = Effect.runSyncExit(unsafe())
const unsafeTrace = trace.slice() // []
trace.length = 0
const safeExit = Effect.runSyncExit(safe())
const safeTrace = trace.slice() // ["dispose"]
trace.length = 0
const defectExit = Effect.runSyncExit(unsafe(true))
const defectTrace = trace.slice() // []
if (!Exit.isFailure(unsafeExit) || !Exit.isFailure(safeExit)) throw new Error("expected failures")
if (!Exit.isFailure(defectExit) || defectTrace.length !== 0) throw new Error("defect disposal changed")
if (unsafeTrace.length !== 0 || safeTrace.join() !== "dispose") throw new Error("disposal changed")Region finalizer observations
// compiler-gap: pending VIP-2, VIP-12
effect function normalObservation() { return do { provide observed { 7 } } }
effect function returnObservation() { provide observed { return 7 } }import { Effect, Layer } from "effect"
const released: unknown[] = []
const observed = Layer.effectDiscard(Effect.addFinalizer(exit => Effect.sync(() => { released.push(exit) })))
function normalObservation() {
return Effect.gen(function* () {
let value = 0
yield* Effect.provide(Effect.gen(function* () { value = 7 }), observed)
return value
})
}
function returnObservation() {
return Effect.provide(Effect.gen(function* () { return 7 }), observed)
}
// Both outer Exits are Success(7); their layer-finalizer Exits are
// Success(undefined) and Success(7), respectively.A source field name that resembles a comparison tag remains data. These Effects succeed with distinct records; each computed __proto__ key is an ordinary own property:
// Source-field witness: VIP-31
import { Effect, Exit } from "effect"
const first = Effect.runSyncExit(Effect.succeed({ $fiber: 1, ["__proto__"]: 7 }))
const second = Effect.runSyncExit(Effect.succeed({ $fiber: 2, ["__proto__"]: 8 }))
if (!Exit.isSuccess(first) || !Exit.isSuccess(second)) throw new Error("expected successful records")
if (first.value.$fiber !== 1 || second.value.$fiber !== 2 ||
first.value["__proto__"] !== 7 || second.value["__proto__"] !== 8 ||
!Object.hasOwn(first.value, "__proto__") || !Object.hasOwn(second.value, "__proto__")) {
throw new Error("source fields changed")
}
// Success({ $fiber: 1, ["__proto__"]: 7 }) differs from Success({ $fiber: 2, ["__proto__"]: 8 }).Unchecked try operands keep ECMA-262's delegated yield evaluation inside the accepted Effect.gen counterpart, without a runtime Effect guard (try). A noniterable defects with its native TypeError; an unchecked iterable can complete normally. Native iterator getters, calls, results and errors keep their reached order and identity. This does not change static operand admission or Effect's generator abandonment.
// compiler-gap: pending VIP-5
effect function unchecked(value: any) { return try value }
// unchecked(5) defects with TypeError; unchecked([]) succeeds with undefined.// Unchecked delegation witness: VIP-5, VIP-31
import { Effect, Exit } from "effect"
function unchecked(value: any): Effect.Effect<unknown> {
return Effect.gen(function* (): Generator<Effect.Effect<unknown>, unknown, unknown> { return yield* value })
}
for (const value of [5, undefined, Promise.resolve(5)]) {
const result = Effect.runSyncExit(unchecked(value))
if (!Exit.isFailure(result) || result.cause.reasons.length !== 1) throw new Error("expected one defect")
const reason = result.cause.reasons[0]
if (reason._tag !== "Die" || !(reason.defect instanceof TypeError)) throw new Error("native delegation error lost")
}
for (const [value, expected] of [
[[], undefined],
[(function* () { return 7 })(), 7],
[(function* () { return yield* Effect.succeed(8) })(), 8]
]) {
const result = Effect.runSyncExit(unchecked(value))
if (!Exit.isSuccess(result) || result.value !== expected) throw new Error("unchecked iterable completion changed")
}
if (await (5 as any) !== 5) throw new Error("native await identity changed")The for concurrent iterable's lifecycle is pinned Effect.forEach behavior (Running Effects), not native for…of backpressure or closing. This finite witness records pulling before explicitly closing its remaining test iterator. The endless-input consequence follows from synchronous materialization; it does not require running an endless probe.
// compiler-gap: pending VIP-19
effect function visit(items: Iterable<number>) {
for concurrent(2) (const item of items) {
if (item === 1) throw "bad"
}
}
// A finite non-array input is fully pulled before item Effects start; the Exit fails with "bad".// Iterable lifecycle witness: VIP-19, VIP-31, VIP-32
import { Effect, Exit } from "effect"
function observe(concurrency: number | "unbounded") {
const trace: string[] = []
function* items() {
try {
for (let item = 0; item < 5; item++) { trace.push("pull:" + item); yield item }
} finally { trace.push("close") }
}
const iterator = items()
const result = Effect.runSyncExit(Effect.forEach(iterator, item => Effect.gen(function* () {
trace.push("item:" + item)
if (item === 1) return yield* Effect.fail("bad")
}), { concurrency, discard: true }))
const observed = trace.slice()
iterator.return() // test-owned cleanup, after recording the native traversal
if (!Exit.isFailure(result) || result.cause.reasons[0]?._tag !== "Fail" ||
result.cause.reasons[0].error !== "bad") throw new Error("item failure changed")
return observed
}
for (const limit of [0, -1, 0.5, 1]) {
if (observe(limit).join() !== "pull:0,item:0,pull:1,item:1") throw new Error("incremental pulling or closing changed")
}
for (const limit of [2, 2.5, "unbounded"] as const) {
const trace = observe(limit)
if (trace.slice(0, 6).join() !== "pull:0,pull:1,pull:2,pull:3,pull:4,close") throw new Error("parallel materialization changed")
if (trace[6] !== "item:0") throw new Error("item started before materialization")
}
let iteratorCalls = 0
const array = [1, 2]
Object.defineProperty(array, Symbol.iterator, { value: function* () { iteratorCalls++; yield 99 } })
function visit(concurrency: number) {
const seen: number[] = []
Effect.runSync(Effect.forEach(array, item => Effect.sync(() => { seen.push(item) }), { concurrency, discard: true }))
return seen.join()
}
if (visit(1) !== "99" || iteratorCalls !== 1) throw new Error("sequential array iterator changed")
iteratorCalls = 0
if (visit(2) !== "1,2" || iteratorCalls !== 0) throw new Error("parallel array reuse changed")A plain fork in a for try item belongs to the current consuming fiber (Running Effects), not to the item. This witness waits for the child to start, lets the item and loop finish, then releases and joins it from the continuing owner. The gates establish this trace without promising scheduling order for unrelated fibers.
// compiler-gap: pending VIP-20
effect function consume(items: Stream.Stream<number>) {
for try (const item of items) { fork work(item) }
try parentWork() // plain children may still run while this consuming fiber continues
}// Stream consuming-fiber witness: VIP-17, VIP-20, VIP-31
import { Deferred, Effect, Exit, Fiber, Stream } from "effect"
const trace: string[] = []
const result = await Effect.runPromiseExit(Effect.gen(function* () {
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const children: Array<Fiber.Fiber<void>> = []
yield* Stream.runForEachWhile(Stream.make(1), () => Effect.gen(function* () {
children.push(yield* Effect.forkChild(Effect.gen(function* () {
yield* Deferred.succeed(started, undefined)
yield* Deferred.await(release)
trace.push("child")
})))
yield* Deferred.await(started)
trace.push("item")
return true
}))
trace.push("loop")
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(children[0])
trace.push("joined")
}))
if (!Exit.isSuccess(result) || trace.join() !== "item,loop,child,joined") throw new Error("consuming-fiber ownership changed")The producer callback supplies Scope for its body, so its inferred Stream environment excludes that Scope and keeps other services (Producers). This witness's body needs Scope.Scope, while each consumption succeeds with [1] without an external Scope. Construction is cold; two consumptions start two bodies and close two callback scopes. Values of type Scope.Scope and explicit annotation checks are unchanged, and the witness adds no ordering promise for unrelated fibers.
// Producer Scope witness: VIP-21, VIP-31, VIP-32
import { Effect, Exit, Queue, Scope, Stream } from "effect"
let starts = 0, closes = 0
const body: Effect.Effect<void, never, Scope.Scope> = Effect.gen(function* () {
starts++
yield* Effect.addFinalizer(() => Effect.sync(() => { closes++ }))
yield* Effect.forkScoped(Effect.never)
})
const values: Stream.Stream<number> = Stream.callback<number>(queue => body.pipe(
Effect.andThen(Queue.offer(queue, 1)),
Effect.andThen(Queue.end(queue))
), { bufferSize: 1, strategy: "suspend" })
if (starts !== 0 || closes !== 0) throw new Error("producer started at construction")
for (let run = 1; run <= 2; run++) {
const result = await Effect.runPromiseExit(Stream.runCollect(values))
if (!Exit.isSuccess(result) || result.value.join() !== "1") throw new Error("producer Exit changed")
if (starts !== run || closes !== run) throw new Error("callback Scope was not fresh or finalized")
}