Skip to content
LogoLogo

VIP-21: producers: effect function* returns a Stream

Status: accepted by Will, 2026-10-28.

Decision

effect function* ticks() {
  yield 1
  try sleep("1 second")
  yield 2
}
for try (const t of ticks()) { … }       // VIP-20
 
// compiles to Effect's Stream.callback
function ticks() {
  return Stream.callback<number>((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" })   // ends the stream when the body returns
}
  • effect function* returns a Stream, as async function* returns an AsyncIterable. yield x emits x, and try runs Effects (VIP-5).
  • A plain function* stays a JavaScript generator.
  • Amended 2026-10-30: inferred producer requirements. Infer the body requirements RBody as for an Effect, then expose Exclude<RBody, Scope.Scope> on the Stream: the accepted Stream.callback supplies its Scope. Other service requirements remain. Explicit annotations keep ordinary contract checking, and values of type Scope.Scope is unchanged. Each callback consumption owns that provided Scope; this adds no new scheduling or finalizer order. See Producers and the typed lifetime witness.
  • It compiles to Effect's Stream.callback with one element of buffer. For one sequential emitter, the producer may run up to two elements ahead of the 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, which would need a continuation runtime, are not provided.
  • return value in a producer is a compile error, because a Stream has no return value. A bare return ends the stream.
  • Amended 2026-10-30: the producer-return diagnostic is EK2128. return value in a producer is EK2128, because a Stream has no return value; a bare return ends the stream. This assigns the next free code in VIP-21's block, replacing the old EK2101, which 0.0.10 reports for another rule. The old EK2101 is retired and will never be reassigned. Earlier numbers in VIP-21's block are already taken or historical, so this changes no diagnostic's meaning for a 0.0.10 user. Decided by the agent on 2026-10-30 under VIP-34's code-numbering amendment.
  • Ambiguity: none for effect function* name(). The shorthand effect *name() exists only for methods, as async *gen() does in TypeScript. At top level it could parse as multiplication.
  • Amended 2026-10-29: no delegation yet. yield* in a producer is a compile error until a VIP specifies delegation. Decided by the agent on 2026-10-29 under Will's rule "when I state a principle, the agent derives the concrete rule and acts; I can override later" (decided detail 50).

History

About 9 findings, on the old design (a type decided a producer's fiber; a runtime built on continuations). Neither applies to this design.

  • Amended 2026-10-30: parameter yield ownership. yield in directly evaluated formal-parameter initialization of any Effect callable, a producer included, is EK2104. A producer owns yield in its body, not its parameter initialization; nested native generators and ordinary async callables keep their own contexts. The existing nonproducer Effect-body rejection remains unchanged.

Prior art

JavaScript's async function*, Kotlin's flow { emit(x) }, Python's async generators.

Will's words

"Accept"

Corrected 2026-10-29: one sequential emitter can run up to two elements ahead (one buffered, one waiting); concurrent emitters can each have a pending offer. The Effect TS needs Queue.end(queue) after the body, or the stream never completes (found by the differential fuzz suite).

  • Clarified 2026-11-03: written producer contracts. Written return annotations follow TypeScript's ordinary contract checking on the Stream.callback counterpart. A written Stream.Stream<A, E, R> checks the yielded values, escaping errors and supplied requirements; otherwise infer the body channels and check the resulting Stream against the annotation. Aliases, any, unknown and compatible unions are admitted; an incompatible annotation is EK2102. This adds no annotation syntax.