Skip to content

Rule proposal: zero-duration Effect.sleep re-implements Effect.yieldNow through the Clock #661

Description

@mattiamanzati

Problem

yield* Effect.sleep(0) (or Effect.sleep("0 millis"), Effect.sleep(Duration.zero)) is the carried-over setTimeout(0) idiom: the author wants to give other fibers a chance to run before continuing. Effect has a dedicated primitive for exactly that intent — Effect.yieldNow (an Effect<void> constant in v4) — and the two are not equivalent. Effect.sleep schedules the fiber through the Clock service: it registers a timer, which means it also interacts with TestClock — under test, a sleep(0) still needs the test clock to advance, whereas the author almost certainly just wanted a cooperative yield. Effect.yieldNow simply reschedules the fiber on the scheduler with no Clock involvement, which is what a zero-duration sleep is trying to express. So this is a prefer-this-API rule with a real semantic payoff: clearer intent, no timer machinery, and no surprising TestClock interaction. Related: #477.

Bad — compiles cleanly, the rule should flag this

// RULE: sleepZeroToYieldNow
// BAD: Effect.sleep with a zero duration goes through the Clock service —
// it registers a timer (and under TestClock waits for the clock to advance)
// just to let other fibers run. The intent here is a cooperative yield, not
// a timed sleep.
import { Duration, Effect } from "effect"

const processChunk = (chunk: ReadonlyArray<number>) =>
  Effect.sync(() => chunk.reduce((acc, n) => acc + n, 0))

const processAll = (chunks: ReadonlyArray<ReadonlyArray<number>>) =>
  Effect.gen(function*() {
    let total = 0
    for (const chunk of chunks) {
      total += yield* processChunk(chunk)
      // "let other fibers breathe" — but this is a Clock-backed timer
      yield* Effect.sleep(0)
      // equivalent spellings the rule should also catch:
      // yield* Effect.sleep("0 millis")
      // yield* Effect.sleep(Duration.zero)
    }
    return total
  })

Good

// RULE: sleepZeroToYieldNow
// GOOD: Effect.yieldNow reschedules the fiber on the scheduler directly —
// no Clock, no timer, no TestClock interaction. It states the intent
// ("yield to other fibers") exactly.
import { Effect } from "effect"

const processChunk = (chunk: ReadonlyArray<number>) =>
  Effect.sync(() => chunk.reduce((acc, n) => acc + n, 0))

const processAll = (chunks: ReadonlyArray<ReadonlyArray<number>>) =>
  Effect.gen(function*() {
    let total = 0
    for (const chunk of chunks) {
      total += yield* processChunk(chunk)
      yield* Effect.yieldNow
    }
    return total
  })

Proposed rule behavior

  • Match CallExpression whose callee resolves (via checker symbol resolution) to Effect.sleep from the effect package.
  • Flag when the single argument is provably zero: the numeric literal 0, a string literal matching a zero-duration form ("0 nanos", "0 micros", "0 millis", "0 seconds", etc.), or a property access resolving to Duration.zero.
  • Optionally also cover constructor calls that are statically zero, e.g. Duration.millis(0) / Duration.seconds(0), via the same symbol check plus a literal-argument check.
  • Report with a suggested rewrite to Effect.yieldNow, and word the message around the semantic difference: sleep(0) schedules via the Clock (and interacts with TestClock), yieldNow just reschedules the fiber.
  • Do not flag non-literal arguments (variables, config-derived durations) — a runtime-zero duration may be intentional Clock behavior, and the rule should stay effectively false-positive-free by only matching provably-zero literals.
  • Grounding note: the only textual match in the reference codebases is the JSDoc doctest inside Effect.sleep's own documentation, so the rule should run on regular source (doctest extraction would self-flag the docs example).

Where this came up

No true-positive occurrences found in Effect-TS/effect@c3c7647 or anomalyco/opencode@550d1ff — proposed from the API sweep; both reference codebases are expert-written, so absence there is weak negative signal.

Mined from a per-export sweep of the Effect module (v4): for each exported function, asking what manual pattern it replaces and whether that pattern is statically detectable; grounded against Effect-TS/effect and anomalyco/opencode; deduplicated against implemented tsgo diagnostics and prior rule-proposal issues.

Proposed rule name

sleepZeroToYieldNow

Incremental true-positive recount: T3 Code

Reviewed pingdotgg/t3code at 01e05c15268d on 2026-09-14. Scope: tracked first-party TypeScript/JavaScript, including authored tests unless excluded by this proposal; vendored .repos, generated files, dependencies, build output and documentation examples excluded.

  • New T3 Code matches: 0. Counts refer to vetted diagnostic source sites, not observed production failures.
  • Previous reviewed count bucket: value:tp-0.
  • Confirmed aggregate minimum: 0; label: value:tp-0. Previously vetted sites remain included; this pass only adds T3 Code.

Review notes. All first-party Effect.sleep arguments were reviewed: nonzero literals or runtime durations; no provably-zero literal/Duration.zero/zero constructor.

No new source location met the reviewed trigger and exclusions. Uncertain and version-inapplicable candidates were not added to the count.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    value:tp-0Rule: 0 vetted true positives in checked repositories

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions