feat(core)!: match the error channel exhaustively with ts-pattern - #113
Conversation
…take a per-tag triage object The error transformers no longer take a single callback: they take an ErrTriage object — one branch per error _tag, exhaustive at compile time, with a reserved Else branch as the explicit blanket escape hatch (and the only form for an untagged or mixed E). Observers (tapErr, flatTapErr, tapDefect, tapFailure) keep single callbacks: observing a union uniformly cannot strand a future tag; only consuming one can (Thesis #5). - outgoing types are the union of branch returns: a throwing branch (never) converts its tag to a Defect AND subtracts it from E - unmodeled tag at runtime (outside the typed contract) becomes a Defect carrying the error, mirroring matchTags; own-property lookup only - ResultMethods/AsyncResultMethods/Awaitable gain verified out-variance annotations; ErrTriage stays free of top-level conditionals so they verify; AsyncOkOf/AsyncErrOf now infer through the Awaitable channel - generic-H inference (TriageReturns/TriageKeysOk) instead of a shared R, which TS inference fixing would break on Else passthroughs - BREAKING: deprecated aliases orElse/recover removed (signatures broke anyway); unwrap-family aliases remain - docs: new 'Triaging the error channel' guide section; orpc examples, migration tables, CLAUDE.md Thesis #5 + invariants + internal design
There was a problem hiding this comment.
Pull request overview
This PR introduces a breaking-change update to unthrown’s core API by replacing the error-transformer callback form with a per-_tag triage object (ErrTriage) for mapErr, flatMapErr, and recoverErr, ensuring compile-time exhaustiveness for tagged error unions and making blanket handling explicit via Else.
Changes:
- Refactors
mapErr/flatMapErr/recoverErr(sync + async surfaces) to accept anErrTriageobject and adds supporting type machinery (TriageReturns,TriageKeysOk,NoThenables), plus updatedAsyncOkOf/AsyncErrOfinference. - Removes deprecated error-channel aliases
orElse/recover, and updates tests and type-level assertions accordingly. - Updates docs, guides, and oRPC examples/migration tables to reflect triage-based error transformations, and adds a major changeset entry.
Reviewed changes
Copilot reviewed 21 out of 21 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| packages/orpc/src/server.ts | Updates inline example to use mapErr({ Else: … }) triage form. |
| packages/orpc/src/extensions/result.ts | Updates extension example to use triage form. |
| packages/orpc/README.md | Updates README examples to use triage object (including multiline formatting). |
| packages/core/typedoc.json | Adds triage helper types to intentionallyNotExported for docs generation. |
| packages/core/src/types.ts | Adds ErrTriage + triage helper types; updates method surface signatures and variance annotations; updates async extractor inference. |
| packages/core/src/types.test-d.ts | Adds/updates type-level assertions for triage exhaustiveness, Else forms, and async-branch rejection. |
| packages/core/src/result.spec.ts | Adds runtime tests for triage dispatch semantics, Else fallback/precedence, and unhandled-tag behavior. |
| packages/core/src/invariants.spec.ts | Updates invariants to use triage form for error transformers. |
| packages/core/src/index.ts | Re-exports ErrTriage type from the public entry point. |
| packages/core/src/deprecated-aliases.spec.ts | Removes tests for deleted orElse/recover aliases; keeps extractor-alias tests. |
| packages/core/src/core.ts | Implements triage-based dispatch in Res and AsyncRes via triageHandlerFor. |
| packages/core/src/async-result.spec.ts | Updates async runtime tests to use triage form; keeps async composition coverage. |
| docs/guide/the-defect-channel.md | Updates examples to triage syntax for mapErr/recoverErr. |
| docs/guide/recipes.md | Updates recipe wording + example to use triage form. |
| docs/guide/orpc.md | Updates oRPC guide examples to triage form; updates a more complex mapping example to be per-tag. |
| docs/guide/migrating-from-neverthrow.md | Updates migration table entries for mapErr/orElse to triage-based equivalents. |
| docs/guide/from-try-catch.md | Updates mapping examples/table to triage-based mapErr({ Else: … }). |
| docs/guide/core-concepts.md | Clarifies that error transformers are triage-based and updates examples. |
| docs/guide/choosing-a-combinator.md | Adds a “Triaging the error channel” section and updates signatures/tables to reflect triage objects. |
| CLAUDE.md | Updates the project spec (Thesis #5) and invariants/internal design notes for triage. |
| .changeset/triage-error-channel.md | Adds a major changeset describing the breaking change and migration guidance. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…rvers take the partial triage Two refinements to the triage object, from review: - Transformer branches (mapErr/flatMapErr/recoverErr) receive the injected defect helper as their second argument — the same injection qualify gets at a boundary, and now the sanctioned deliberate Err→Defect form ((e, defect) => defect(e.cause)); the outgoing types subtract the Defect arm (Exclude<TriageReturns<H>, Defect>, the boundary inference). A throwing branch remains the safety net. This keeps deliberate defects lint-clean under a no-throw rule and scopes minting to triage decisions, preserving the #77 rejection of a public constructor. - tapErr/flatTapErr take the same triage object in its PARTIAL form (ErrTriagePartial: every branch optional, Else included) — an unobserved tag flows through, so per-tag observation needs no manual narrowing and uniform logging is { Else: log }. One object shape across the error channel: exhaustive when you consume, partial when you observe. Observer branches do NOT receive defect (their returns never replace the error; withholding the helper keeps the marker unconstructible there). tapDefect/tapFailure keep single callbacks (no tags to triage). - TriageKeysOk also rejects a bare function as the handlers argument — it would structurally satisfy the all-optional shape and silently observe nothing.
The triage object is now exhaustive, full stop — no fallthrough branch inside it. The explicit opt-out of exhaustiveness is the separate, greppable mergeTags(fn) wrapper (a unique-symbol-branded MergedTriage the error combinators accept in place of the per-tag object): .tapErr(mergeTags((err) => logger.error(err))) .mapErr(mergeTags((e, defect) => (isRetryable(e) ? e : defect(e)))) - Else undermined the purpose of the change: a fallthrough living inside the exhaustive object, silently absorbing future tags. mergeTags moves the uniform decision to a distinct call site that reads as an explicit opt-out and is auditable across a codebase. There is no partial-with-fallback middle ground anymore: a union that treats several tags identically writes each branch explicitly — a new tag is a compile error, never silently absorbed. - mergeTags is the only form for an untagged or mixed E (per-tag triage is impossible there), and the one-line migration of old callback call sites: mapErr(f) -> mapErr(mergeTags(f)). - Merged transformer handlers receive the injected defect helper (qualify-style triage over non-_tag unions); merged observers that produce a Defect are rejected at compile time (NoDefects), and a bare callback is rejected on all six methods (TriageKeysOk). - The merged overloads are declared BEFORE the object overloads — contextual inference of the merged callback's parameter depends on the order. - 'Else' is no longer a reserved key: an error tag literally named Else is an ordinary tag.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 22 out of 22 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (7)
packages/core/src/types.ts:354
- This section still references a missing
Elsebranch ("no branch and no Else"). The behavior is simply "no branch" (only reachable outside the typed contract), which becomes a Defect.
* An error whose tag has no branch and no `Else` (unreachable through
* well-typed code) becomes a `Defect` carrying it. An async branch is
* rejected at compile time ({@link NotThenable}).
packages/core/src/types.ts:359
- The
@paramdocs still mention using anElsebranch for blanket handling, but uniform handling is done viamergeTags(fn)rather than a reserved object key.
* @param handlers - the triage object: per-tag branches, each mapping its
* error to a new one (or `Else` for the deliberate blanket case).
packages/core/src/types.ts:375
- flatMapErr’s TSDoc repeats the idea of an
Elsefallthrough branch. The triage object is exhaustive; the explicit uniform form ismergeTags(fn).
* mirror of {@link ResultMethods.flatMap | flatMap}, **triaging the error by
* tag** ({@link ErrTriage}: exhaustive unless `Else` is present).
packages/core/src/types.ts:404
- recoverErr’s TSDoc still says the triage is "exhaustive unless Else is present", which doesn’t match the current API (no fallthrough key;
mergeTagsis the opt-out).
* Recover from an `Err` by producing a success value, emptying the error
* channel — **triaging the error by tag** ({@link ErrTriage}: exhaustive
* unless `Else` is present). Pairs with
* {@link ResultMethods.recoverDefect | recoverDefect}.
packages/core/src/types.ts:434
- tapErr’s TSDoc claims the partial triage includes an
Elsebranch and later shows{ Else: ... }as uniform observation. Uniform observation is viatapErr(mergeTags(fn));Elseis just an ordinary tag name if it exists in E.
* Run a side effect on the error — **observed by tag** through a *partial*
* triage object ({@link ErrTriagePartial}: every branch optional, `Else`
* included) — and pass the `Result` through unchanged.
packages/core/src/types.ts:438
- The uniform-observation example uses
{ Else: ... }, but the uniform form ismergeTags(fn)(and the per-tag partial object doesn’t have a reserved fallthrough key).
* Runs the matching branch only on `Err`; a tag without a branch is simply
* not observed. Uniform observation is `{ Else: (e) => log(e) }`. If a
* branch throws, the result is a `Defect` whose cause is an `AggregateError`
packages/core/src/core.ts:507
- triageHandlerFor can throw when
handlersisnull/undefined(and it’s called outside the surrounding try/catch). That would violate the library’s “throw → Defect” invariant for sync Results, and can also cause AsyncRes’s internal promise chain to reject (breaking the “AsyncResult promise never rejects” invariant). Adding a null/typeof guard here keeps invalid handler values contained as a Defect instead of escaping as a raw throw/rejection.
if (isMergedTriage(handlers)) {
// The explicit uniform form: one handler for every error, tagged or not.
return handlers.handler as (error: unknown, defect?: (cause: unknown) => Defect) => unknown;
}
const triage = handlers as Record<string, unknown>;
A transformer accepts {} only where the uncovered set is empty. New
type-level assertions in types.test-d.ts lock this against regression:
- coded.mapErr({}) on a code-discriminated (non-_tag) union — REJECTED
(the orpc shape: no _tag keys, forced to mergeTags)
- coded.mapErr({ NOT_FOUND, FORBIDDEN }) — REJECTED (triage keys off _tag)
- none.mapErr({}) on E = never — compiles (vacuously exhaustive)
- coded.tapErr({}) — compiles as an observer, but the error type is
UNCHANGED: observing zero cases does not consume the error
Documented as a load-bearing invariant in CLAUDE.md and a tip block in
the choosing-a-combinator guide: there is no path where a case slips past
a transformer uncovered without a compile error.
Replace the bespoke record/mergeTags triage with a ts-pattern match
builder. The error combinators (mapErr/flatMapErr/recoverErr/tapErr/
flatTapErr) now receive match(error) plus the injected defect helper and
return the un-terminated builder; the combinator calls .exhaustive()
itself, so a missing case does not compile — no .exhaustive() to forget,
no .otherwise() to smuggle in a fallback.
Why this is better than the record:
- matches ANY discriminant (_tag, code, structural, guards) and grouped
patterns — not just _tag; fixes the two limits raised in review
- P._ is the explicit catch-all (replaces the old single callback /
mergeTags), exhaustive and greppable
- one construct instead of {record + mergeTags}; smaller surface
Mechanics:
- ErrMatcher<E> = ReturnType<typeof match<E>>; the callback returns an
ExhaustiveMatch<O> whose .exhaustive is callable only when ts-pattern
narrowed the input to never. Outgoing E = Exclude<MatchOut<M>, Defect>
(a defect branch drops its case, the boundary inference)
- runMatch(f, error) = f(match(error), defect).run(); NonExhaustiveError
from a value that slips past the types becomes a Defect (throw-to-defect)
- flatTapErr infers a plain E2 and returns E | E2: unioning an M-derived
output with the class E in a covariant return re-invades E's variance
and collapses inference (combine<T,E> → unknown). ErrMatcher<E> stays a
contravariant callback parameter only.
Core now depends on ts-pattern (small, types-heavy, dual-copy-safe via a
global Symbol.for) and re-exports match/P + tag. The @unthrown/pattern
package is removed — tag moved into core; the P.Ok/Err/Defect sugar
dropped (match the union structurally instead).
BREAKING: deprecated orElse/recover removed; unwrap-family aliases remain.
AsyncOkOf/AsyncErrOf infer through the Awaitable channel only.
The prior 5.0.7 pin (GHSA-3jxr-9vmj-r5cp) is now flagged by a new high advisory, GHSA-mh99-v99m-4gvg (DoS via unbounded expansion length), failing the Security Audit gate. 5.0.8 is the first release patched for both. Add a temporary minimumReleaseAge exclusion — it was published 2026-07-23, inside the 7-day maturity cutoff.
…chers The load-bearing invariant still described the pre-matcher `NoThenables` union-wide check applied by `mapErr`/`recoverErr`/`tapErr` — a type that no longer exists. Match reality (and the file's own Thesis #5): only the awaiting `flatMapErr`/`flatTapErr` reject an async branch via their builder-output constraint; the non-awaiting triage combinators run the branch synchronously, so an async branch is a visible Promise value, not a rejection bypass.
Remove `matchTags` / `TagHandlers` and upgrade `match`'s `err` handler to
the same ts-pattern exhaustive matcher the error combinators use. A per-tag
fold at the edge is now:
result.match({
ok, defect,
err: (matcher) => matcher.with(tag("NotFound"), …).with(tag("Forbidden"), …),
})
which generalises beyond `_tag` to any discriminant. The `err` handler
returns the un-terminated builder (match runs `.exhaustive()`) and receives
no `defect` helper — match is total elimination to a value. `ok`/`defect`
have independent result type params, so divergent channel shapes union
cleanly.
Also rename the matcher callback param `m` → `matcher` across every error
combinator.
Library code generic in `E` can't prove `P._` exhaustive over an unresolved
type parameter, so the interop `to*` bridges and `@unthrown/orpc`'s
handlerResult fold via the isOk/isErr/isDefect guards instead.
BREAKING CHANGE: `matchTags` and `TagHandlers` are removed; `match`'s `err`
handler now takes the exhaustive matcher instead of a blanket callback.
Show the single-wildcard-branch idiom (log/transform/recover/fold any error) in the error-triage guide, and note that a P._ branch keeps compiling as E is enriched — the deliberate opt-out of per-case handling.
Adds .changeset/pre.json (mode: pre, tag: beta). Once this branch lands on main, the release pipeline versions the pending changesets as prereleases (unthrown 5.0.0-beta.0; the fixed group with it, orpc/prisma 0.1.1-beta.0) and publishes under the npm "beta" dist-tag, leaving "latest" on 4.x. Run `changeset pre exit` to graduate v5 to a stable release.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 56 out of 57 changed files in this pull request and generated 6 comments.
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
Comments suppressed due to low confidence (1)
docs/guide/the-defect-channel.md:52
- This example again uses
P._without definingP. Sinced’s error channel isnever, the vacuously-exhaustivematcher => matcherform keeps the example consistent and self-contained.
```ts
const recovered = d.recoverErr((matcher) => matcher.with(P._, () => 99));
// type: Result<number, never>
- Self-contain P._ snippets: vacuous `matcher => matcher` where E is never (core-concepts, the-defect-channel pass-throughs), literal/structural matches where the modeled error is concrete (from-try-catch, boundaries); keep P._ with a visible import only where the branch supplies the U type. - Fix broken ./pattern-matching links (guide removed in 18783bc): link ts-pattern to its repo (core-concepts, orpc); drop testing's dangling Continue-to (it was the terminal page). - Drop matchTags from the hand-written API overview.
…gregates BREAKING CHANGES: - Remove the deprecated `unwrap`/`unwrapErr`/`unwrapOr`/`unwrapOrElse` aliases (runtime-identical delegates). The extractor family is spelled only `get…`. One concept, one name — no deprecated surface survives into v5. - Gate `getOrThrow` as the complement of `get`: it now compiles only when the error channel is non-empty (E ≠ never). On `Result<T, never>` there is nothing to throw, so `get()` is the tool. `get`/`getOrThrow` partition extraction by the error channel's state. Hardening (non-breaking): - `allAsync`/`allFromDictAsync` adopt each input defensively, so a cast/untyped rejecting thenable becomes a Defect rather than rejecting the internal promise (upholds 'the internal promise never rejects' for out-of-contract input). Regression tests added. - Document the flatTapErr asymmetry (a branch returning a Defect replaces the Err; only a throw aggregates). Fix UnwrapError's stale 'unwrap' message. Audit-driven: the getOrThrow gate and unwrap removal were the design audit's recommendations; the aggregate rejection gap was the robustness audit's one real invariant finding (verified by repro, now tested).
… coverage) From the documentation accuracy audit: - from-try-catch: the escape-hatch example used get() on a fallible Result (won't compile — get() is gated to E = never); switch it and the surrounding prose to getOrThrow(), the ungated extractor that throws the modeled error. - Correct the stale 'zero runtime dependencies' claim (core depends on ts-pattern) in getting-started, docs/index, the comparison table, core README. - the-defect-channel: add tapDefect/tapFailure to the defect pass-through exception list. - Add toBeErrWith to the vitest matcher lists (testing guide, vitest README, API index); add the missing @unthrown/prisma entry and round out the core bullet in the API index; fix the effect README match example to the matcher form.
With the unwrap* aliases gone, UnwrapError was the last 'unwrap' vocabulary in the surface. It's what the get… extractors throw on a wrong-variant access, so it now lives in the get register. Unreachable through well-typed code (only a cast/JS caller reaches it), so migration is a one-line rename for the few who instanceof/catch it. BREAKING CHANGE: the exported UnwrapError class is renamed to GetError.
The four aggregates read each element's .tag; a hole/undefined element (only reachable via untyped/cast input) made sync all()/allFromDict() throw a raw TypeError and made allAsync()/allFromDictAsync() REJECT — the latter violating 'an AsyncResult's internal promise never rejects'. Guard the fold: a non-Result element becomes a Defect (an unexpected failure), uniformly across sync and async. Completes the earlier rejecting-thenable hardening (Array.map skipped holes, so they still reached the fold). Regression tests for all four.
What
Every error is now handled explicitly, and enriching the error channel
Eis a compile error at every consuming site until each new case is handled.
The error combinators —
mapErr,flatMapErr,recoverErr,tapErr,flatTapErr— receive a ts-patternmatch builder over the error (
match(error)) plus the injecteddefecthelper,and return the un-terminated builder — the combinator calls
.exhaustive()for you:
Because the combinator runs
.exhaustive(), a missing case does not compile —there is no
.exhaustive()to forget and no.otherwise()to smuggle in afallback. Errors-as-values only pays off if the values can't be silently
dropped; this makes sure they aren't.
Design journey (why not the record)
This PR started as a per-tag record (
{ RecordNotFound: …, DriverError: … })with a
mergeTagsescape hatch. Review surfaced two real limits of that shape:it only keyed on
_tag(breakingcode-discriminated unions like oRPC's), andit couldn't share one strategy across several cases. ts-pattern dissolves both —
and, crucially, keeps the forced-exhaustiveness the record was invented to
provide, because the combinator owns
.exhaustive(). So the whole bespokemachinery (
ErrTriage/mergeTags/TriageReturns/TriageKeysOk/NoDefects)is deleted in favor of the matcher.
The rules
_tag,code, structural shapes, guards, and groupedpatterns (
.with(a, b, handler)— one strategy for several cases).tag("X")is sugar for
{ _tag: "X" }.P._is the deliberate catch-all — the uniform "handle everything else"branch that replaces the old single callback, made explicit and greppable.
defecthelper;its
Defectarm is subtracted from the outgoingE(Exclude<O, Defect>, theboundary inference). A throwing branch also becomes a
Defect(safety net).tapErr/flatTapErr,P._for acatch-all); the error is observed and flows through unchanged.
Type-machinery notes (recorded in CLAUDE.md → Internal design)
ErrMatcher<E> = ReturnType<typeof match<E>>; the callback returns anExhaustiveMatch<O>whose.exhaustiveis typed callable only when ts-patternnarrowed the input to
never. OutgoingE = Exclude<MatchOut<M>, Defect>.ErrMatcher<E>must stay a contravariant callback parameter only.ts-pattern's
Matchis invariant in its input; unioning anM-derived outputwith the class
Ein a covariant return re-invadedE's variance andcollapsed inference (
combine<T,E>(rs: AsyncResult<T,E>[])→unknown). Thatis why
flatTapErrinfers a plainE2and returnsE | E2— verified by aregression assertion in
types.test-d.ts.runMatch(f, error) = f(match(error), defect).run(); a value slipping past thetypes throws
NonExhaustiveError, which the throw-to-defect net turns into aDefect.AsyncRes's five methods are typed loosely (neverchannels) acrossthe
implementsboundary; the public surface re-imposes precision.flatMapErr/flatTapErrawait, so only theyreject an async branch (via the builder-output constraint);
mapErr/recoverErr/tapErrrun synchronously, so an async branch is a visiblePromise-valued result, not a rejection bypass.
@unthrown/patternremoved; ts-pattern folded into coreCore now depends on
ts-pattern(small, types-heavy, and dual-copy-safe —its matcher protocol keys off a global
Symbol.for), and re-exportsmatchandP, plustag. The standalone@unthrown/patternpackage is deleted:tagmoved into core; the
P.Ok/P.Err/P.Defectsugar is dropped (match the unionstructurally with ts-pattern instead —
match(r).with(P.Ok(), …)still workssince
Resultis a discriminated union).Also breaking
orElse/recoverremoved. Theextractor aliases (
unwrap,unwrapErr,unwrapOr,unwrapOrElse) remain.AsyncOkOf/AsyncErrOfnow infer through theAwaitablechannel only(same results for ordinary
AsyncResulttypes).Verification
format --check,lint,typecheck,knip,test,build— 36/36 turbo tasks, 386 tests across the workspace (incl. prisma SQLite and
orpc end-to-end suites). Core 236 tests, 100% line/function coverage;
typedoc warning-free.
types.test-d.tspins: forced exhaustiveness (a missing case doesn't compile),P._catch-all, grouped patterns,code-discriminated and untagged unions,defect/throw-branch subtraction,
flatMapErr/flatTapErrasync-branchrejection, and the
combineinference regression guard.handle-everything-explicitly / compile-error-on-enrichment guarantee; migration
tables, orpc guide/README, core-concepts, and the CLAUDE.md spec (Thesis chore: migrate repo + docs URLs to btravstack org #5,
invariants, internal design, monorepo layout) all updated; the pattern package
guide/API pages removed.