Skip to content

Rule proposal: re-failing with e.reason in catchTag/mapError re-implements Effect.unwrapReason #660

Description

@mattiamanzati

Problem

Effect v4 introduced a family of "reason" combinators for tagged errors that wrap a nested tagged reason field (the shape used by AiError-style wrapper errors). When users want to strip the wrapper and expose the inner reasons directly in the error channel, they hand-roll it: Effect.catchTag("AiError", (e) => Effect.fail(e.reason)), or — when the wrapper is the only error in E — Effect.mapError((e) => e.reason). Both compile cleanly and behave correctly; the resulting E is the union of reason tags.

Effect.unwrapReason("AiError") is the dedicated API for exactly this promotion. It replaces the callback with a single named operation, and its TagsWithReason<E> constraint documents intent at the type level: it only accepts tags whose extracted error actually carries a tagged reason field, so the rewrite is a pure readability/precision win with identical semantics (Effect<A, ExcludeTag<E, K> | ReasonOf<ExtractTag<E, K>>, R> — the same type the manual version produces).

This complements the already-implemented catch_tag_to_catch_reason diagnostic and Related: #407 — those target handling one reason tag with a recovery effect; this rule targets promoting all reasons into the error channel by re-failing with them.

Bad — compiles cleanly, the rule should flag this

// RULE: manualReasonRefailToUnwrapReason
// BAD: catchTag whose handler only re-fails with the error's tagged `reason`
// field re-implements Effect.unwrapReason by hand. Same for mapError when the
// wrapper is the whole error channel. The named combinator states the intent
// (promote nested reasons into E) and its TagsWithReason constraint verifies
// the tag actually carries tagged reasons.
import { Data, Effect } from "effect"

class RateLimitError extends Data.TaggedError("RateLimitError")<{
  readonly retryAfter: number
}> {}

class QuotaExceededError extends Data.TaggedError("QuotaExceededError")<{
  readonly limit: number
}> {}

class AiError extends Data.TaggedError("AiError")<{
  readonly reason: RateLimitError | QuotaExceededError
}> {}

const generateText: Effect.Effect<string, AiError> = Effect.fail(
  new AiError({ reason: new RateLimitError({ retryAfter: 30 }) })
)

// Manual unwrap via catchTag + Effect.fail(e.reason):
// Effect<string, RateLimitError | QuotaExceededError>
const unwrapped = generateText.pipe(
  Effect.catchTag("AiError", (e) => Effect.fail(e.reason))
)
void unwrapped

// Manual unwrap via mapError when AiError is the entire error channel:
const unwrappedToo = generateText.pipe(
  Effect.mapError((e) => e.reason)
)
void unwrappedToo

Good

// RULE: manualReasonRefailToUnwrapReason
// GOOD: Effect.unwrapReason promotes the nested reason errors into the error
// channel in one named step, producing the exact same
// Effect<string, RateLimitError | QuotaExceededError> type.
import { Data, Effect } from "effect"

class RateLimitError extends Data.TaggedError("RateLimitError")<{
  readonly retryAfter: number
}> {}

class QuotaExceededError extends Data.TaggedError("QuotaExceededError")<{
  readonly limit: number
}> {}

class AiError extends Data.TaggedError("AiError")<{
  readonly reason: RateLimitError | QuotaExceededError
}> {}

const generateText: Effect.Effect<string, AiError> = Effect.fail(
  new AiError({ reason: new RateLimitError({ retryAfter: 30 }) })
)

// Effect<string, RateLimitError | QuotaExceededError>
const unwrapped = generateText.pipe(Effect.unwrapReason("AiError"))
void unwrapped

Proposed rule behavior

  • Shape 1: Effect.catchTag(self, tag, handler) (data-first or pipeable) where the handler body is exactly Effect.fail(p.reason) with p being the handler's parameter — either an arrow with expression body or a single-return block.
  • For shape 1, use the checker to extract the error type selected by tag from the effect's E and verify it has a reason property whose type is a union of tagged errors (every member has a literal _tag) — i.e. the tag satisfies TagsWithReason<E>; only then suggest Effect.unwrapReason(tag).
  • Shape 2: Effect.mapError(self, (e) => e.reason) where the checker shows the effect's full error channel E is a single tagged error whose reason field is itself a tagged error or union of tagged errors — suggest Effect.unwrapReason("<thatTag>").
  • Do not fire on bare Effect.fail(x.reason) outside a catchTag/mapError callback, or when .reason is not a tagged-error field — e.g. Effect.fail(input.signal.reason ?? ...) reading AbortSignal.reason (a real near-miss found in opencode).
  • Do not fire when the handler does anything beyond re-failing with the reason (logging, transforming the reason, failing with a different value), or when the mapError callback's parameter type is a union of wrapper tags (unwrapReason takes one tag).
  • Fix: replace the catchTag/mapError call with a direct Effect.unwrapReason(tag) call, preserving pipe/data-first style.

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.

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

manualReasonRefailToUnwrapReason

Incremental true-positive recount: T3 Code

Reviewed pingdotgg/t3code at 01e05c15268d on 2026-09-14. Scope: tracked first-party TypeScript/JavaScript, including authored tests unless excluded by this proposal; vendored .repos, generated files, dependencies, build output and documentation examples excluded.

  • New T3 Code matches: 0. Counts refer to vetted diagnostic source sites, not observed production failures.
  • Previous reviewed count bucket: value:tp-0.
  • Confirmed aggregate minimum: 0; label: value:tp-0. Previously vetted sites remain included; this pass only adds T3 Code.

Review notes. Eight catchTag/mapError candidates mentioning .reason transform/wrap errors or inspect reason; none simply re-fail or map the callback parameter to its tagged reason.

No new source location met the reviewed trigger and exclusions. Uncertain and version-inapplicable candidates were not added to the count.

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-0Rule: 0 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