Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/document-combinator-surface.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"unthrown": minor
---

Make the fluent combinator surface (`map`, `flatMap`, `bind`, `match`, `unwrap`,
…) show up in the generated API reference. The methods were authored on an
internal `ResultMethods` type that TypeDoc dropped, so a discriminated-union
alias like `Result`/`AsyncResult` carried no method list anywhere in the docs.

`ResultMethods<T, E>` and the new parallel `AsyncResultMethods<T, E>` are now
**exported** and documented under the `Types` category (they are the single home
a union alias can hang its method list on); the `Result`/`AsyncResult` type docs
`{@link}` them. The core API reference also gets an explicit `categoryOrder`
(`Facade` → `Types` → `Constructors` → … then `Aggregate`, `Errors`) so the core
surface leads instead of the default alphabetical order, which had buried it
under `Aggregate`.
10 changes: 10 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,16 @@ async work re-enters via `fromPromise` / `fromSafePromise` and composes with
styles — not a second concept. (Each companion re-aliases its type in
`facade.ts`, so the `types.ts` `Result`/`AsyncResult` declarations both sit in
`typedoc.json`'s `intentionallyNotExported`.)
- method surface: the fluent combinators live on **`ResultMethods<T, E>`** (and
its async twin **`AsyncResultMethods<T, E>`**) — the object-literal types each
`Result` / `AsyncResult` variant intersects. Both are **exported from
`index.ts`** and carry `@category Types`: a discriminated union alias can't hang
a method list off itself in TypeDoc, so these named types are the single
documented home for `map`/`flatMap`/`match`/… (the `Result` / `AsyncResult` doc
comments `{@link}` them). The core `typedoc.json` sets an explicit
`categoryOrder` (`Facade`, `Types`, `Constructors`, … then `Aggregate`,
`Errors`) so the core surface leads the API reference instead of the default
alphabetical order (which buried it under `Aggregate`).
- tagged errors: `TaggedError(tag, options?)` (the error-class factory; optional
`options.name` sets `Error.name` independently of the `_tag` discriminant, so a
tag can be namespaced for collision-safety without leaking into the display
Expand Down
32 changes: 32 additions & 0 deletions docs/guide/why-unthrown.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,38 @@ Two more deliberate choices follow from this:
what lets an HTTP handler do a single `match({ ok, err, defect })` with no
surrounding `try`/`catch`.

## Why this matters more with AI in the loop

Most code today is written with an assistant in the loop, and the economics of
that loop reward exactly what errors-as-values provides. A model converges on
correct code fastest when a mistake is caught **at author time by the type
checker** rather than **at run time by a crash**: a compile error is local,
specific, and available before anything executes, whereas a thrown exception
costs a full generate → run → observe → retry cycle just to _discover_ that
something can fail.

Thrown exceptions give a model nothing to work with. A signature
`(id: string) => User` hides every way it can fail, so neither the model nor the
compiler can tell that a caller forgot to handle a timeout — the failure surfaces
later, as a stack trace, in a separate iteration. `Result<T, E>` puts every
anticipated failure _in the type_, which turns the type checker into a
specification the model must satisfy: a non-exhaustive `match`, an unhandled
`PaymentDeclined`, a forgotten `Err` arm each become a compile error the moment
they're written. That is the tightest correction signal there is — immediate,
mechanical, and free of a run.

The defect channel is what keeps that signal sharp. If unexpected failures were
folded into `E` as `unknown` or `Error`, exhaustiveness would degrade into
"handle the catch-all" and stop meaning anything. By holding `E` to exactly the
modeled errors and routing the unexpected to a separate, type-invisible defect,
`unthrown` keeps `E` a precise contract — so the type stays worth checking and
the compiler keeps telling the model something _true_. The enforced `qualify` at
every boundary reinforces this: it forces an explicit triage decision at each
`fromPromise` / `fromThrowable` instead of letting `unknown` be swallowed and
carried forward. The [`@unthrown/oxlint`](./linting) rule
`no-ambiguous-error-type` guards the same line from the other side, failing the
build when `unknown` / `any` / `Error` leak back into `E`.

## Compared to the alternatives

- **neverthrow / boxed / byethrow** — model errors as values, but have no proper
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/facade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ export const Result = {
* {@link Result | companion object} above (the value and type are one name); this
* is the type half.
*
* @remarks
* The fluent combinators (`map`, `flatMap`, `match`, `unwrap`, …) every variant
* carries are documented on {@link ResultMethods}.
*
* @category Facade
*/
// Re-alias the Result type into this module so a single `export { Result }`
Expand Down Expand Up @@ -106,6 +110,10 @@ export const AsyncResult = {
* with the {@link AsyncResult | companion object} above (value and type are one
* name); this is the type half.
*
* @remarks
* The fluent combinators it carries are documented on
* {@link AsyncResultMethods}.
*
* @category Facade
*/
// Re-alias the AsyncResult type into this module (same companion-object pattern
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,12 @@ export type { TaggedErrorConstructor, TaggedErrorInstance, TagHandlers } from ".
export type {
AsyncErrOf,
AsyncOkOf,
AsyncResultMethods,
Awaitable,
DefectView,
ErrOf,
ErrView,
OkOf,
OkView,
ResultMethods,
} from "./types.js";
29 changes: 24 additions & 5 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,15 @@ export type Prettify<T> = { [K in keyof T]: T[K] } & {};
export type Bound<T, K extends string, U> = Prettify<Omit<T, K> & { readonly [P in K]: U }>;

/**
* The method surface every {@link Result} variant carries. Factored out so the
* three variants ({@link OkView}, {@link ErrView}, {@link DefectView}) can each
* intersect it. Not part of the public API on its own.
* The fluent method surface every {@link Result} variant carries. Factored out
* so the three variants ({@link OkView}, {@link ErrView}, {@link DefectView}) can
* each intersect it — and so the combinators (`map`, `flatMap`, `match`, …) have
* a single documented home, since a discriminated union can't carry a method
* list on its own. The async counterpart is {@link AsyncResultMethods}.
*
* @typeParam T - the success value type.
* @typeParam E - the modeled error type.
* @internal
* @category Types
*/
export type ResultMethods<T, E> = {
/**
Expand Down Expand Up @@ -399,7 +401,24 @@ export type Awaitable<T> = {
* @typeParam T - the success value type.
* @typeParam E - the modeled error type.
*/
export type AsyncResult<T, E> = Awaitable<Result<T, E>> & {
export type AsyncResult<T, E> = Awaitable<Result<T, E>> & AsyncResultMethods<T, E>;

/**
* The fluent method surface an {@link AsyncResult} carries — the async
* counterpart of {@link ResultMethods}, factored out so the combinators have a
* single documented home (an {@link AsyncResult} is `Awaitable<Result>`
* intersected with these methods).
*
* @remarks
* **Combinator callbacks are synchronous** (see {@link AsyncResult}). The binds
* (`flatMap`, `flatTap`, `bind`, `orElse`, `recoverDefect`) additionally accept
* an `AsyncResult`; the eliminators (`unwrap`, …) return promises.
*
* @typeParam T - the success value type.
* @typeParam E - the modeled error type.
* @category Types
*/
export type AsyncResultMethods<T, E> = {
/** Asynchronous `map`. `f` is synchronous; a throw becomes a `Defect`. */
map<U>(f: (value: T) => U): AsyncResult<U, E>;
/**
Expand Down
13 changes: 12 additions & 1 deletion packages/core/typedoc.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,23 @@
"extends": "@unthrown/typedoc/base.json",
"entryPoints": ["src/index.ts"],
"out": "docs",
"categoryOrder": [
"Facade",
"Types",
"Constructors",
"Interop",
"Do-notation",
"Guards",
"Tagged errors",
"Aggregate",
"Errors",
"*"
],
"intentionallyNotExported": [
"Result",
"AsyncResult",
"Defect",
"Props",
"ResultMethods",
"AllOk",
"ResultRecord",
"AsyncResultRecord"
Expand Down
Loading