Skip to content

Rule proposal: Effect.flatMap nesting an independent Effect.map re-implements Effect.zipWith #656

Description

@mattiamanzati

Problem

A recurring way to combine the results of two effects is to nest one inside the other: Effect.flatMap(first, (a) => Effect.map(second, (b) => combine(a, b))). Users write this because it is the mechanical monadic composition — sequence the first effect, then map over the second — and it compiles cleanly and behaves correctly. But when the second effect does not depend on the first result, the nesting is pure noise: Effect v4 ships Effect.zipWith(self, that, f), which runs the two effects sequentially (same semantics as the nested form) and combines their results with a single binary function. The rewrite flattens one level of callback nesting, names the intent directly ("combine two independent effects"), and — when the combiner is just the pair [a, b] — collapses further to Effect.zip.

The key subtlety is independence: if the inner effect is derived from the outer callback parameter (e.g. Effect.flatMap(getUser, (user) => Effect.map(loadProfile(user.id), ...))), zipWith is not applicable and the nested form is the correct idiom. The rule therefore only fires when the second effect expression makes no reference to the first result.

Bad — compiles cleanly, the rule should flag this

// RULE: flatMapMapToZipWith
// BAD: the inner Effect.map runs an effect that is completely independent of
// `user`, so the nesting is just manual plumbing for "run both, combine the
// results" — exactly what Effect.zipWith expresses in one call, with the same
// sequential semantics and one less level of callbacks.
import { Effect } from "effect"

declare const getUser: Effect.Effect<{ readonly name: string }>
declare const getConfig: Effect.Effect<{ readonly locale: string }>

const greeting = Effect.flatMap(getUser, (user) =>
  Effect.map(getConfig, (config) => `${user.name} (${config.locale})`)
)
void greeting

Good

// RULE: flatMapMapToZipWith
// GOOD: Effect.zipWith runs getUser then getConfig sequentially (identical
// semantics to the nested form) and combines both results in a single binary
// function — flatter, and the independence of the two effects is explicit.
import { Effect } from "effect"

declare const getUser: Effect.Effect<{ readonly name: string }>
declare const getConfig: Effect.Effect<{ readonly locale: string }>

const greeting = Effect.zipWith(
  getUser,
  getConfig,
  (user, config) => `${user.name} (${config.locale})`
)
void greeting

Proposed rule behavior

  • Match Effect.flatMap(self, (a) => <inner>) in both data-first and pipe/curried form, where <inner> is Effect.map(that, (b) => expr) or that.pipe(Effect.map((b) => expr)).
  • Use the type checker to confirm both self and that are Effect types (not Option/Either/Stream variants of the same combinator names).
  • Independence guard (the load-bearing exclusion): walk the that expression and require that no identifier inside it resolves to the outer callback parameter a (or to any binding destructured from it). If the inner effect depends on the outer result — e.g. Effect.flatMap((response) => Effect.map(decodeResponse(response), ...)) in packages/ai/openai-compat/src/OpenAiClient.ts, or Effect.flatMap(FileSystem.FileSystem, (fs) => Effect.map(fs.stat(path), ...)) in packages/effect/src/unstable/http/HttpBody.tszipWith is not applicable and the rule must stay silent.
  • If expr is exactly the tuple [a, b] of the two callback parameters, suggest Effect.zip(self, that) instead of zipWith.
  • Report on the flatMap call with a fix that merges the two unary callbacks into one binary combiner: Effect.zipWith(self, that, (a, b) => expr).
  • Do not fire when the outer callback body contains statements beyond the single returned Effect.map(...) expression (side effects or conditionals between the two effects change the shape).

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. Every nested flatMap+map pair found in those repos had the inner effect depending on the outer result, which the independence guard correctly excludes — evidence the guard keeps false positives at zero.

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

flatMapMapToZipWith

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions