From 6f9eb6fcc2708a0603adfa66cec110470512b692 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Mon, 6 Jul 2026 18:48:08 +0200 Subject: [PATCH] feat(core): document the fluent combinator surface in the API reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The method surface (map, flatMap, bind, match, unwrap, …) was authored on an internal ResultMethods type marked @internal and excluded via intentionallyNotExported, so TypeDoc dropped it. Because Result/AsyncResult are discriminated-union aliases (which can't carry a method list), the combinators appeared nowhere in the generated API reference. - Export ResultMethods and a new parallel AsyncResultMethods as the single documented home for the combinators (@category Types); Result/AsyncResult type docs {@link} them. - Set an explicit categoryOrder in the core typedoc.json (Facade → Types → Constructors → … then Aggregate, Errors) so the core surface leads the API reference instead of the default alphabetical order. - Add a "Why this matters more with AI in the loop" section to the Why unthrown? guide: explicit errors move failure detection to author-time type checking, shortening the LLM feedback loop; the defect channel keeps E a precise contract. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/document-combinator-surface.md | 16 ++++++++++++ CLAUDE.md | 10 +++++++ docs/guide/why-unthrown.md | 32 +++++++++++++++++++++++ packages/core/src/facade.ts | 8 ++++++ packages/core/src/index.ts | 2 ++ packages/core/src/types.ts | 29 ++++++++++++++++---- packages/core/typedoc.json | 13 ++++++++- 7 files changed, 104 insertions(+), 6 deletions(-) create mode 100644 .changeset/document-combinator-surface.md diff --git a/.changeset/document-combinator-surface.md b/.changeset/document-combinator-surface.md new file mode 100644 index 0000000..6378511 --- /dev/null +++ b/.changeset/document-combinator-surface.md @@ -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` and the new parallel `AsyncResultMethods` 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`. diff --git a/CLAUDE.md b/CLAUDE.md index 7d930c7..34f81d2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`** (and + its async twin **`AsyncResultMethods`**) — 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 diff --git a/docs/guide/why-unthrown.md b/docs/guide/why-unthrown.md index 67dea4b..0116545 100644 --- a/docs/guide/why-unthrown.md +++ b/docs/guide/why-unthrown.md @@ -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` 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 diff --git a/packages/core/src/facade.ts b/packages/core/src/facade.ts index 0282adb..5f530de 100644 --- a/packages/core/src/facade.ts +++ b/packages/core/src/facade.ts @@ -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 }` @@ -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 diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 355b6b7..c1e64fb 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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"; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 289b17b..5b90b42 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -19,13 +19,15 @@ export type Prettify = { [K in keyof T]: T[K] } & {}; export type Bound = Prettify & { 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 = { /** @@ -399,7 +401,24 @@ export type Awaitable = { * @typeParam T - the success value type. * @typeParam E - the modeled error type. */ -export type AsyncResult = Awaitable> & { +export type AsyncResult = Awaitable> & AsyncResultMethods; + +/** + * 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` + * 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 = { /** Asynchronous `map`. `f` is synchronous; a throw becomes a `Defect`. */ map(f: (value: T) => U): AsyncResult; /** diff --git a/packages/core/typedoc.json b/packages/core/typedoc.json index 869866e..a4aba00 100644 --- a/packages/core/typedoc.json +++ b/packages/core/typedoc.json @@ -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"