Skip to content

Existing error types ​

examples/existing-errors — the adoption case. A codebase that already has an error convention, wired to Result without rewriting it. TaggedError does not appear once.

sh
pnpm turbo run test --filter=@unthrown/example-existing-errors

Why the package exists ​

Result<T, E> is generic in E and unconstrained — there is no E extends { _tag: string } anywhere in core, and P.tag("X") is only sugar for the object pattern { _tag: "X" }. Saying so in prose is cheap; the three modules here compile and are tested in CI, so the claim cannot quietly stop being true.

Each one takes a different existing convention, matching a section of Model errors.

tickets.ts — your own class hierarchy ​

The convention that was already there: an abstract base carrying a kind.

ts
export abstract class AppError extends Error {
  abstract readonly kind: string;
}

export class TicketNotFound extends AppError {
  readonly kind = "TicketNotFound" as const;
  constructor(readonly ticketId: string) {
    super(`no ticket ${ticketId}`);
  }
}

mapErrCases drives the same exhaustive matcher the tagged path uses, dispatching on kind through a plain object pattern:

ts
assignTicket(store, ticketId, to).mapErrCases((matcher) =>
  matcher
    .with({ kind: "TicketNotFound" }, (e) => ({
      status: 404,
      detail: e.ticketId,
    }))
    .with({ kind: "TicketLocked" }, (e) => ({
      status: 423,
      detail: e.lockedBy,
    })),
);

Each branch is narrowed to its own class, so ticketId and lockedBy are reachable without a cast. Add a third AppError subclass to the union and this stops compiling until it is named — the guarantee comes from the union's shape, not from TaggedError.

billing.ts — a plain union, no classes at all ​

The other extreme: a client generated from an OpenAPI document, whose failures are plain objects with a code. E does not have to be an Error either.

ts
export type BillingError =
  | { readonly code: "CARD_DECLINED"; readonly declineCode: string }
  | { readonly code: "INSUFFICIENT_FUNDS" }
  | { readonly code: "RATE_LIMITED"; readonly retryAfter: number };

Two codes deserve the same response, so they share one arm as a grouped pattern — both still named, which is the difference from a wildcard:

ts
client.charge(cents).match({
  ok: () => 200,
  defect: () => 500,
  errCases: (matcher) =>
    matcher
      .with(
        { code: "CARD_DECLINED" },
        { code: "INSUFFICIENT_FUNDS" },
        () => 402,
      )
      .with({ code: "RATE_LIMITED" }, () => 429),
});

The spec pins the defect arm too: a socket hang-up in the billing client folds to 500 rather than arriving as a fourth code a caller might branch on.

vendor.ts — untagged third-party classes ​

Two SDK error classes with no shared discriminant, no tag, and no possibility of editing them. The boundary is where the real decision gets made:

ts
export const render = fromThrowable(
  (source: string): Template => ({ rendered: vendorRender(source) }),
  (cause, defect) =>
    cause instanceof VendorSyntaxError || cause instanceof VendorTimeoutError
      ? cause
      : defect(cause),
);

That is the answer to "how does unthrown know which failures are modelled" — not the error's shape, but qualify. What you return becomes E; what you hand to the injected defect leaves the modelled type entirely. The spec asserts both halves, including that a RangeError from inside the SDK never reaches E.

Matching then uses P.instanceOf, the pattern for a union with nothing to dispatch on but identity:

ts
result.mapErrCases((matcher) =>
  matcher
    .with(P.instanceOf(VendorSyntaxError), (e) => ({
      detail: `bad syntax at ${e.at}`,
    }))
    .with(P.instanceOf(VendorTimeoutError), (e) => ({
      detail: `timed out after ${e.afterMs}ms`,
    })),
);

This is exhaustive only because the two classes differ in shape (at vs afterMs). Exhaustiveness is checked structurally, so two identical-shaped classes would count as one case: naming either would compile, and the other would become a Defect at runtime. When a vendor's classes are interchangeable in shape, tag them yourself in qualify rather than matching them with P.instanceOf.

P.when(guard) covers whatever neither an object pattern nor instanceof can express.

What you still have to give up ​

Nothing about the error type — but E must be a union TypeScript can discriminate, because exhaustiveness is Exclude over it. A kind, a code, distinct class shapes or a guard all qualify; a widened Error, string or unknown does not, and leaves P._ as the only arm that terminates the match. That is the same thing a switch needs, and what no-ambiguous-error-type is really guarding.

Where to go next ​

Released under the MIT License.