Skip to content

Rule proposal: chained Context.add/merge calls clone the backing map on every step instead of batching with Context.mutate #443

Description

@mattiamanzati

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions