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.
Problem
When a piece of code needs direct access to the
Scopeit 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 inEffect.scopedand then pull the scope back out as the very first step:const scope = yield* Effect.scopeinside anEffect.gen, orEffect.flatMap(Effect.scope, f). This works and compiles cleanly, but it is the exact manual expansion ofEffect.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 aScope, then immediately ask the environment for it), turns an implicit dataflow into an explicit function parameter, and drops one statement from the body.scopedWithis 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
Good
Proposed rule behavior
Effect.scoped(symbol resolved against theeffectpackage) in both forms: the direct callEffect.scoped(body)and the pipe suffixbody.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.Effect.genwhose first statement isconst s = yield* Effect.scope— the same first-statement AST shape test used by the existingeffect_do_notation/nested_effect_genrules, withEffect.scope's symbol resolved against the package.Effect.scoped(Effect.flatMap(Effect.scope, f))andEffect.scoped(Effect.scope.pipe(Effect.flatMap(f))).Effect.scopeappears later in the wrapped body (single retrieval), so the fix is a pure parameterization: delete the retrieval statement and swapEffect.scopedforEffect.scopedWith((scope) => ...).const scope = yield* Effect.scopeinside bodies that are not directly wrapped byEffect.scoped(layer constructors,acquireReleasebodies,fnUntracedimplementations, etc.) — there theScopecomes from an outer provider andscopedWithwould wrongly introduce a fresh scope. The single-wrap precondition correctly excludes the ~20 such sites in the effect monorepo.Where this came up
Effect.scope, single retrieval, gen closed with.pipe(Effect.scoped)at line 549: the exact manual spelling ofEffect.scopedWith((scope) => Effect.gen(...)).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
scopedImmediateScopeToScopedWithIncremental true-positive recount: T3 Code
Reviewed pingdotgg/t3code at
01e05c15268don 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.value:tp-2-4.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.