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.
Problem
Effect v4 introduced a family of "reason" combinators for tagged errors that wrap a nested tagged
reasonfield (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 itsTagsWithReason<E>constraint documents intent at the type level: it only accepts tags whose extracted error actually carries a taggedreasonfield, 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_reasondiagnostic 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
Good
Proposed rule behavior
Effect.catchTag(self, tag, handler)(data-first or pipeable) where the handler body is exactlyEffect.fail(p.reason)withpbeing the handler's parameter — either an arrow with expression body or a single-returnblock.tagfrom the effect's E and verify it has areasonproperty whose type is a union of tagged errors (every member has a literal_tag) — i.e. the tag satisfiesTagsWithReason<E>; only then suggestEffect.unwrapReason(tag).Effect.mapError(self, (e) => e.reason)where the checker shows the effect's full error channel E is a single tagged error whosereasonfield is itself a tagged error or union of tagged errors — suggestEffect.unwrapReason("<thatTag>").Effect.fail(x.reason)outside a catchTag/mapError callback, or when.reasonis not a tagged-error field — e.g.Effect.fail(input.signal.reason ?? ...)readingAbortSignal.reason(a real near-miss found in opencode).catchTag/mapErrorcall with a directEffect.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
manualReasonRefailToUnwrapReasonIncremental true-positive recount: T3 Code
Reviewed pingdotgg/t3code at
01e05c15268don 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.value:tp-0.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.