Problem
Detects throw statements (or calls to obviously-throwing helpers like JSON.parse) inside the callback of Effect.map, Effect.tap, Effect.andThen-with-plain-value and similar pure-mapping combinators. The map callback is a pure value transformation with no error channel, so nothing thrown there can appear in E; the throw escapes the typed error model and surfaces as a defect. The correct idiom is Effect.flatMap (or Effect.andThen returning an Effect) with Effect.fail / Effect.try so the failure is typed. Distinct from proposal #406, which covers throws inside the catch mapper of Effect.try.
Why the compiler is silent / what breaks at runtime: The code type-checks with E unchanged (often never), but at runtime the thrown value bypasses the error channel and kills the fiber as a defect: Effect.catchTag/catchAll handlers downstream never fire, and the ValidationError arrives as an unrecoverable die instead of a recoverable typed failure.
Both examples below type-check with zero errors against effect@4.0.0-beta.104 (re-verified) under the monorepo's strict tsconfig, verified with an isolated per-proposal tsconfig — so the compiler offers no protection here and a diagnostic is the only static safety net.
Bad — compiles cleanly, the rule should flag this
// RULE: throw-in-effect-map-callback
// BAD: Effect.map / Effect.tap callbacks are pure value transformations with
// no error channel. A `throw` (or an obviously-throwing helper like
// JSON.parse) inside them never appears in E — the compiler keeps E = never,
// but at runtime the thrown value bypasses the typed error model and kills
// the fiber as an unrecoverable defect. Downstream catchTag/catchAll never
// fire.
import { Data, Effect } from "effect"
class ValidationError extends Data.TaggedError("ValidationError")<{
readonly value: number
}> {}
const fetchQuantity: Effect.Effect<number> = Effect.succeed(-1)
// throw inside Effect.map: type is Effect<number, never> — ValidationError
// is invisible to the type system and surfaces as a defect (die).
const validated = fetchQuantity.pipe(
Effect.map((n) => {
if (n < 0) {
throw new ValidationError({ value: n })
}
return n * 2
})
)
void validated
// JSON.parse throws SyntaxError on malformed input — again a defect, and E
// stays never, so no handler can ever recover it.
const config = Effect.succeed("{not json").pipe(
Effect.map((raw) => JSON.parse(raw) as { readonly port: number })
)
void config
// Effect.tap callback must return an Effect, but a throw on the way there
// still escapes as a defect instead of a typed failure.
const audited = fetchQuantity.pipe(
Effect.tap((n) => {
if (n < 0) {
throw new ValidationError({ value: n })
}
return Effect.log(`quantity: ${n}`)
})
)
void audited
Good
// RULE: throw-in-effect-map-callback
// GOOD: When a mapping step can fail, switch to Effect.flatMap and return
// Effect.fail (or wrap the throwing helper in Effect.try) so the failure
// lands in the typed error channel E. Downstream Effect.catchTag/catchAll
// can then recover it as a normal, typed failure.
import { Data, Effect } from "effect"
class ValidationError extends Data.TaggedError("ValidationError")<{
readonly value: number
}> {}
class ConfigParseError extends Data.TaggedError("ConfigParseError")<{
readonly cause: unknown
}> {}
const fetchQuantity: Effect.Effect<number> = Effect.succeed(-1)
// Effect.flatMap + Effect.fail: type is Effect<number, ValidationError>,
// and the failure is recoverable.
const validated = fetchQuantity.pipe(
Effect.flatMap((n) =>
n < 0
? Effect.fail(new ValidationError({ value: n }))
: Effect.succeed(n * 2)
),
Effect.catchTag("ValidationError", () => Effect.succeed(0))
)
void validated
// Throwing helpers belong inside Effect.try, which routes the throw into E.
const config = Effect.succeed("{not json").pipe(
Effect.flatMap((raw) =>
Effect.try({
try: () => JSON.parse(raw) as { readonly port: number },
catch: (cause) => new ConfigParseError({ cause })
})
)
)
void config
// A tap step that can fail should return the failure as an Effect, keeping
// E = ValidationError instead of dying with a defect.
const audited = fetchQuantity.pipe(
Effect.tap((n) =>
n < 0
? Effect.fail(new ValidationError({ value: n }))
: Effect.log(`quantity: ${n}`)
)
)
void audited
Where this came up
Mined from the Effect Office Hours playlist; deduplicated against all implemented tsgo diagnostics and prior rule-proposal issues.
Proposed rule name
throwInEffectMapCallback
Problem
Detects throw statements (or calls to obviously-throwing helpers like JSON.parse) inside the callback of Effect.map, Effect.tap, Effect.andThen-with-plain-value and similar pure-mapping combinators. The map callback is a pure value transformation with no error channel, so nothing thrown there can appear in E; the throw escapes the typed error model and surfaces as a defect. The correct idiom is Effect.flatMap (or Effect.andThen returning an Effect) with Effect.fail / Effect.try so the failure is typed. Distinct from proposal #406, which covers throws inside the catch mapper of Effect.try.
Why the compiler is silent / what breaks at runtime: The code type-checks with E unchanged (often never), but at runtime the thrown value bypasses the error channel and kills the fiber as a defect: Effect.catchTag/catchAll handlers downstream never fire, and the ValidationError arrives as an unrecoverable die instead of a recoverable typed failure.
Both examples below type-check with zero errors against
effect@4.0.0-beta.104(re-verified) under the monorepo's strict tsconfig, verified with an isolated per-proposal tsconfig — so the compiler offers no protection here and a diagnostic is the only static safety net.Bad — compiles cleanly, the rule should flag this
Good
Where this came up
Mined from the Effect Office Hours playlist; deduplicated against all implemented tsgo diagnostics and prior rule-proposal issues.
Proposed rule name
throwInEffectMapCallback