Skip to content

Rule proposal: catching TimeoutError right after Effect.timeout re-implements Effect.timeoutOrElse #655

Description

@mattiamanzati

Problem

Detects Effect.catchTag("TimeoutError", ...) applied directly to the result of Effect.timeout. The two-step recipe is the natural thing to write — Effect.timeout adds Cause.TimeoutError to the error channel, and the very next pipe step catches exactly that tag to substitute a fallback — but Effect v4 ships single combinators for precisely this composition: Effect.timeoutOrElse({ duration, orElse }) for a fallback effect, and Effect.timeoutOption(duration) for the "wrap success in Some, return None on timeout" shape (often hand-rolled as Effect.timeout + Effect.asSome + Effect.catchTag("TimeoutError", () => Effect.succeedNone)).

Beyond verbosity there is a real semantic hazard: catchTag matches by tag, not by origin. If the pre-timeout effect can itself fail with a TimeoutError (a nested Effect.timeout deeper in the call chain, an HTTP client that times out internally), the manual catch silently swallows those inner failures too. Effect.timeoutOrElse runs orElse only when its own timeout wins, and Effect.timeoutOption explicitly preserves inner failures while representing only the timeout as Option.none(). The dedicated APIs are both shorter and more precise about which failure they handle — and they never widen or disturb the rest of the error channel the way a mis-scoped catchTag can.

Both examples below compile cleanly — the manual recipe type-checks and usually behaves identically, so this is a prefer-the-dedicated-API diagnostic with a semantic edge case (inner TimeoutError collision) where the rewrite is strictly safer.

Bad — compiles cleanly, the rule should flag this

// RULE: timeoutCatchTagToTimeoutOrElse
// BAD: Effect.timeout introduces Cause.TimeoutError, and the immediately
// following catchTag("TimeoutError") exists only to handle it — two pipe
// steps re-implementing Effect.timeoutOrElse. Worse, catchTag matches by
// tag, so a TimeoutError raised *inside* fetchQuote (e.g. a nested timeout)
// would be silently swallowed by the same handler.
import { Effect } from "effect"

declare const fetchQuote: Effect.Effect<string>
declare const cachedQuote: string

const quote = fetchQuote.pipe(
  Effect.timeout("5 seconds"),
  Effect.catchTag("TimeoutError", () => Effect.succeed(cachedQuote))
)
void quote

// The Option-shaped variant: wrap success in Some, map the timeout to None —
// a hand-rolled Effect.timeoutOption.
const maybeQuote = fetchQuote.pipe(
  Effect.timeout("5 seconds"),
  Effect.asSome,
  Effect.catchTag("TimeoutError", () => Effect.succeedNone)
)
void maybeQuote

Good

// RULE: timeoutCatchTagToTimeoutOrElse
// GOOD: Effect.timeoutOrElse expresses timeout-with-fallback in one step and
// runs orElse only when its own timeout wins — inner TimeoutErrors from
// fetchQuote still propagate as failures instead of being swallowed.
import { Effect } from "effect"

declare const fetchQuote: Effect.Effect<string>
declare const cachedQuote: string

const quote = fetchQuote.pipe(
  Effect.timeoutOrElse({
    duration: "5 seconds",
    orElse: () => Effect.succeed(cachedQuote)
  })
)
void quote

// Effect.timeoutOption is the one-step form of the Some/None recipe: success
// becomes Option.some(value), only the timeout becomes Option.none(), and
// pre-existing failures are preserved.
const maybeQuote = fetchQuote.pipe(Effect.timeoutOption("5 seconds"))
void maybeQuote

Proposed rule behavior

  • Match a call resolving to Effect.catchTag whose tag argument is the string literal "TimeoutError" and whose self argument (the previous pipe step, or the first argument in data-first form) is directly a call resolving to Effect.timeout.
  • For the timeoutOption shape, also tolerate a single interposed Effect.asSome (or Effect.map(Option.some)) between the timeout and the catchTag: when the handler resolves to Effect.succeedNone (or Effect.succeed(Option.none())), suggest Effect.timeoutOption(duration) instead.
  • Otherwise suggest Effect.timeoutOrElse({ duration, orElse: <handler> }), lifting the catchTag handler into orElse (the handler must ignore its error parameter or the fix becomes suggestion-only, since orElse is a LazyArg and receives no error).
  • Use the checker to confirm the pre-timeout effect's error channel does not already contain Cause.TimeoutError; if it does, the manual catchTag intentionally (or accidentally) catches inner timeouts too, so the rewrite would change behavior — either skip, or downgrade to an informational note about the tag collision.
  • Require direct adjacency (modulo the asSome allowance above): a catchTag("TimeoutError", ...) several unrelated steps after a timeout is not flagged, keeping false positives near zero.
  • Do not flag catchTag with other tags, catchTags objects that handle TimeoutError alongside other tags, or handlers that inspect the error value.

Where this came up

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

timeoutCatchTagToTimeoutOrElse

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    value:tp-2-4Rule: 2-4 vetted true positives in checked repositories

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions