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
Problem
Detects
Effect.catchTag("TimeoutError", ...)applied directly to the result ofEffect.timeout. The two-step recipe is the natural thing to write —Effect.timeoutaddsCause.TimeoutErrorto 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, andEffect.timeoutOption(duration)for the "wrap success inSome, returnNoneon timeout" shape (often hand-rolled asEffect.timeout+Effect.asSome+Effect.catchTag("TimeoutError", () => Effect.succeedNone)).Beyond verbosity there is a real semantic hazard:
catchTagmatches by tag, not by origin. If the pre-timeout effect can itself fail with aTimeoutError(a nestedEffect.timeoutdeeper in the call chain, an HTTP client that times out internally), the manual catch silently swallows those inner failures too.Effect.timeoutOrElserunsorElseonly when its own timeout wins, andEffect.timeoutOptionexplicitly preserves inner failures while representing only the timeout asOption.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-scopedcatchTagcan.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
TimeoutErrorcollision) where the rewrite is strictly safer.Bad — compiles cleanly, the rule should flag this
Good
Proposed rule behavior
Effect.catchTagwhose 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 toEffect.timeout.timeoutOptionshape, also tolerate a single interposedEffect.asSome(orEffect.map(Option.some)) between thetimeoutand thecatchTag: when the handler resolves toEffect.succeedNone(orEffect.succeed(Option.none())), suggestEffect.timeoutOption(duration)instead.Effect.timeoutOrElse({ duration, orElse: <handler> }), lifting thecatchTaghandler intoorElse(the handler must ignore its error parameter or the fix becomes suggestion-only, sinceorElseis aLazyArgand receives no error).Cause.TimeoutError; if it does, the manualcatchTagintentionally (or accidentally) catches inner timeouts too, so the rewrite would change behavior — either skip, or downgrade to an informational note about the tag collision.asSomeallowance above): acatchTag("TimeoutError", ...)several unrelated steps after atimeoutis not flagged, keeping false positives near zero.catchTagwith other tags,catchTagsobjects that handleTimeoutErroralongside other tags, or handlers that inspect the error value.Where this came up
catchTag("TimeoutError", ...)directly adjacent toEffect.timeout(login.expiry)on a poll loop that cannot itself fail withTimeoutError; rewrites toEffect.timeoutOrElse({ duration: login.expiry, orElse: () => Effect.succeed(new PollExpired()) }).timeoutOptionshape:Effect.timeout(1)+Effect.asSome+catchTag("TimeoutError", () => Effect.succeedNone).timeoutOptionpattern duplicated in the platform-node test fixtures.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