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.ts — zipWith 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
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 shipsEffect.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 toEffect.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), ...))),zipWithis 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
Good
Proposed rule behavior
Effect.flatMap(self, (a) => <inner>)in both data-first and pipe/curried form, where<inner>isEffect.map(that, (b) => expr)orthat.pipe(Effect.map((b) => expr)).selfandthatareEffecttypes (not Option/Either/Stream variants of the same combinator names).thatexpression and require that no identifier inside it resolves to the outer callback parametera(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), ...))inpackages/ai/openai-compat/src/OpenAiClient.ts, orEffect.flatMap(FileSystem.FileSystem, (fs) => Effect.map(fs.stat(path), ...))inpackages/effect/src/unstable/http/HttpBody.ts—zipWithis not applicable and the rule must stay silent.expris exactly the tuple[a, b]of the two callback parameters, suggestEffect.zip(self, that)instead ofzipWith.flatMapcall with a fix that merges the two unary callbacks into one binary combiner:Effect.zipWith(self, that, (a, b) => expr).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