Skip to content

Qualification at the boundary

Explanation. This page is about why every boundary forces a triage decision. For the concrete API — fromThrowable, fromPromise, fromNullable, and the fromSafe* pair — see Qualify a boundary.

The edges of your program — a throwing function, a rejecting promise, a nullable third-party API — are where untyped failure enters. Everything inside a Result pipeline is already triaged into Ok / Err / Defect; the boundary is the one place a raw unknown shows up. unthrown's position is that this is exactly where the decision must be forced: is this failure a modeled error or a defect?

The original sin: unknown in E

Most errors-as-values libraries spell their promise boundary like this:

ts
fromPromise(p): AsyncResult<T, unknown>;

That single unknown is the leak. It flows into your error type, and from there into every consumer. You reach for a match or a mapErrCases, discover the error is unknown, and now you either cast it (a lie the compiler can't check) or widen E to unknown all the way up. The type that was supposed to be a precise contract quietly became "something went wrong."

unthrown closes this by making qualify mandatory:

ts
fromPromise(p, (cause, defect) =>
  cause instanceof NotFoundError ? new NotFound() : defect(cause),
);

qualify receives the raw cause and a defect helper the boundary injects. Every branch must end in one of two places: a modeled error (which enters E) or defect(cause) (which does not). There is no third option, and there is no code path that yields unknown in E.

Exclude<R, Defect> — the defect arm is subtracted

The error channel is inferred as Exclude<R, Defect>, where R is qualify's return type. The Defect arm is subtracted, never inferred into E:

  • a qualify returning NotFound | Defect yields AsyncResult<T, NotFound>;
  • a qualify returning only defect(cause) yields AsyncResult<T, never> — not AsyncResult<T, Defect>.

This is what keeps the defect channel out-of-band. Modeled failures are in the type; the unmodeled ones are routed to the invisible third state. The subtraction is sound because Defect is unique symbol-branded — no domain error is ever assignable to it, so there is no way to accidentally smuggle a real error into the defect arm or vice versa.

qualify is synchronous — on purpose

qualify must be synchronous. Its return type intersects NotThenable, so an async qualify does not compile. This is not an arbitrary restriction: if qualify could be async, its returned Promise would land in Eun-triaged — the exact leak the boundary exists to prevent. A thenable that slips past the types at runtime is turned into a Defect (never Err(Promise)), and the orphaned thenable is adopted-and-silenced so a later rejection can't float unhandled.

The same rule is why an AsyncResult's combinator callbacks are synchronous — a raw Promise may never enter a combinator, because its rejection would silently become a defect and skip the triage. Async work re-enters only through fromPromise / fromSafePromise and composes via flatMap. That story has its own page: The async model.

When every failure is a bug: fromSafe*

Sometimes "everything here is a defect" is the correct, deliberate decision — a promise that should never fail in a modeled way, a function whose every throw is a bug. For those, fromSafePromise / fromSafeThrowable skip qualify entirely and give E = never; any failure becomes a defect.

These are escape hatches from qualification, not shortcuts. They are the named, explicit form of the (cause, defect) => defect(cause) you would otherwise write out — an on-the-record "I decided this is all defects", not a convenience that quietly drops the triage. Keep fromThrowable / fromPromise wherever some failures are genuinely anticipated.

The payoff

Because every boundary is qualified and every in-pipeline throw becomes a defect, the interior of your program never handles a raw unknown, and its edge needs no try/catch — just one exhaustive match that folds Ok / Err / Defect into a response. The forced triage at the boundary is what makes that single handler trustworthy: every failure has already been sorted into "modeled" or "bug" before it arrives.

Where to go next

Released under the MIT License.