Skip to content

Rule proposal: Effect.scoped around a body that immediately retrieves Effect.scope re-implements Effect.scopedWith #652

Description

@mattiamanzati

Problem

When a piece of code needs direct access to the Scope it runs in — to register finalizers manually, or to pass the scope to an API that takes one explicitly — a common spelling is to wrap the body in Effect.scoped and then pull the scope back out as the very first step: const scope = yield* Effect.scope inside an Effect.gen, or Effect.flatMap(Effect.scope, f). This works and compiles cleanly, but it is the exact manual expansion of Effect.scopedWith (available since 3.11): Effect.scopedWith((scope) => ...) creates the scope and hands it straight to the callback as a parameter. The rewrite removes the environment round-trip (provide a Scope, then immediately ask the environment for it), turns an implicit dataflow into an explicit function parameter, and drops one statement from the body. scopedWith is little-known, so codebases keep re-deriving it by hand — the rule points users at the API that already says what they mean.

Bad — compiles cleanly, the rule should flag this

// RULE: scopedImmediateScopeToScopedWith
// BAD: Effect.scoped provides a Scope to the environment, and the very first
// statement of the body immediately reads it back out with Effect.scope.
// This provide-then-retrieve round-trip is the manual expansion of
// Effect.scopedWith, which hands the freshly created scope to the callback
// directly as a parameter.
import { Effect, Scope } from "effect"

declare const openConnection: (scope: Scope.Scope) => Effect.Effect<string>

const program = Effect.gen(function*() {
  const scope = yield* Effect.scope
  const connection = yield* openConnection(scope)
  yield* Scope.addFinalizer(scope, Effect.log("closing"))
  return connection
}).pipe(Effect.scoped)

Good

// RULE: scopedImmediateScopeToScopedWith
// GOOD: Effect.scopedWith creates the scope and passes it straight into the
// callback — no environment round-trip, the scope is an ordinary explicit
// parameter, and the scoping boundary is stated once.
import { Effect, Scope } from "effect"

declare const openConnection: (scope: Scope.Scope) => Effect.Effect<string>

const program = Effect.scopedWith((scope) =>
  Effect.gen(function*() {
    const connection = yield* openConnection(scope)
    yield* Scope.addFinalizer(scope, Effect.log("closing"))
    return connection
  })
)

Proposed rule behavior

  • Find applications of Effect.scoped (symbol resolved against the effect package) in both forms: the direct call Effect.scoped(body) and the pipe suffix body.pipe(Effect.scoped) / pipe(body, Effect.scoped). All real-world hits found so far use the .pipe(Effect.scoped) suffix form, so covering it is essential.
  • Flag when the wrapped body is an Effect.gen whose first statement is const s = yield* Effect.scope — the same first-statement AST shape test used by the existing effect_do_notation / nested_effect_gen rules, with Effect.scope's symbol resolved against the package.
  • Also flag the combinator spellings of the same round-trip: Effect.scoped(Effect.flatMap(Effect.scope, f)) and Effect.scoped(Effect.scope.pipe(Effect.flatMap(f))).
  • Only fire when no other reference to Effect.scope appears later in the wrapped body (single retrieval), so the fix is a pure parameterization: delete the retrieval statement and swap Effect.scoped for Effect.scopedWith((scope) => ...).
  • Do not fire on const scope = yield* Effect.scope inside bodies that are not directly wrapped by Effect.scoped (layer constructors, acquireRelease bodies, fnUntraced implementations, etc.) — there the Scope comes from an outer provider and scopedWith would wrongly introduce a fresh scope. The single-wrap precondition correctly excludes the ~20 such sites in the effect monorepo.

Where this came up

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

scopedImmediateScopeToScopedWith

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-2-4.
  • Confirmed aggregate minimum: 2; label: value:tp-2-4. The earlier range is preserved; the label uses its conservative lower bound plus these new sites, not an invented exact historical total.

Review notes. Eight Effect.scope references reviewed. None is the first statement of a directly Effect.scoped-wrapped Effect.gen; constructor/Effect.fn scopes and non-first retrievals excluded.

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-2-4Rule: 2-4 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