Problem
When users compose a service map by hand (custom runtimes, per-request context enrichment), the natural way to add several services is to chain Context.add / Context.merge calls — data-first nesting, .pipe(...), or a mix of both. Each of these calls is copy-on-write: it clones the entire backing Map before inserting one entry, so N chained mutations allocate N intermediate maps that are immediately discarded.
TypeScript is completely happy with this — the types compose correctly and the result value is identical — so the cost is invisible at compile time and at review time. The Effect team considered this enough of a footgun to ship a dedicated batching API and immediately migrate their own code off the chained form:
6d9393a07 — add ServiceMap.mutate, a mutable-batch API with a mutable flag consumed by add/merge
4605db69c — EFF-706: refactor multiple ServiceMap mutations to ServiceMap.mutate (converts 5 in-repo modules)
Verified against effect@4.0.0-beta.101 with TypeScript 7.0.2 --strict. Naming note: in this beta the module is exported as Context (formerly ServiceMap), with the same surface — add, addOrOmit, merge, and mutate, whose docs explicitly state it avoids unnecessary map copying. The rule must resolve the Context module in current v4 betas.
Reproduction
import { Context, Tracer } from "effect"
interface Connection {
readonly query: (sql: string) => string
}
declare const conn: Connection
declare const id: string
declare const span: Tracer.AnySpan
declare const base: Context.Context<never>
const TxService = Context.Service<readonly [Connection, string]>("TxService")
// Each .add clones the whole backing Map before inserting one entry
const services = Context.add(base, TxService, [conn, id]).pipe(
Context.add(Tracer.ParentSpan, span)
)
// What the rule should suggest instead: one clone, mutations batched
const batched = Context.mutate(base, (ctx) =>
Context.add(Context.add(ctx, TxService, [conn, id]), Tracer.ParentSpan, span)
)
tsc --strict reports nothing (exit 0) on the chained form — and on every natural variant of it (2+ curried stages in one pipe, nested data-first self arguments, add flowing into merge). At runtime, inspection of Context's internals confirms each chained add allocates a fresh Map, while adds inside Context.mutate share a single mutable map and produce an identical result.
Proposed rule behavior
Report when two or more calls to Context.add / Context.addOrOmit / Context.merge (resolved to the effect Context module, née ServiceMap) chain over one value within a single expression:
- nested data-first calls where the result of one mutation is the
self argument of another,
- two or more curried mutation stages in a single
.pipe(...) / pipe(...) chain,
- a mutation result flowing directly into another mutation (e.g.
Context.merge(Context.add(base, ...), other)).
Anchor the diagnostic on the outermost mutation call of the chain. Suggested message:
Chained Context.add/merge calls copy the whole service map on every step. Batch these mutations with Context.mutate to clone it once.
Category: efficiency (suggestion severity). A code fix can wrap the chain in Context.mutate(base, (ctx) => ...), rebasing the chain onto the callback parameter.
Must NOT flag:
- a single mutation call (no chain, nothing to batch),
- chains lexically inside a
Context.mutate callback — those already run against the mutable map,
- pipes mixing in non-mutation combinators such that fewer than two mutation stages are adjacent on the same value.
This complements the existing pipe hygiene rules (unnecessaryPipe, unnecessaryPipeChain, missedPipeableOpportunity): those reason about pipe shape, not about which API is being chained, so there is no overlap — this rule fires only on Context mutation calls and stays silent on pipe-shape concerns.
Proposed rule name
chainedContextMutations — names the exact pattern being flagged, matching descriptive names like multipleEffectProvide and nestedEffectGenYield
preferContextMutate — follows the prefer* convention and states the fix rather than the problem
contextMutateOpportunity — follows missedPipeableOpportunity / effectFnOpportunity for suggestion-grade rules
Problem
When users compose a service map by hand (custom runtimes, per-request context enrichment), the natural way to add several services is to chain
Context.add/Context.mergecalls — data-first nesting,.pipe(...), or a mix of both. Each of these calls is copy-on-write: it clones the entire backingMapbefore inserting one entry, so N chained mutations allocate N intermediate maps that are immediately discarded.TypeScript is completely happy with this — the types compose correctly and the result value is identical — so the cost is invisible at compile time and at review time. The Effect team considered this enough of a footgun to ship a dedicated batching API and immediately migrate their own code off the chained form:
6d9393a07— addServiceMap.mutate, a mutable-batch API with amutableflag consumed byadd/merge4605db69c— EFF-706: refactor multiple ServiceMap mutations toServiceMap.mutate(converts 5 in-repo modules)Verified against effect@4.0.0-beta.101 with TypeScript 7.0.2
--strict. Naming note: in this beta the module is exported asContext(formerlyServiceMap), with the same surface —add,addOrOmit,merge, andmutate, whose docs explicitly state it avoids unnecessary map copying. The rule must resolve theContextmodule in current v4 betas.Reproduction
tsc --strictreports nothing (exit 0) on the chained form — and on every natural variant of it (2+ curried stages in one pipe, nested data-first self arguments,addflowing intomerge). At runtime, inspection ofContext's internals confirms each chainedaddallocates a freshMap, while adds insideContext.mutateshare a single mutable map and produce an identical result.Proposed rule behavior
Report when two or more calls to
Context.add/Context.addOrOmit/Context.merge(resolved to theeffectContextmodule, néeServiceMap) chain over one value within a single expression:selfargument of another,.pipe(...)/pipe(...)chain,Context.merge(Context.add(base, ...), other)).Anchor the diagnostic on the outermost mutation call of the chain. Suggested message:
Category: efficiency (suggestion severity). A code fix can wrap the chain in
Context.mutate(base, (ctx) => ...), rebasing the chain onto the callback parameter.Must NOT flag:
Context.mutatecallback — those already run against the mutable map,This complements the existing pipe hygiene rules (
unnecessaryPipe,unnecessaryPipeChain,missedPipeableOpportunity): those reason about pipe shape, not about which API is being chained, so there is no overlap — this rule fires only onContextmutation calls and stays silent on pipe-shape concerns.Proposed rule name
chainedContextMutations— names the exact pattern being flagged, matching descriptive names likemultipleEffectProvideandnestedEffectGenYieldpreferContextMutate— follows theprefer*convention and states the fix rather than the problemcontextMutateOpportunity— followsmissedPipeableOpportunity/effectFnOpportunityfor suggestion-grade rules