Skip to content

Rule proposal: Error-wrapping handler drops the caught error instead of attaching it as cause #490

Description

@mattiamanzati

Problem

In Effect.mapError / Effect.catchAll / Effect.try / Effect.tryPromise handlers that construct a new tagged error, flags handlers whose caught-error parameter is never referenced in the constructor arguments. Wrapping an implementation-level failure (e.g. a Postgres driver error behind an abstract service error) without passing it as the cause property throws away the only diagnostic information about what actually failed.

Why the compiler is silent / what breaks at runtime: Type-checks identically, but at runtime the rendered failure and logs show only the abstract wrapper error; the concrete underlying error (SQL message, driver code) is silently lost, making production incidents undiagnosable.

Both examples below type-check with zero errors against effect@4.0.0-beta.101 (latest main of Effect-TS/effect) 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: wrapped-error-drops-cause
// BAD: These handlers wrap a lower-level failure in an abstract tagged error
// but never reference the caught error in the constructor arguments, so the
// concrete failure (SQL message, driver code, thrown exception) is thrown
// away. The wrapper type-checks identically, but at runtime the rendered
// failure shows only "database operation failed" — production incidents
// become undiagnosable. The linter flags handlers whose caught-error
// parameter never flows into the constructed error (e.g. as `cause`).
import { Data, Effect } from "effect"

class NoteError extends Data.TaggedError("NoteError")<{
  message: string
  cause?: unknown
}> {}

declare const runQuery: (sql: string) => Effect.Effect<ReadonlyArray<string>, Error>
declare const driverQuery: (sql: string) => Promise<ReadonlyArray<string>>

// flagged: mapError handler ignores the underlying driver error entirely
export const listNotes = runQuery("SELECT * FROM notes").pipe(
  Effect.mapError(() => new NoteError({ message: "database operation failed" }))
)

// flagged: the parameter is declared but never used in the new error
export const listNotesCatch = runQuery("SELECT * FROM notes").pipe(
  Effect.catch((_error) => Effect.fail(new NoteError({ message: "database operation failed" })))
)

// flagged: Effect.tryPromise catch drops the thrown value on the floor
export const listNotesTry = Effect.tryPromise({
  try: () => driverQuery("SELECT * FROM notes"),
  catch: () => new NoteError({ message: "database operation failed" })
})

void listNotes
void listNotesCatch
void listNotesTry

Good

// RULE: wrapped-error-drops-cause
// GOOD: When wrapping an implementation-level failure in an abstract tagged
// error, attach the caught error as the `cause` property. The abstract error
// still hides the driver from the public API, but the concrete failure is
// preserved and rendered as part of the failure/stack trace, keeping
// production incidents diagnosable.
import { Data, Effect } from "effect"

class NoteError extends Data.TaggedError("NoteError")<{
  message: string
  cause?: unknown
}> {}

declare const runQuery: (sql: string) => Effect.Effect<ReadonlyArray<string>, Error>
declare const driverQuery: (sql: string) => Promise<ReadonlyArray<string>>

// ok: the underlying error travels along as the cause
export const listNotes = runQuery("SELECT * FROM notes").pipe(
  Effect.mapError((cause) => new NoteError({ message: "database operation failed", cause }))
)

// ok: catch handler forwards the caught error as cause
export const listNotesCatch = runQuery("SELECT * FROM notes").pipe(
  Effect.catch((cause) => Effect.fail(new NoteError({ message: "database operation failed", cause })))
)

// ok: the thrown value from the promise is captured as cause
export const listNotesTry = Effect.tryPromise({
  try: () => driverQuery("SELECT * FROM notes"),
  catch: (cause) => new NoteError({ message: "database operation failed", cause })
})

void listNotes
void listNotesCatch
void listNotesTry

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

wrappedErrorDropsCause

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