---
url: /unthrown/explanation/the-defect-channel.md
---
# The Defect Channel

> **Explanation.** This page explains the idea that sets `unthrown` apart and
> the reasoning behind how a defect behaves. For the tasks it touches — producing
> a defect, recovering one — see [Qualify a boundary](../how-to/qualify-a-boundary)
> and the [combinator reference](../reference/combinators).

A **defect** is a failure you did not model — a thrown `TypeError`, an
un-triaged promise rejection, a bug in a callback. It is the third runtime state
of a `Result`, and it is **invisible to the type**: it never appears in `E`.

This is the idea that sets `unthrown` apart. A defect is a value (not a thrown
exception), so errors-as-values stays uniform — but it behaves very differently
from a modeled `Err`.

## Throw → defect

Any value thrown by a callback inside a combinator is **caught and converted to
a defect**, never allowed to escape:

```ts
const r = Ok(1).map(() => {
  throw new Error("boom");
});

r.isDefect(); // => true
```

This is what makes "no `try`/`catch` at the edge" real: a bug in a `.map`
becomes a defect, short-circuits the pipeline, and is handled once at `match`.

## A defect flows through almost everything

A defect passes through **every** method untouched — *except* `match`,
`recoverDefect`, and the observers `tapDefect` / `tapFailure` (which observe it
without consuming it). The success **and** error combinators never see it — even
when the error channel is fully typed:

```ts
import { Ok, type Result } from "unthrown";

// A pipeline whose error channel is modeled as "boom", but whose success
// callback throws — so at runtime `d` is a Defect. (`never` widens to "boom",
// so the annotation is a legal widening of `Ok(1).map(…)`.)
const d: Result<number, "boom"> = Ok(1).map((): number => {
  throw new Error("bug");
});

d.map((n) => n + 1); // still a Defect — the success callback is skipped
d.mapErrCases((m) => m.with("boom", () => "handled")); // still a Defect — the branch never runs
d.recoverErrCases((m) => m.with("boom", () => 0)); // still a Defect — see below
```

The matchers above are the real, exhaustive form — one branch per modeled error.
They simply never run here, because a defect is not an `Err`: the error
combinators only touch the `Err` channel, and a defect flows straight past them.

### No identity on the error channel

::: warning `mapErrCases((m) => m)` is not an identity
It can be tempting to write `mapErrCases((matcher) => matcher)` — returning the
matcher untouched — as a "do nothing" on the error channel. It is **not**. The
combinator terminates the builder with `.exhaustive()` for you, and a builder
with no `.with(…)` branch is only exhaustive when `E` is `never`. On any real
error channel it fails to compile; if it slips past the types (a cast), it
throws `NonExhaustiveError` at runtime, which becomes a `Defect`. There is no
"no-op" on the error channel — every branch of the union must be handled, which
is the whole point (see [Exhaustive error matching](./exhaustive-error-matching)).
To genuinely pass the error through unchanged, use an observer (`tapErrCases`) —
or re-emit each case by name (`.with(P.tag("NotFound"), (e) => e)…`).
:::

## `recoverErrCases` clears the error channel, not the runtime

`recoverErrCases` turns an `Err` into an `Ok`, so its type is `Result<T | U, never>`. But
`never` describes only the **error** channel — a defect can still be present at
runtime:

```ts
// the "boom" branch is what supplies the `U` in `Result<T | U, never>`
const recovered = d.recoverErrCases((matcher) =>
  matcher.with("boom", () => 99),
);
// type: Result<number, never>
recovered.isDefect(); // => true — `never` does NOT mean "total"
```

A defect is a bug; you should not be able to accidentally "recover" it into a
success. So the recovering eliminators **rethrow** on a defect:

```ts
d.getOr(0); // throws the original cause
d.getOrNull(); // throws the original cause
d.getOrElse(() => 0); // throws the original cause
```

They recover a modeled `Err`, never an unmodeled defect.

::: warning `getOr` / `getOrNull` still throw on a defect
It's tempting to read `getOr(0)` as "always give me a value." It doesn't — it
supplies the fallback for a modeled `Err` but **rethrows a defect** (a bug is not
an absent value). If you must not throw, handle the defect explicitly first with
`match` or `recoverDefect`.
:::

## `get` is asymmetric

`get()` / `getErr()` are **type-gated**: `get()` only compiles when the
error channel is `never` (`Result<T, never>`), `getErr()` only when the success
channel is `never` (`Result<never, E>`). Calling `.get()` on a still-fallible
`Result<T, E>` is a compile error, not a runtime throw — so the `Err` case can't
reach either eliminator in well-typed code. The remaining wrong-variant throw
(`GetError`) is a defensive runtime guard for unsound edges (e.g. a cast), not
something you should hit normally.

A `Defect`, though, is invisible to the type system — `never` on the error
channel says nothing about it (see above). So on a `Defect`, `get()` /
`getErr()` **rethrow the original cause** with its original stack — so an
unhandled defect reaches the global handler looking like the real failure, not
wrapped in library noise.

```ts
try {
  recovered.get(); // compiles: the error channel is `never`
} catch (e) {
  // e is the very `Error("bug")` thrown in the `map` above — same instance,
  // original stack
}
```

## The only door: `recoverDefect`

When you genuinely need to handle a defect — say, to convert a third-party
library's thrown error back into a modeled one — use `recoverDefect`. It is the
only combinator that can *consume* a defect (the observers below only look at
it), and it re-enters the modeled world by returning a `Result`:

```ts
d.recoverDefect((cause) =>
  cause instanceof RangeError ? Err("out_of_range") : Err("unknown"),
);
```

Use `tapDefect` to observe a defect's cause (e.g. logging) without changing it.
When the same observation applies to a modeled `Err` too — one logger, one
rollback trigger for "it went KO" — `tapFailure` runs it on either failure,
passing the discriminated variant (`ErrView | DefectView`) so you can still
branch on `tag`. It observes without consuming; there is deliberately no
`recoverFailure` — recovering a defect stays a separate, loud `recoverDefect`.

Recovering a defect should feel awkward — usually you don't. You let it bubble
to the edge, log it, and return a 500.

## Producing a defect on purpose

Sometimes a condition is *anticipated* but still not a domain outcome — a
required config value is missing, a "can't happen" branch is reached. You want
the defect channel (the edge's 500), not an `Err` that every caller must
thread through `E`.

There is deliberately **no** `Defect(...)` constructor for this. The primitive
already exists — it is `throw`. The throw → defect rule at the top of this page
is not merely a safety net for accidental bugs; it is the *sanctioned syntax*
for declaring one:

```ts
// Inside a pipeline: just throw. The combinator converts it.
pipeline.tap(() => {
  if (!config.apiKey) throw new MissingConfigError();
});
```

And if you find yourself wanting to *mint* a defect mid-chain, the failure
usually happened somewhere earlier — put the boundary at its origin instead:

```ts
// An ordinary function that throws on a bug — perfectly idiomatic JS.
function requireConfig(key: string): string {
  const value = process.env[key];
  if (value === undefined) throw new MissingConfigError(key);
  return value;
}

// Wrapped ONCE at its boundary: every throw is a defect, by decision.
const readConfig = fromSafeThrowable(requireConfig);

readConfig("API_KEY").toAsync().flatMap(callTheApi);
```

For the residual case — you hold a cause in hand and need to *start* a chain in
defect state — the documented idiom is
`fromSafeThrowable(() => { throw cause })()` (add `.toAsync()` for an
`AsyncResult`).

::: info Why no constructor?
Once minting a defect is one frictionless call, "I don't feel like modeling
this error" starts flowing into the defect channel — and the discipline that
makes `E` trustworthy erodes. The friction is a forcing function: either model
the failure as an `Err`, or throw at its true origin. This was weighed and
decided in [#77](https://github.com/btravstack/unthrown/issues/77), and is
recorded among the [design decisions](./design-decisions).
:::

## Where to go next

* The rule that keeps defects *out* of `E`: [Qualification](./qualification).
* Why the error channel is matched exhaustively:
  [Exhaustive error matching](./exhaustive-error-matching).
* The per-combinator behavior grid: [Combinator reference](../reference/combinators).
