Skip to content

Rule proposal: throw inside Effect.map/Effect.tap callbacks becomes an untyped defect, not a typed failure #489

Description

@mattiamanzati

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

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