From f54c2012ecfc656159ccede3363a35da0535c720 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sat, 27 Jun 2026 20:36:25 +0200 Subject: [PATCH 1/5] feat(core): add do-notation (Do / bind / let) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `Do()` plus `bind`/`let` methods on Result and AsyncResult for sequencing dependent steps into a named, accumulating object scope — without nested flatMap closures, and without generators. - `bind(name, f)`: f returns a Result; its value is bound under `name` in a readonly object scope; error types union (E | E2). On AsyncResult, f may return a Result or an AsyncResult. - `let(name, f)`: binds a pure value. - A throw in either becomes a Defect; Err/Defect short-circuits — same invariants as every combinator (guarded in invariants.spec). `Do` is capitalised because `do` is reserved; lift a sync chain with `toAsync()` to go async. Runtime + type-level tests, docs (a Do Notation guide + sidebar/surface/cheatsheet), and CLAUDE.md updated (fluent do-notation moved off the "excluded" list; generator `gen`/`safeTry` style stays out). 100% line/function coverage held. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/do-notation.md | 26 +++++++ CLAUDE.md | 24 ++++--- docs/.vitepress/config.ts | 1 + docs/guide/choosing-a-combinator.md | 1 + docs/guide/core-concepts.md | 2 + docs/guide/do-notation.md | 69 ++++++++++++++++++ packages/core/src/core.ts | 74 +++++++++++++++++++- packages/core/src/do.spec.ts | 100 +++++++++++++++++++++++++++ packages/core/src/do.ts | 32 +++++++++ packages/core/src/facade.ts | 2 + packages/core/src/index.ts | 1 + packages/core/src/interop.ts | 11 +-- packages/core/src/invariants.spec.ts | 6 +- packages/core/src/types.test-d.ts | 32 +++++++++ packages/core/src/types.ts | 60 ++++++++++++++++ 15 files changed, 427 insertions(+), 14 deletions(-) create mode 100644 .changeset/do-notation.md create mode 100644 docs/guide/do-notation.md create mode 100644 packages/core/src/do.spec.ts create mode 100644 packages/core/src/do.ts diff --git a/.changeset/do-notation.md b/.changeset/do-notation.md new file mode 100644 index 0000000..10ab82c --- /dev/null +++ b/.changeset/do-notation.md @@ -0,0 +1,26 @@ +--- +"unthrown": minor +--- + +Add **do-notation**: `Do()` plus the `bind` / `let` methods on `Result` and +`AsyncResult`, for sequencing dependent steps into a named scope without nested +`flatMap` closures. + +```ts +Do() + .bind("user", () => findUser(id)) // Result + .bind("org", ({ user }) => findOrg(user.orgId)) // Result + .let("label", ({ user, org }) => `${user.name} @ ${org.name}`) + .map(({ user, org, label }) => render(user, org, label)); +// Result +``` + +`bind(name, f)` sequences a `Result`-returning step and binds its value under +`name` in an accumulating **readonly** object scope (error types union); `let` +binds a pure value. On `AsyncResult`, `bind` accepts a `Result` or an +`AsyncResult`. A throw in either becomes a `Defect`, and `Err`/`Defect` +short-circuits — same guarantees as every other combinator. (`Do` is capitalised +because `do` is reserved; lift a sync chain with `toAsync()` to go async.) + +This is the fluent do-notation only; generator (`gen`/`safeTry`) style remains +out of scope. diff --git a/CLAUDE.md b/CLAUDE.md index 2b7e547..28847c1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,9 +46,9 @@ was planned). ## Load-bearing runtime invariants (tests must guard these) - **Throw → defect.** Any value thrown by a callback inside a combinator - (`map`, `flatMap`, `flatTap`, `mapErr`, `orElse`, `recover`, `tap*`, - `recoverDefect`) is caught and converted to a `Defect`. Nothing escapes a - pipeline as a raw throw. + (`map`, `flatMap`, `flatTap`, `bind`, `let`, `mapErr`, `orElse`, `recover`, + `tap*`, `recoverDefect`) is caught and converted to a `Defect`. Nothing + escapes a pipeline as a raw throw. This is what lets an HTTP adapter do a single `match({ ok, err, defect })` with **no surrounding `try/catch`**. - **A `Defect` flows through every method untouched EXCEPT `match()` and @@ -86,6 +86,13 @@ async work re-enters via `fromPromise` / `fromSafePromise` and composes with - success: `map`, `flatMap`, `tap`, `flatTap` (a failable `tap` — runs a `Result`-returning effect, keeps the original value, threads the effect's error), `as` +- do-notation: `Do()` (entry — `ok({})`, an empty object scope; capitalised + because `do` is reserved) plus the methods `bind(name, f)` (sequence a + `Result`-returning step, binding its value under `name` in an accumulating + **readonly** object scope; errors union `E | E2`) and `let(name, f)` (bind a + pure value). On `AsyncResult`, `bind`'s `f` may return a `Result` or an + `AsyncResult`. A throw in either becomes a `Defect`; `Err`/`Defect` + short-circuits/passes through. To go async, lift with `toAsync()`. - error: `mapErr`, `orElse`, `recover`, `tapErr` - defect: `recoverDefect`, `tapDefect` - eliminate: `match`, `unwrap`, `unwrapErr`, `unwrapOr`, `unwrapOrElse`, @@ -108,7 +115,7 @@ async work re-enters via `fromPromise` / `fromSafePromise` and composes with reject. The record fold writes keys via `Object.defineProperty`, so a caller-supplied `"__proto__"` key can't pollute the prototype. - facade: a `Result` companion object aliases the standalone entry points - (`Result.ok`/`err`/`defect`/`from*`/`all`/`allAsync`/`allFromDict`/`allFromDictAsync`/`is*`) + (`Result.ok`/`err`/`defect`/`Do`/`from*`/`all`/`allAsync`/`allFromDict`/`allFromDictAsync`/`is*`) for discoverability; the free functions remain the primary, tree-shakeable API. One concept, two import styles — not a second concept. @@ -119,10 +126,11 @@ async work re-enters via `fromPromise` / `fromSafePromise` and composes with per-tag` fold; has an async overload resolving to `Promise`); see the `TaggedError` convention in Thesis #4. -Deliberately **excluded** for now: `gen`/do-notation (heaviest possible -addition; revisit only if sequential code demands it), accumulation/`Validation`, -and aliases (`andThen`, etc. — one name per concept). Keep the surface small -enough that the library can be "done". +Deliberately **excluded** for now: **generator** do-notation (`gen`/`yield*` +"safeTry" style — the fluent `Do`/`bind`/`let` above covers sequential code +without the generator machinery), accumulation/`Validation`, and aliases +(`andThen`, etc. — one name per concept). Keep the surface small enough that the +library can be "done". ## Internal design (don't break these) diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 5b1f169..230da35 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -48,6 +48,7 @@ export default defineConfig({ { text: "The Defect Channel", link: "/guide/the-defect-channel" }, { text: "Boundaries & Qualification", link: "/guide/boundaries" }, { text: "Async Results", link: "/guide/async-results" }, + { text: "Do Notation", link: "/guide/do-notation" }, { text: "Choosing a Combinator", link: "/guide/choosing-a-combinator" }, { text: "Recipes", link: "/guide/recipes" }, ], diff --git a/docs/guide/choosing-a-combinator.md b/docs/guide/choosing-a-combinator.md index 8d63004..7fe2c81 100644 --- a/docs/guide/choosing-a-combinator.md +++ b/docs/guide/choosing-a-combinator.md @@ -12,6 +12,7 @@ channel** and turns a thrown callback into a `Defect`. | run a dependent, `Result`-returning step | `flatMap` | Ok | | run a side effect, keep the value | `tap` | Ok | | run a **failable** side effect, keep the value | `flatTap` | Ok | +| sequence dependent steps into a named scope | `Do`/`bind`/`let` | Ok | | replace the value with a constant | `as` | Ok | | transform the error | `mapErr` | Err | | try a fallback that returns a `Result` | `orElse` | Err | diff --git a/docs/guide/core-concepts.md b/docs/guide/core-concepts.md index 53f2cb3..e94ce65 100644 --- a/docs/guide/core-concepts.md +++ b/docs/guide/core-concepts.md @@ -24,6 +24,8 @@ reachable once you've narrowed to a variant. Every `Result` shares one method surface, grouped by the channel it touches: - **success** (runs on `Ok`): `map`, `flatMap`, `tap`, `flatTap`, `as` +- **do-notation** (runs on `Ok`): `bind`, `let` — accumulate a named scope; see + [Do Notation](./do-notation) - **error** (runs on `Err`): `mapErr`, `orElse`, `recover`, `tapErr` - **defect** (the only door to a `Defect`): `recoverDefect`, `tapDefect` - **eliminate**: `match`, `unwrap`, `unwrapErr`, `unwrapOr`, `unwrapOrElse`, diff --git a/docs/guide/do-notation.md b/docs/guide/do-notation.md new file mode 100644 index 0000000..a59eb96 --- /dev/null +++ b/docs/guide/do-notation.md @@ -0,0 +1,69 @@ +# Do Notation + +When several steps each depend on the values of the ones before, nesting +`flatMap` callbacks gets awkward. **Do notation** flattens that into a linear +chain that accumulates a named scope — without generators, and with the same +defect guarantees as every other combinator. + +## `Do` · `bind` · `let` + +Start a chain with `Do()` (an empty object scope), then grow it: + +- **`bind(name, f)`** — `f` receives the scope so far and returns a `Result`. On + `Ok`, its value is added to the scope under `name`; on `Err`/`Defect` the chain + short-circuits. Error types **union** across binds. +- **`let(name, f)`** — the pure-value counterpart: `f` returns a plain value (not + a `Result`), added under `name`. + +```ts +import { Do } from "unthrown"; + +const view = Do() + .bind("user", () => findUser(id)) // Result + .bind("org", ({ user }) => findOrg(user.orgId)) // Result + .let("label", ({ user, org }) => `${user.name} @ ${org.name}`) + .map(({ user, org, label }) => render(user, org, label)); +// Result +``` + +Each step's callback is typed with everything bound so far, and the final value +is the accumulated object. (The scope is **readonly** — you don't mutate it +mid-chain.) + +`Do` is capitalised because `do` is a reserved word. + +## It's just a `Result` + +A do-chain is an ordinary `Result` at every step — `bind`/`let` are methods on +the normal surface, so you can mix in `map`, `flatMap`, `match`, and the rest +freely, and a thrown callback still becomes a `Defect`: + +```ts +Do() + .bind("n", () => ok(2)) + .let("doubled", ({ n }) => n * 2) + .match({ + ok: ({ n, doubled }) => `${n} → ${doubled}`, + err: (e) => `failed: ${e}`, + defect: (cause) => `bug: ${String(cause)}`, + }); +``` + +## Async + +To sequence asynchronous steps, lift the chain with `toAsync()`. From there a +`bind` may return a `Result` **or** an `AsyncResult` (never a raw `Promise` — see +[Boundaries](./boundaries)): + +```ts +import { Do, fromPromise, defect } from "unthrown"; + +const profile = await Do() + .toAsync() + .bind("user", () => fromPromise(fetchUser(id), (c) => defect(c))) + .bind("posts", ({ user }) => fromPromise(fetchPosts(user.id), (c) => defect(c))) + .let("count", ({ posts }) => posts.length) + .match({ ok: (s) => s, err: () => null, defect: () => null }); +``` + +→ Continue to [The Defect Channel](./the-defect-channel). diff --git a/packages/core/src/core.ts b/packages/core/src/core.ts index 7fbcd3f..59e6576 100644 --- a/packages/core/src/core.ts +++ b/packages/core/src/core.ts @@ -13,7 +13,7 @@ // boxed uses, sound because the passed-through variant carries no value of the // changed type. -import type { AsyncResult, DefectView, ErrView, OkView, Result } from "./types.js"; +import type { AsyncResult, DefectView, ErrView, OkView, Prettify, Result } from "./types.js"; /** * Thrown by a {@link Result}'s `unwrap` / `unwrapErr` when the assertion is @@ -87,6 +87,36 @@ class Res { } } + bind( + this: Result, + name: K, + f: (scope: T) => Result, + ): Result, E | E2> { + type Out = Result, E | E2>; + if (this.tag !== "Ok") return this as unknown as Out; + try { + const r = f(this.value); + if (r.tag !== "Ok") return r as unknown as Out; + return okRes({ ...(this.value as object), [name]: r.value }) as unknown as Out; + } catch (cause) { + return defectRes(cause); + } + } + + let( + this: Result, + name: K, + f: (scope: T) => U, + ): Result, E> { + type Out = Result, E>; + if (this.tag !== "Ok") return this as unknown as Out; + try { + return okRes({ ...(this.value as object), [name]: f(this.value) }) as unknown as Out; + } catch (cause) { + return defectRes(cause); + } + } + as(this: Result, value: U): Result { if (this.tag !== "Ok") return this as unknown as Result; return okRes(value); @@ -336,6 +366,48 @@ export class AsyncRes implements AsyncResult { ); } + bind( + name: K, + f: (scope: T) => Result | AsyncResult, + ): AsyncResult, E | E2> { + type Merged = Prettify; + return new AsyncRes( + this.promise.then(async (r) => { + if (r.tag !== "Ok") return r as unknown as Result; + try { + const inner = (await f(r.value)) as Result; + if (inner.tag !== "Ok") return inner as unknown as Result; + return okRes({ ...(r.value as object), [name]: inner.value }) as unknown as Result< + Merged, + E | E2 + >; + } catch (cause) { + return defectRes(cause); + } + }), + ); + } + + let( + name: K, + f: (scope: T) => U, + ): AsyncResult, E> { + type Merged = Prettify; + return new AsyncRes( + this.promise.then((r) => { + if (r.tag !== "Ok") return r as unknown as Result; + try { + return okRes({ ...(r.value as object), [name]: f(r.value) }) as unknown as Result< + Merged, + E + >; + } catch (cause) { + return defectRes(cause); + } + }), + ); + } + as(value: U): AsyncResult { return new AsyncRes( this.promise.then((r) => diff --git a/packages/core/src/do.spec.ts b/packages/core/src/do.spec.ts new file mode 100644 index 0000000..95eba16 --- /dev/null +++ b/packages/core/src/do.spec.ts @@ -0,0 +1,100 @@ +import { describe, expect, it, vi } from "vitest"; + +import { Do, err, fromSafePromise, ok, type Result } from "./index.js"; + +const boom = new Error("boom"); +const defectOf = (cause: unknown): Result => + ok(0).map(() => { + throw cause; + }); + +describe("Do / bind / let", () => { + it("Do() starts an empty object scope", () => { + expect(Do().unwrap()).toEqual({}); + }); + + it("accumulates bound Results and pure lets into a named scope", () => { + const r = Do() + .bind("a", () => ok(1)) + .bind("b", ({ a }) => ok(a + 1)) + .let("c", ({ a, b }) => a + b); + expect(r.unwrap()).toEqual({ a: 1, b: 2, c: 3 }); + }); + + it("short-circuits on the first Err and skips later steps", () => { + const later = vi.fn(() => ok(2)); + const r = Do() + .bind("a", () => err("denied")) + .bind("b", later); + expect(r.unwrapErr()).toBe("denied"); + expect(later).not.toHaveBeenCalled(); + }); + + it("unions the error types across binds", () => { + const a: Result<{ x: number }, "e1"> = Do().bind("x", () => err<"e1">("e1")); + const b = a.bind("y", () => err<"e2">("e2")); + // `b` is Result<{ x; y }, "e1" | "e2"> — exercised at runtime here: + expect(b.unwrapErr()).toBe("e1"); + }); + + it("propagates a Defect and does not run later steps", () => { + const later = vi.fn(() => ok(1)); + const r = Do() + .bind("a", () => defectOf(boom)) + .let("b", later); + expect(r.isDefect()).toBe(true); + expect(later).not.toHaveBeenCalled(); + }); + + it("turns a throw in bind or let into a Defect", () => { + expect( + Do() + .bind("a", () => { + throw boom; + }) + .isDefect(), + ).toBe(true); + expect( + Do() + .let("a", () => { + throw boom; + }) + .isDefect(), + ).toBe(true); + }); +}); + +describe("Do / bind / let — async", () => { + it("binds AsyncResults and pure lets, mixing with sync Results", async () => { + const r = await Do() + .toAsync() + .bind("a", () => fromSafePromise(Promise.resolve(1))) + .bind("b", ({ a }) => ok(a + 1)) // a sync Result is accepted too + .let("c", ({ a, b }) => a + b); + expect(r.unwrap()).toEqual({ a: 1, b: 2, c: 3 }); + }); + + it("short-circuits on an async Err", async () => { + const r = await Do() + .toAsync() + .bind("a", () => err("denied")) + .bind("b", () => ok(1)); + expect(r.unwrapErr()).toBe("denied"); + }); + + it("turns a throw in an async bind or let into a Defect and never rejects", async () => { + const fromBind = await Do() + .toAsync() + .bind("a", () => { + throw boom; + }); + expect(fromBind.isDefect()).toBe(true); + + const fromLet = await Do() + .toAsync() + .let("a", () => { + throw boom; + }); + expect(fromLet.isDefect()).toBe(true); + }); +}); diff --git a/packages/core/src/do.ts b/packages/core/src/do.ts new file mode 100644 index 0000000..d40b54e --- /dev/null +++ b/packages/core/src/do.ts @@ -0,0 +1,32 @@ +// Do-notation entry point. The `bind` / `let` steps live on the `Result` / +// `AsyncResult` method surface (core.ts); `Do()` just seeds an empty object +// scope to grow. + +import { ok } from "./constructors.js"; +import type { Result } from "./types.js"; + +/** + * Start a do-notation chain with an empty object scope, grown step by step with + * `bind` (for `Result`-returning steps) and `let` (for pure values). + * + * @remarks + * Capitalised because `do` is a reserved word. Each step receives the scope + * accumulated so far; the error types union across `bind`s, and a throw in any + * step becomes a `Defect`. To go asynchronous, lift the chain with `toAsync()` + * (then a `bind` may return an `AsyncResult`). + * + * @example + * ```ts + * import { Do, ok } from "unthrown"; + * + * const result = Do() + * .bind("user", () => findUser(id)) // Result + * .bind("org", ({ user }) => findOrg(user.orgId)) // Result + * .let("label", ({ user, org }) => `${user.name} @ ${org.name}`) + * .map(({ user, org, label }) => render(user, org, label)); + * // Result + * ``` + */ +export function Do(): Result<{}, never> { + return ok({}); +} diff --git a/packages/core/src/facade.ts b/packages/core/src/facade.ts index 432eca9..4a383ca 100644 --- a/packages/core/src/facade.ts +++ b/packages/core/src/facade.ts @@ -6,6 +6,7 @@ import { err, isDefect, isErr, isOk, ok } from "./constructors.js"; import { defect } from "./defect.js"; +import { Do } from "./do.js"; import { all, allAsync, @@ -43,6 +44,7 @@ export const Result = { ok, err, defect, + Do, fromNullable, fromThrowable, fromPromise, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8e65068..e6b0985 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,6 +1,7 @@ export { err, isDefect, isErr, isOk, ok } from "./constructors.js"; export { UnwrapError } from "./core.js"; export { defect } from "./defect.js"; +export { Do } from "./do.js"; export { Result } from "./facade.js"; export { all, diff --git a/packages/core/src/interop.ts b/packages/core/src/interop.ts index 7c4aa7e..27b5634 100644 --- a/packages/core/src/interop.ts +++ b/packages/core/src/interop.ts @@ -249,7 +249,7 @@ function foldRecord(results: ResultRecord): Result { export function all[]>( results: readonly [...Rs], ): Result }>, ErrOf> { - return foldArray(results) as Result< + return foldArray(results) as unknown as Result< AllOk }>, ErrOf >; @@ -274,7 +274,10 @@ export function all[]>( export function allFromDict( results: R, ): Result<{ [K in keyof R]: OkOf }, ErrOf> { - return foldRecord(results) as Result<{ [K in keyof R]: OkOf }, ErrOf>; + return foldRecord(results) as unknown as Result< + { [K in keyof R]: OkOf }, + ErrOf + >; } /** @@ -301,7 +304,7 @@ export function allAsync[]>( const settled = Promise.all(results).then((resolved) => foldArray(resolved as readonly Result[]), ); - return new AsyncRes(settled) as AsyncResult< + return new AsyncRes(settled) as unknown as AsyncResult< AllOk }>, AsyncErrOf >; @@ -333,7 +336,7 @@ export function allFromDictAsync( }); return foldRecord(byKey); }); - return new AsyncRes(settled) as AsyncResult< + return new AsyncRes(settled) as unknown as AsyncResult< { [K in keyof R]: AsyncOkOf }, AsyncErrOf >; diff --git a/packages/core/src/invariants.spec.ts b/packages/core/src/invariants.spec.ts index 74e3da7..4476085 100644 --- a/packages/core/src/invariants.spec.ts +++ b/packages/core/src/invariants.spec.ts @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest"; -import { err, fromSafePromise, ok, type Result, UnwrapError } from "./index.js"; +import { Do, err, fromSafePromise, ok, type Result, UnwrapError } from "./index.js"; const boom = new Error("boom"); const defectOf = (cause: unknown): Result => @@ -20,6 +20,8 @@ describe("Invariant 1: throw inside any combinator becomes a Defect", () => { expect(ok(1).flatMap(t).isDefect()).toBe(true); expect(ok(1).tap(t).isDefect()).toBe(true); expect(ok(1).flatTap(t).isDefect()).toBe(true); + expect(Do().bind("a", t).isDefect()).toBe(true); + expect(Do().let("a", t).isDefect()).toBe(true); expect(err("e").mapErr(t).isDefect()).toBe(true); expect(err("e").orElse(t).isDefect()).toBe(true); expect(err("e").recover(t).isDefect()).toBe(true); @@ -37,6 +39,8 @@ describe("Invariant 2: a Defect flows through every method except match() and re defectOf(boom).flatMap(f), defectOf(boom).tap(f), defectOf(boom).flatTap(f), + defectOf(boom).bind("a", f), + defectOf(boom).let("a", f), defectOf(boom).as(1), defectOf(boom).mapErr(f), defectOf(boom).orElse(f), diff --git a/packages/core/src/types.test-d.ts b/packages/core/src/types.test-d.ts index 5784451..3a7ec36 100644 --- a/packages/core/src/types.test-d.ts +++ b/packages/core/src/types.test-d.ts @@ -15,6 +15,7 @@ import { type AsyncOkOf, type AsyncResult, defect, + Do, type ErrOf, err, fromPromise, @@ -117,6 +118,37 @@ type _mapped = Expect>>; const errMapped = r1.mapErr(() => 0 as const); type _errMapped = Expect>>; +// --- do-notation: bind/let accumulate a named scope, errors union ------------ + +// the accumulated scope is readonly (it mustn't be mutated mid-chain) +const doChain = Do() + .bind("a", () => ok(1)) + .bind("b", ({ a }) => (a > 0 ? ok("x") : err<"e1">("e1"))) + .let("c", ({ a, b }) => a + b.length); +type _doChain = Expect< + Equal< + typeof doChain, + Result<{ readonly a: number; readonly b: string; readonly c: number }, "e1"> + > +>; + +// the bound scope is typed in each step's callback (compiles → keys are present) +const doScoped = Do() + .bind("user", () => ok({ name: "ada" })) + .let("upper", ({ user }) => user.name.toUpperCase()); +type _doScoped = Expect< + Equal> +>; + +// async do-notation accumulates the same way +const doAsync = Do() + .toAsync() + .bind("a", () => ok(1)) + .let("b", ({ a }) => a + 1); +type _doAsync = Expect< + Equal> +>; + // --- guards narrow (methods AND standalone) ---------------------------------- declare const g: Result; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 68f7525..f3fbcf3 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1,5 +1,13 @@ // unthrown — public type surface. Pure types, no runtime. +/** + * Flatten an intersection into a single object literal so accumulated `bind` / + * `let` scopes display cleanly (`{ a; b }` rather than `{ a } & { b }`). + * + * @internal + */ +export type Prettify = { [K in keyof T]: T[K] } & {}; + /** * The method surface every {@link Result} variant carries. Factored out so the * three variants ({@link OkView}, {@link ErrView}, {@link DefectView}) can each @@ -57,6 +65,45 @@ export type ResultMethods = { * @param f - the failable side effect; its `Ok` value is ignored. */ flatTap(f: (value: T) => Result): Result; + /** + * Do-notation: run `f` for a `Result` and **bind its value** under `name` in + * an accumulating object scope. + * + * @remarks + * Begin a chain with {@link Do} (an empty object scope) and grow it step by + * step. `f` receives the scope accumulated so far and returns a `Result`; on + * `Ok` the value is added as `{ ...scope, [name]: value }`, on `Err`/`Defect` + * the chain short-circuits. Errors union (`E | E2`). A throw becomes a + * `Defect`. (`let` is the pure-value counterpart.) + * + * @typeParam K - the key the bound value is stored under. + * @typeParam U - the bound value type. + * @typeParam E2 - the error type `f` may introduce. + * @param name - the scope key. + * @param f - produces a `Result` from the accumulated scope. + */ + bind( + name: K, + f: (scope: T) => Result, + ): Result, E | E2>; + /** + * Do-notation: run `f` for a **plain value** and bind it under `name` in the + * accumulating object scope. The pure-value counterpart of {@link ResultMethods.bind | bind}. + * + * @remarks + * `f` receives the scope and returns a value (not a `Result`); it is added as + * `{ ...scope, [name]: value }`. Runs only on `Ok`; `Err`/`Defect` pass + * through. A throw becomes a `Defect`. + * + * @typeParam K - the key the value is stored under. + * @typeParam U - the value type. + * @param name - the scope key. + * @param f - computes a value from the accumulated scope. + */ + let( + name: K, + f: (scope: T) => U, + ): Result, E>; /** * Replace the success value with a constant `value`. * @@ -311,6 +358,19 @@ export type AsyncResult = Awaitable> & { flatTap( f: (value: T) => Result | AsyncResult, ): AsyncResult; + /** + * Asynchronous `bind` (do-notation). `f` may return a `Result` **or** an + * `AsyncResult`; its value is bound under `name` in the accumulating scope. + */ + bind( + name: K, + f: (scope: T) => Result | AsyncResult, + ): AsyncResult, E | E2>; + /** Asynchronous `let` (do-notation). `f` returns a plain value, bound under `name`. */ + let( + name: K, + f: (scope: T) => U, + ): AsyncResult, E>; /** Asynchronous `as`. */ as(value: U): AsyncResult; From 61736fb0e47bcdee2b133e49a4858f9679a3cd09 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sat, 27 Jun 2026 21:38:32 +0200 Subject: [PATCH 2/5] fix(core): bind/let overwrite a re-bound key instead of intersecting Address PR review: the scope type used `T & { [K]: U }`, so re-binding an existing name produced an unsound `T[K] & U` intersection while the runtime spread overwrites. Introduce `Bound = Prettify & { readonly [P in K]: U }>` so the type matches the overwrite. Applied to both bind/let on Result and AsyncResult, with a type-level test for the re-bind case. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/core.ts | 68 ++++++++++++++++++------------- packages/core/src/types.test-d.ts | 6 +++ packages/core/src/types.ts | 24 ++++++----- 3 files changed, 60 insertions(+), 38 deletions(-) diff --git a/packages/core/src/core.ts b/packages/core/src/core.ts index 59e6576..c6254f2 100644 --- a/packages/core/src/core.ts +++ b/packages/core/src/core.ts @@ -13,7 +13,7 @@ // boxed uses, sound because the passed-through variant carries no value of the // changed type. -import type { AsyncResult, DefectView, ErrView, OkView, Prettify, Result } from "./types.js"; +import type { AsyncResult, Bound, DefectView, ErrView, OkView, Result } from "./types.js"; /** * Thrown by a {@link Result}'s `unwrap` / `unwrapErr` when the assertion is @@ -91,13 +91,16 @@ class Res { this: Result, name: K, f: (scope: T) => Result, - ): Result, E | E2> { - type Out = Result, E | E2>; + ): Result, E | E2> { + type Out = Result, E | E2>; if (this.tag !== "Ok") return this as unknown as Out; try { const r = f(this.value); if (r.tag !== "Ok") return r as unknown as Out; - return okRes({ ...(this.value as object), [name]: r.value }) as unknown as Out; + return okRes({ + ...(this.value as object), + [name]: r.value, + }) as unknown as Out; } catch (cause) { return defectRes(cause); } @@ -107,11 +110,14 @@ class Res { this: Result, name: K, f: (scope: T) => U, - ): Result, E> { - type Out = Result, E>; + ): Result, E> { + type Out = Result, E>; if (this.tag !== "Ok") return this as unknown as Out; try { - return okRes({ ...(this.value as object), [name]: f(this.value) }) as unknown as Out; + return okRes({ + ...(this.value as object), + [name]: f(this.value), + }) as unknown as Out; } catch (cause) { return defectRes(cause); } @@ -183,7 +189,11 @@ class Res { match( this: Result, - cases: { ok: (value: T) => R; err: (error: E) => R; defect: (cause: unknown) => R }, + cases: { + ok: (value: T) => R; + err: (error: E) => R; + defect: (cause: unknown) => R; + }, ): R { switch (this.tag) { case "Ok": @@ -244,9 +254,11 @@ class Res { isOk(this: Result): this is OkView { return this.tag === "Ok"; } + isErr(this: Result): this is ErrView { return this.tag === "Err"; } + isDefect(this: Result): this is DefectView { return this.tag === "Defect"; } @@ -264,7 +276,10 @@ const RESULT_PROTO = Res.prototype; * @internal */ export function okRes(value: T): Result { - return Object.assign(Object.create(RESULT_PROTO), { tag: "Ok" as const, value }) as OkView; + return Object.assign(Object.create(RESULT_PROTO), { + tag: "Ok" as const, + value, + }) as OkView; } /** @@ -273,10 +288,10 @@ export function okRes(value: T): Result { * @internal */ export function errRes(error: E): Result { - return Object.assign(Object.create(RESULT_PROTO), { tag: "Err" as const, error }) as ErrView< - E, - T - >; + return Object.assign(Object.create(RESULT_PROTO), { + tag: "Err" as const, + error, + }) as ErrView; } /** @@ -369,18 +384,18 @@ export class AsyncRes implements AsyncResult { bind( name: K, f: (scope: T) => Result | AsyncResult, - ): AsyncResult, E | E2> { - type Merged = Prettify; + ): AsyncResult, E | E2> { + type Merged = Bound; return new AsyncRes( this.promise.then(async (r) => { if (r.tag !== "Ok") return r as unknown as Result; try { const inner = (await f(r.value)) as Result; if (inner.tag !== "Ok") return inner as unknown as Result; - return okRes({ ...(r.value as object), [name]: inner.value }) as unknown as Result< - Merged, - E | E2 - >; + return okRes({ + ...(r.value as object), + [name]: inner.value, + }) as unknown as Result; } catch (cause) { return defectRes(cause); } @@ -388,19 +403,16 @@ export class AsyncRes implements AsyncResult { ); } - let( - name: K, - f: (scope: T) => U, - ): AsyncResult, E> { - type Merged = Prettify; + let(name: K, f: (scope: T) => U): AsyncResult, E> { + type Merged = Bound; return new AsyncRes( this.promise.then((r) => { if (r.tag !== "Ok") return r as unknown as Result; try { - return okRes({ ...(r.value as object), [name]: f(r.value) }) as unknown as Result< - Merged, - E - >; + return okRes({ + ...(r.value as object), + [name]: f(r.value), + }) as unknown as Result; } catch (cause) { return defectRes(cause); } diff --git a/packages/core/src/types.test-d.ts b/packages/core/src/types.test-d.ts index 3a7ec36..9382f24 100644 --- a/packages/core/src/types.test-d.ts +++ b/packages/core/src/types.test-d.ts @@ -149,6 +149,12 @@ type _doAsync = Expect< Equal> >; +// re-binding a key OVERWRITES it (not an unsound `number & string` intersection) +const doRebind = Do() + .bind("a", () => ok(1)) + .bind("a", () => ok("x")); +type _doRebind = Expect>>; + // --- guards narrow (methods AND standalone) ---------------------------------- declare const g: Result; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index f3fbcf3..1b8d8bf 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -8,6 +8,16 @@ */ export type Prettify = { [K in keyof T]: T[K] } & {}; +/** + * The scope produced by a `bind` / `let` step: `T` with `K` added (as a readonly + * property of type `U`). `Omit` first drops any existing `K`, so re-binding + * a name **overwrites** it — matching the runtime spread — rather than producing + * an unsound `T[K] & U` intersection. + * + * @internal + */ +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 @@ -85,7 +95,7 @@ export type ResultMethods = { bind( name: K, f: (scope: T) => Result, - ): Result, E | E2>; + ): Result, E | E2>; /** * Do-notation: run `f` for a **plain value** and bind it under `name` in the * accumulating object scope. The pure-value counterpart of {@link ResultMethods.bind | bind}. @@ -100,10 +110,7 @@ export type ResultMethods = { * @param name - the scope key. * @param f - computes a value from the accumulated scope. */ - let( - name: K, - f: (scope: T) => U, - ): Result, E>; + let(name: K, f: (scope: T) => U): Result, E>; /** * Replace the success value with a constant `value`. * @@ -365,12 +372,9 @@ export type AsyncResult = Awaitable> & { bind( name: K, f: (scope: T) => Result | AsyncResult, - ): AsyncResult, E | E2>; + ): AsyncResult, E | E2>; /** Asynchronous `let` (do-notation). `f` returns a plain value, bound under `name`. */ - let( - name: K, - f: (scope: T) => U, - ): AsyncResult, E>; + let(name: K, f: (scope: T) => U): AsyncResult, E>; /** Asynchronous `as`. */ as(value: U): AsyncResult; From 668f9e181ae0274075028b2134baf396be3f6a65 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sat, 27 Jun 2026 22:36:09 +0200 Subject: [PATCH 3/5] refactor(core): funnel pass-through casts through one passThrough helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The short-circuit branches reused a non-matching variant (`Err`/`Defect`) as a differently-typed `Result` via inline `as unknown as` at ~21 sites. Centralise that single sound assertion in one `passThrough(self): Result` helper; every combinator's pass-through (sync + async) now calls it. `return this` is preserved, so nothing is reconstructed and no new object is allocated — only the phantom type parameter moves. Trade-off chosen deliberately: boxed casts inline (scattered), neverthrow reconstructs (allocates); this keeps boxed's zero-alloc while collapsing the casts to a single documented home. The only remaining casts are the bind/let scope merge (a computed key can't be spelled at the type level) and the builders' construction. Behaviour unchanged; 151 tests + the type-level suite pass, 100% line/function coverage held. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 11 +++- packages/core/src/core.ts | 131 +++++++++++++++++++++----------------- 2 files changed, 79 insertions(+), 63 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 28847c1..f04337c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -143,9 +143,14 @@ library can be "done". and return them as the variant type (`OkView`/`ErrView`/`DefectView`) — so a builder yields a value that already _is_ a union member, with **no construction cast**. `Res` methods type `this` as `Result` and narrow on `tag`. The - only remaining casts are the inherent type-changing pass-throughs (`map` reusing - an `Err` as a differently-typed `Result`) — the same `as unknown as` boxed uses, - sound because the passed-through variant carries no value of the changed type. + type-changing pass-throughs (`map` reusing an `Err` as a differently-typed + `Result`) **all funnel through one `passThrough` helper** — a single sound + `as unknown as` in one place (boxed instead casts inline at every branch), since + the passed-through variant carries no value of the changed success type. We + deliberately **do not reconstruct** the variant (neverthrow's approach) — that + would allocate a fresh object on every short-circuit. The only other casts are + the `bind`/`let` scope merge (a computed key widens to an index signature, so it + can't be spelled at the type level) and the builder construction noted above. - "Check before you access" is enforced by the union: `result.value` only type-checks on the `Ok` variant. `AsyncRes` operates purely on the public `Result` union (wraps a `Promise`, branches on `r.tag`), never on `Res` diff --git a/packages/core/src/core.ts b/packages/core/src/core.ts index c6254f2..eb20989 100644 --- a/packages/core/src/core.ts +++ b/packages/core/src/core.ts @@ -8,10 +8,11 @@ // `AsyncRes` wraps a `Promise` constructed never to reject and operates // purely on the public union (via `r.tag`). See CLAUDE.md → "Internal design". // -// The only casts left are the inherent type-changing pass-throughs (e.g. `map` -// reusing an `Err` as a differently-typed `Result`) — the same `as unknown as` -// boxed uses, sound because the passed-through variant carries no value of the -// changed type. +// Type-changing pass-throughs (e.g. `map` reusing an `Err` as a differently-typed +// `Result`) all funnel through the single `passThrough` helper — one sound +// `as unknown as` in one place, rather than boxed's inline cast at every branch. +// The only other casts are the builders' construction (`as OkView`/…) and the +// `bind`/`let` scope merge (a computed key can't be spelled at the type level). import type { AsyncResult, Bound, DefectView, ErrView, OkView, Result } from "./types.js"; @@ -49,7 +50,7 @@ export class UnwrapError extends Error { */ class Res { map(this: Result, f: (value: T) => U): Result { - if (this.tag !== "Ok") return this as unknown as Result; + if (this.tag !== "Ok") return passThrough(this); try { return okRes(f(this.value)); } catch (cause) { @@ -58,9 +59,9 @@ class Res { } flatMap(this: Result, f: (value: T) => Result): Result { - if (this.tag !== "Ok") return this as unknown as Result; + if (this.tag !== "Ok") return passThrough(this); try { - return f(this.value) as Result; + return f(this.value); } catch (cause) { return defectRes(cause); } @@ -77,11 +78,11 @@ class Res { } flatTap(this: Result, f: (value: T) => Result): Result { - if (this.tag !== "Ok") return this as unknown as Result; + if (this.tag !== "Ok") return this; try { const r = f(this.value); // Keep the original value on success; an Err/Defect from `f` short-circuits. - return (r.tag === "Ok" ? this : r) as unknown as Result; + return r.tag === "Ok" ? this : passThrough(r); } catch (cause) { return defectRes(cause); } @@ -92,15 +93,16 @@ class Res { name: K, f: (scope: T) => Result, ): Result, E | E2> { - type Out = Result, E | E2>; - if (this.tag !== "Ok") return this as unknown as Out; + if (this.tag !== "Ok") return passThrough(this); try { const r = f(this.value); - if (r.tag !== "Ok") return r as unknown as Out; - return okRes({ - ...(this.value as object), - [name]: r.value, - }) as unknown as Out; + if (r.tag !== "Ok") return passThrough(r); + // The merged scope can't be spelled at the type level (a computed key + // widens to an index signature), so the constructed Ok is cast to `Bound`. + return okRes({ ...(this.value as object), [name]: r.value }) as unknown as Result< + Bound, + E | E2 + >; } catch (cause) { return defectRes(cause); } @@ -111,25 +113,24 @@ class Res { name: K, f: (scope: T) => U, ): Result, E> { - type Out = Result, E>; - if (this.tag !== "Ok") return this as unknown as Out; + if (this.tag !== "Ok") return passThrough(this); try { - return okRes({ - ...(this.value as object), - [name]: f(this.value), - }) as unknown as Out; + return okRes({ ...(this.value as object), [name]: f(this.value) }) as unknown as Result< + Bound, + E + >; } catch (cause) { return defectRes(cause); } } as(this: Result, value: U): Result { - if (this.tag !== "Ok") return this as unknown as Result; + if (this.tag !== "Ok") return passThrough(this); return okRes(value); } mapErr(this: Result, f: (error: E) => E2): Result { - if (this.tag !== "Err") return this as unknown as Result; + if (this.tag !== "Err") return passThrough(this); try { return errRes(f(this.error)); } catch (cause) { @@ -138,16 +139,16 @@ class Res { } orElse(this: Result, f: (error: E) => Result): Result { - if (this.tag !== "Err") return this as unknown as Result; + if (this.tag !== "Err") return passThrough(this); try { - return f(this.error) as Result; + return f(this.error); } catch (cause) { return defectRes(cause); } } recover(this: Result, f: (error: E) => U): Result { - if (this.tag !== "Err") return this as unknown as Result; + if (this.tag !== "Err") return passThrough(this); try { return okRes(f(this.error)); } catch (cause) { @@ -169,9 +170,9 @@ class Res { this: Result, f: (cause: unknown) => Result, ): Result { - if (this.tag !== "Defect") return this as unknown as Result; + if (this.tag !== "Defect") return this; try { - return f(this.cause) as Result; + return f(this.cause); } catch (cause) { return defectRes(cause); } @@ -306,6 +307,20 @@ export function defectRes(cause: unknown): Result { }) as DefectView; } +/** + * Reuse a non-matching variant (an `Err` or `Defect`) as a differently-typed + * `Result`, with no runtime work. Sound because the passed-through variant + * carries no value of the changed success type, so retyping it is a no-op — only + * the phantom type parameter moves. This is the single sanctioned home for that + * assertion (the same one boxed applies inline at every pass-through); every + * combinator's short-circuit branch funnels through here instead of casting. + * + * @internal + */ +function passThrough(self: Result): Result { + return self as unknown as Result; +} + /** * The sole runtime implementation of {@link AsyncResult}: wraps a * `Promise` constructed never to reject. Operates on the public `Result` @@ -327,7 +342,7 @@ export class AsyncRes implements AsyncResult { map(f: (value: T) => U): AsyncResult { return new AsyncRes( this.promise.then((r) => { - if (r.tag !== "Ok") return r as unknown as Result; + if (r.tag !== "Ok") return passThrough(r); try { return okRes(f(r.value)); } catch (cause) { @@ -340,9 +355,9 @@ export class AsyncRes implements AsyncResult { flatMap(f: (value: T) => Result | AsyncResult): AsyncResult { return new AsyncRes( this.promise.then(async (r) => { - if (r.tag !== "Ok") return r as unknown as Result; + if (r.tag !== "Ok") return passThrough(r); try { - return (await f(r.value)) as Result; + return await f(r.value); } catch (cause) { return defectRes(cause); } @@ -369,11 +384,11 @@ export class AsyncRes implements AsyncResult { ): AsyncResult { return new AsyncRes( this.promise.then(async (r) => { - if (r.tag !== "Ok") return r as unknown as Result; + if (r.tag !== "Ok") return passThrough(r); try { - const inner = (await f(r.value)) as Result; + const inner = await f(r.value); // Keep the original value on success; an Err/Defect from `f` wins. - return (inner.tag === "Ok" ? r : inner) as unknown as Result; + return inner.tag === "Ok" ? r : passThrough(inner); } catch (cause) { return defectRes(cause); } @@ -385,17 +400,16 @@ export class AsyncRes implements AsyncResult { name: K, f: (scope: T) => Result | AsyncResult, ): AsyncResult, E | E2> { - type Merged = Bound; - return new AsyncRes( + return new AsyncRes, E | E2>( this.promise.then(async (r) => { - if (r.tag !== "Ok") return r as unknown as Result; + if (r.tag !== "Ok") return passThrough(r); try { - const inner = (await f(r.value)) as Result; - if (inner.tag !== "Ok") return inner as unknown as Result; - return okRes({ - ...(r.value as object), - [name]: inner.value, - }) as unknown as Result; + const inner = await f(r.value); + if (inner.tag !== "Ok") return passThrough(inner); + return okRes({ ...(r.value as object), [name]: inner.value }) as unknown as Result< + Bound, + E | E2 + >; } catch (cause) { return defectRes(cause); } @@ -404,15 +418,14 @@ export class AsyncRes implements AsyncResult { } let(name: K, f: (scope: T) => U): AsyncResult, E> { - type Merged = Bound; - return new AsyncRes( + return new AsyncRes, E>( this.promise.then((r) => { - if (r.tag !== "Ok") return r as unknown as Result; + if (r.tag !== "Ok") return passThrough(r); try { - return okRes({ - ...(r.value as object), - [name]: f(r.value), - }) as unknown as Result; + return okRes({ ...(r.value as object), [name]: f(r.value) }) as unknown as Result< + Bound, + E + >; } catch (cause) { return defectRes(cause); } @@ -422,16 +435,14 @@ export class AsyncRes implements AsyncResult { as(value: U): AsyncResult { return new AsyncRes( - this.promise.then((r) => - r.tag === "Ok" ? okRes(value) : (r as unknown as Result), - ), + this.promise.then((r) => (r.tag === "Ok" ? okRes(value) : passThrough(r))), ); } mapErr(f: (error: E) => E2): AsyncResult { return new AsyncRes( this.promise.then((r) => { - if (r.tag !== "Err") return r as unknown as Result; + if (r.tag !== "Err") return passThrough(r); try { return errRes(f(r.error)); } catch (cause) { @@ -444,9 +455,9 @@ export class AsyncRes implements AsyncResult { orElse(f: (error: E) => Result | AsyncResult): AsyncResult { return new AsyncRes( this.promise.then(async (r) => { - if (r.tag !== "Err") return r as unknown as Result; + if (r.tag !== "Err") return passThrough(r); try { - return (await f(r.error)) as Result; + return await f(r.error); } catch (cause) { return defectRes(cause); } @@ -457,7 +468,7 @@ export class AsyncRes implements AsyncResult { recover(f: (error: E) => U): AsyncResult { return new AsyncRes( this.promise.then((r) => { - if (r.tag !== "Err") return r as unknown as Result; + if (r.tag !== "Err") return passThrough(r); try { return okRes(f(r.error)); } catch (cause) { @@ -486,9 +497,9 @@ export class AsyncRes implements AsyncResult { ): AsyncResult { return new AsyncRes( this.promise.then(async (r) => { - if (r.tag !== "Defect") return r as unknown as Result; + if (r.tag !== "Defect") return r; try { - return (await f(r.cause)) as Result; + return await f(r.cause); } catch (cause) { return defectRes(cause); } From 26e478e3ec680cbdfb53546cf1b98067a96a13eb Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sat, 27 Jun 2026 23:21:25 +0200 Subject: [PATCH 4/5] =?UTF-8?q?refactor!:=20capitalize=20constructors=20(o?= =?UTF-8?q?k/err/defect=20=E2=86=92=20Ok/Err/Defect)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Align the value constructors with the discriminated-union tags and the capitalized `Do`. Facade (`Result.Ok`/`Err`/`Defect`) and pattern constructors (`P.Ok`/`Err`/`Defect`) follow. Match handler keys, the `is*` guards, and the "defect channel" terminology are unchanged. `Err` (not `Error`) avoids shadowing the global. BREAKING CHANGE: ok/err/defect renamed to Ok/Err/Defect across unthrown and @unthrown/pattern. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/capitalize-constructors.md | 16 ++++ CLAUDE.md | 12 +-- README.md | 6 +- docs/api/index.md | 4 +- docs/guide/boundaries.md | 10 +-- docs/guide/core-concepts.md | 38 ++++---- docs/guide/do-notation.md | 8 +- docs/guide/getting-started.md | 6 +- docs/guide/interop.md | 10 +-- docs/guide/pattern-matching.md | 20 ++--- docs/guide/recipes.md | 10 +-- docs/guide/testing.md | 18 ++-- docs/guide/the-defect-channel.md | 6 +- docs/index.md | 6 +- packages/boxed/README.md | 4 +- packages/boxed/src/index.spec.ts | 14 +-- packages/boxed/src/index.ts | 26 +++--- packages/core/README.md | 4 +- packages/core/src/aggregate.spec.ts | 32 +++---- packages/core/src/async-result.spec.ts | 26 +++--- packages/core/src/constructors.spec.ts | 28 +++--- packages/core/src/constructors.ts | 12 +-- packages/core/src/defect.ts | 12 +-- packages/core/src/do.spec.ts | 24 ++--- packages/core/src/do.ts | 6 +- packages/core/src/facade.spec.ts | 18 ++-- packages/core/src/facade.ts | 20 ++--- packages/core/src/index.ts | 5 +- packages/core/src/interop.spec.ts | 24 ++--- packages/core/src/interop.ts | 44 +++++----- packages/core/src/invariants.spec.ts | 28 +++--- packages/core/src/result.spec.ts | 116 ++++++++++++------------- packages/core/src/tagged.spec.ts | 18 ++-- packages/core/src/types.test-d.ts | 40 ++++----- packages/core/src/types.ts | 18 ++-- packages/effect/README.md | 6 +- packages/effect/src/index.spec.ts | 24 ++--- packages/effect/src/index.ts | 34 ++++---- packages/neverthrow/README.md | 4 +- packages/neverthrow/src/index.spec.ts | 16 ++-- packages/neverthrow/src/index.ts | 26 +++--- packages/pattern/README.md | 12 +-- packages/pattern/src/index.spec.ts | 42 ++++----- packages/pattern/src/index.ts | 34 ++++---- packages/standard-schema/src/index.ts | 14 +-- packages/vitest/README.md | 10 +-- packages/vitest/src/index.spec.ts | 54 ++++++------ packages/vitest/src/index.ts | 2 +- 48 files changed, 491 insertions(+), 476 deletions(-) create mode 100644 .changeset/capitalize-constructors.md diff --git a/.changeset/capitalize-constructors.md b/.changeset/capitalize-constructors.md new file mode 100644 index 0000000..7675e7e --- /dev/null +++ b/.changeset/capitalize-constructors.md @@ -0,0 +1,16 @@ +--- +"unthrown": major +"@unthrown/standard-schema": patch +--- + +**BREAKING:** capitalize the value constructors so they match the +discriminated-union tags (`"Ok"`/`"Err"`/`"Defect"`) and the capitalized `Do`: + +- `ok` → `Ok`, `err` → `Err`, `defect` → `Defect` +- facade: `Result.ok`/`err`/`defect` → `Result.Ok`/`Err`/`Defect` +- `@unthrown/pattern`: `P.ok`/`err`/`defect` → `P.Ok`/`Err`/`Defect` + +Unchanged: the `match` handler keys (`r.match({ ok, err, defect })`), the guards +(`isOk`/`isErr`/`isDefect`), and the `"defect channel"` terminology. Migration is +a near-mechanical rename of the constructor call sites (`ok(` → `Ok(`, etc.). +Note `Err`, not `Error`, to avoid shadowing the global `Error`. diff --git a/CLAUDE.md b/CLAUDE.md index f04337c..b146a3f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,7 +86,7 @@ async work re-enters via `fromPromise` / `fromSafePromise` and composes with - success: `map`, `flatMap`, `tap`, `flatTap` (a failable `tap` — runs a `Result`-returning effect, keeps the original value, threads the effect's error), `as` -- do-notation: `Do()` (entry — `ok({})`, an empty object scope; capitalised +- do-notation: `Do()` (entry — `Ok({})`, an empty object scope; capitalised because `do` is reserved) plus the methods `bind(name, f)` (sequence a `Result`-returning step, binding its value under `name` in an accumulating **readonly** object scope; errors union `E | E2`) and `let(name, f)` (bind a @@ -101,7 +101,7 @@ async work re-enters via `fromPromise` / `fromSafePromise` and composes with `isOk`/`isErr`/`isDefect` both narrow (to `OkView`/`ErrView`/`DefectView`) — the methods are `this is …` type predicates, so `if (r.isErr()) r.error` compiles. One narrowing concept, two call styles. -- constructors: `ok`, `err`, `defect` +- constructors: `Ok`, `Err`, `Defect` - interop: `fromNullable`, `fromThrowable`, `fromPromise`, `fromSafePromise` - aggregate: `all` / `allAsync` take a **tuple/array** (a fixed tuple keeps positional types; a dynamic `Result[]` / `AsyncResult[]` collapses @@ -115,7 +115,7 @@ async work re-enters via `fromPromise` / `fromSafePromise` and composes with reject. The record fold writes keys via `Object.defineProperty`, so a caller-supplied `"__proto__"` key can't pollute the prototype. - facade: a `Result` companion object aliases the standalone entry points - (`Result.ok`/`err`/`defect`/`Do`/`from*`/`all`/`allAsync`/`allFromDict`/`allFromDictAsync`/`is*`) + (`Result.Ok`/`Err`/`Defect`/`Do`/`from*`/`all`/`allAsync`/`allFromDict`/`allFromDictAsync`/`is*`) for discoverability; the free functions remain the primary, tree-shakeable API. One concept, two import styles — not a second concept. @@ -155,10 +155,10 @@ library can be "done". type-checks on the `Ok` variant. `AsyncRes` operates purely on the public `Result` union (wraps a `Promise`, branches on `r.tag`), never on `Res` internals. -- **Builders are free functions** (`ok`, `err`, …) because they tree-shake — and +- **Builders are free functions** (`Ok`, `Err`, …) because they tree-shake — and there is a `bundle-size` CI gate that protects this. The `Result` companion object is additive sugar (value + type share the name via a re-alias in - `facade.ts`); it must stay a separate export so `import { ok }` never pulls it + `facade.ts`); it must stay a separate export so `import { Ok }` never pulls it in. - **`AsyncResult` is `Awaitable>`, not `PromiseLike`.** Its `then` stays a runtime thenable (so `await` collapses it) and forwards `onrejected` @@ -166,7 +166,7 @@ library can be "done". promise never rejects. - **Source layout** (`packages/core/src/`): `types.ts` (public types), `defect.ts` (the `Defect` marker), `core.ts` (the `Res`/`AsyncRes` engine + `UnwrapError`), - `constructors.ts` (`ok`/`err` + guards), `interop.ts` (`from*`/`qualify`/`all`), + `constructors.ts` (`Ok`/`Err` + guards), `interop.ts` (`from*`/`qualify`/`all`), `facade.ts` (the `Result` object), `tagged.ts` (`TaggedError`/`matchTags`), and `index.ts` (the curated public re-exports — the one place the API is decided). diff --git a/README.md b/README.md index 8afd543..8348cb4 100644 --- a/README.md +++ b/README.md @@ -50,13 +50,13 @@ pnpm add unthrown ## Example ```ts -import { fromPromise, defect, TaggedError } from "unthrown"; +import { fromPromise, Defect, TaggedError } from "unthrown"; class NotFound extends TaggedError("NotFound") {} // Cross an async boundary — every rejection MUST be triaged into E or a defect. const user = fromPromise(fetchUser(id), (cause) => - cause instanceof NotFoundError ? new NotFound() : defect(cause), + cause instanceof NotFoundError ? new NotFound() : Defect(cause), ); // Handle every channel once, at the edge — no surrounding try/catch. @@ -79,7 +79,7 @@ defect, so the edge of your program needs a single `match` and no `try`/`catch`. | ----------------------------------------------- | ---------------------------------------------------------------------------------------------- | | [`unthrown`](./packages/core) | The core `Result` / `AsyncResult`, interop, `TaggedError`, `matchTags`. Zero runtime deps. | | [`@unthrown/vitest`](./packages/vitest) | Vitest matchers: `toBeOk`, `toBeOkWith`, `toBeErr`, `toBeErrTagged`, `toBeDefect`. | -| [`@unthrown/pattern`](./packages/pattern) | Thin `ts-pattern` sugar for the natively-matchable `Result`: `P.ok`/`P.err`/`P.defect`, `tag`. | +| [`@unthrown/pattern`](./packages/pattern) | Thin `ts-pattern` sugar for the natively-matchable `Result`: `P.Ok`/`P.Err`/`P.Defect`, `tag`. | | [`@unthrown/effect`](./packages/effect) | Effect interop: `Result ↔ Exit` (bijection), `Either`, `Effect`. | | [`@unthrown/neverthrow`](./packages/neverthrow) | neverthrow interop: `Result ↔ Result`, `AsyncResult ↔ ResultAsync`. | | [`@unthrown/boxed`](./packages/boxed) | Boxed interop: `Result ↔ Result`, `AsyncResult ↔ Future`. | diff --git a/docs/api/index.md b/docs/api/index.md index c7e6474..696047f 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -6,11 +6,11 @@ This reference is generated from the source with ## Packages - [**unthrown**](./core/) — the core `Result` / `AsyncResult` types, - constructors (`ok`, `err`, `defect`), guards, boundary interop + constructors (`Ok`, `Err`, `Defect`), guards, boundary interop (`fromNullable`, `fromThrowable`, `fromPromise`, `fromSafePromise`), aggregation (`all`), and the tagged-error utilities (`TaggedError`, `matchTags`). - [**@unthrown/vitest**](./vitest/) — custom Vitest matchers (`toBeOk`, `toBeOkWith`, `toBeErr`, `toBeErrTagged`, `toBeDefect`). - [**@unthrown/pattern**](./pattern/) — thin `ts-pattern` sugar for the - natively-matchable `Result` (`P.ok`/`P.err`/`P.defect`, `tag`). + natively-matchable `Result` (`P.Ok`/`P.Err`/`P.Defect`, `tag`). diff --git a/docs/guide/boundaries.md b/docs/guide/boundaries.md index 87f5980..e9d223a 100644 --- a/docs/guide/boundaries.md +++ b/docs/guide/boundaries.md @@ -26,10 +26,10 @@ a `qualify` function that triages the thrown cause into a modeled error `E` or a `defect`: ```ts -import { fromThrowable, defect } from "unthrown"; +import { fromThrowable, Defect } from "unthrown"; const parse = fromThrowable(JSON.parse, (cause) => - cause instanceof SyntaxError ? ("invalid_json" as const) : defect(cause), + cause instanceof SyntaxError ? ("invalid_json" as const) : Defect(cause), ); parse("{ not json"); // Err("invalid_json") @@ -44,10 +44,10 @@ A throw _inside_ `qualify` is itself treated as a defect. [`AsyncResult`](./async-results). Every rejection **must** be triaged: ```ts -import { fromPromise, defect } from "unthrown"; +import { fromPromise, Defect } from "unthrown"; const user = fromPromise(fetchUser(id), (cause) => - cause instanceof NotFoundError ? new NotFound() : defect(cause), + cause instanceof NotFoundError ? new NotFound() : Defect(cause), ); ``` @@ -58,7 +58,7 @@ The boxed/neverthrow "original sin" is `fromPromise(p): AsyncResult` The error channel is inferred as `Exclude` — the `Defect` arm of `qualify`'s return is **subtracted**, never inferred into `E`. So a `qualify` -that returns _only_ `defect(cause)` gives `AsyncResult`, not +that returns _only_ `Defect(cause)` gives `AsyncResult`, not `AsyncResult` — a defect stays out-of-band. (When _every_ rejection is a defect, reach for `fromSafePromise` below.) diff --git a/docs/guide/core-concepts.md b/docs/guide/core-concepts.md index e94ce65..96bae2f 100644 --- a/docs/guide/core-concepts.md +++ b/docs/guide/core-concepts.md @@ -35,9 +35,9 @@ A combinator only runs its callback on its own channel — the other states flow through untouched: ```ts -ok(2).map((n) => n + 1); // Ok(3) -err("e").map((n) => n + 1); // Err("e") — callback skipped -ok(2).mapErr((e) => `${e}!`); // Ok(2) — callback skipped +Ok(2).map((n) => n + 1); // Ok(3) +Err("e").map((n) => n + 1); // Err("e") — callback skipped +Ok(2).mapErr((e) => `${e}!`); // Ok(2) — callback skipped ``` `tap` and `flatTap` both run a side effect and keep the original value — the @@ -47,7 +47,7 @@ threads its error (a validation or write whose _outcome_ matters but whose _value_ you don't need): ```ts -ok(user) +Ok(user) .flatTap((u) => writeAudit(u)) // returns Result .map((u) => u.name); // → still the original user on success; short-circuits to WriteError on failure @@ -58,7 +58,7 @@ ok(user) The constructors and helpers are tree-shakeable free functions: ```ts -import { ok, err, fromNullable, all } from "unthrown"; +import { Ok, Err, fromNullable, all } from "unthrown"; ``` If you prefer a namespace, a `Result` companion object aliases the same entry @@ -67,13 +67,13 @@ points — handy for discoverability: ```ts import { Result } from "unthrown"; -Result.ok(1); +Result.Ok(1); Result.fromNullable(map.get(key), () => "absent"); -Result.all([Result.ok(1), Result.ok(2)]); +Result.all([Result.Ok(1), Result.Ok(2)]); ``` The free functions remain the primary, tree-shakeable API; `Result.*` is a -zero-cost alias (a separate export — `import { ok }` never pulls it in). +zero-cost alias (a separate export — `import { Ok }` never pulls it in). ## Guards that narrow @@ -85,7 +85,7 @@ predicates): ```ts import { isOk, isErr } from "unthrown"; -const r: Result = ok(7); +const r: Result = Ok(7); // standalone function if (isOk(r)) { @@ -103,11 +103,11 @@ if (r.isErr()) { Once you are ready to leave the `Result` world, pick the right exit: ```ts -ok(1).unwrap(); // 1 — throws on Err/Defect -err("e").unwrapErr(); // "e" — throws on Ok/Defect -err("e").unwrapOr(0); // 0 — recovers an Err; rethrows a Defect -err("e").getOrNull(); // null — recovers an Err; rethrows a Defect -ok(1).match({ ok, err, defect }); // fold all three channels +Ok(1).unwrap(); // 1 — throws on Err/Defect +Err("e").unwrapErr(); // "e" — throws on Ok/Defect +Err("e").unwrapOr(0); // 0 — recovers an Err; rethrows a Defect +Err("e").getOrNull(); // null — recovers an Err; rethrows a Defect +Ok(1).match({ ok, err, defect }); // fold all three channels ``` `unwrapOr`, `unwrapOrElse`, `getOrNull`, and `getOrUndefined` recover a modeled @@ -122,19 +122,19 @@ earlier `Err`). A fixed tuple keeps its positional types; a dynamic `Result[]` collapses to `Result`: ```ts -import { all, ok, type Result } from "unthrown"; +import { all, Ok, type Result } from "unthrown"; -all([ok(1), ok("two"), ok(true)]).unwrap(); // [1, "two", true] (typed [number, string, boolean]) -all([ok(1), ok(2)] as Result[]).unwrap(); // number[] +all([Ok(1), Ok("two"), Ok(true)]).unwrap(); // [1, "two", true] (typed [number, string, boolean]) +all([Ok(1), Ok(2)] as Result[]).unwrap(); // number[] ``` For **named** parallel work, `allFromDict` takes a record instead — same rules, no tupling: ```ts -import { allFromDict, ok } from "unthrown"; +import { allFromDict, Ok } from "unthrown"; -allFromDict({ id: ok(1), name: ok("ada") }).unwrap(); // { id: 1, name: "ada" } +allFromDict({ id: Ok(1), name: Ok("ada") }).unwrap(); // { id: 1, name: "ada" } ``` Both short-circuit on the first `Err` — this is **not** error accumulation. If diff --git a/docs/guide/do-notation.md b/docs/guide/do-notation.md index a59eb96..da4ae2c 100644 --- a/docs/guide/do-notation.md +++ b/docs/guide/do-notation.md @@ -40,7 +40,7 @@ freely, and a thrown callback still becomes a `Defect`: ```ts Do() - .bind("n", () => ok(2)) + .bind("n", () => Ok(2)) .let("doubled", ({ n }) => n * 2) .match({ ok: ({ n, doubled }) => `${n} → ${doubled}`, @@ -56,12 +56,12 @@ To sequence asynchronous steps, lift the chain with `toAsync()`. From there a [Boundaries](./boundaries)): ```ts -import { Do, fromPromise, defect } from "unthrown"; +import { Do, fromPromise, Defect } from "unthrown"; const profile = await Do() .toAsync() - .bind("user", () => fromPromise(fetchUser(id), (c) => defect(c))) - .bind("posts", ({ user }) => fromPromise(fetchPosts(user.id), (c) => defect(c))) + .bind("user", () => fromPromise(fetchUser(id), (c) => Defect(c))) + .bind("posts", ({ user }) => fromPromise(fetchPosts(user.id), (c) => Defect(c))) .let("count", ({ posts }) => posts.length) .match({ ok: (s) => s, err: () => null, defect: () => null }); ``` diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 9dc5f22..5f119ef 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -24,13 +24,13 @@ and has **zero runtime dependencies**. It targets TypeScript with `strict` mode. ## Your first Result A `Result` is either an **Ok** carrying a value `T`, or an **Err** carrying -a _modeled_ error `E`. Build them with `ok` and `err`: +a _modeled_ error `E`. Build them with `Ok` and `Err`: ```ts -import { ok, err, type Result } from "unthrown"; +import { Ok, Err, type Result } from "unthrown"; function half(n: number): Result { - return n % 2 === 0 ? ok(n / 2) : err("odd"); + return n % 2 === 0 ? Ok(n / 2) : Err("odd"); } ``` diff --git a/docs/guide/interop.md b/docs/guide/interop.md index f6655e5..410f9fa 100644 --- a/docs/guide/interop.md +++ b/docs/guide/interop.md @@ -25,11 +25,11 @@ libraries have only two. That single difference decides every signature. form. ```ts -import { ok } from "unthrown"; +import { Ok } from "unthrown"; import { toNeverthrow } from "@unthrown/neverthrow"; // onDefect is required — the compiler will not let you drop a defect. -toNeverthrow(ok(1), (cause) => ({ _tag: "Bug", cause })); +toNeverthrow(Ok(1), (cause) => ({ _tag: "Bug", cause })); ``` ## Effect — a genuine bijection @@ -38,12 +38,12 @@ Effect is the exception: it _does_ have a defect channel (`Cause.die`), so `Result ↔ Exit` round-trips losslessly. ```ts -import { ok, err } from "unthrown"; +import { Ok, Err } from "unthrown"; import { toExit, fromEffect } from "@unthrown/effect"; import { Effect } from "effect"; -toExit(ok(1)); // Exit.succeed(1) -toExit(err("e")); // Exit.fail("e") — a modeled Cause.fail +toExit(Ok(1)); // Exit.succeed(1) +toExit(Err("e")); // Exit.fail("e") — a modeled Cause.fail // a Defect would become Exit.die(cause) // Run an Effect and collect its outcome; a die/interrupt becomes a Defect: diff --git a/docs/guide/pattern-matching.md b/docs/guide/pattern-matching.md index 8a62acc..2173e0f 100644 --- a/docs/guide/pattern-matching.md +++ b/docs/guide/pattern-matching.md @@ -49,20 +49,20 @@ import { match } from "ts-pattern"; import * as P from "@unthrown/pattern"; const status = match(result) - .with(P.ok(), ({ value }) => 200) - .with(P.err(P.tag("NotFound")), () => 404) - .with(P.err(P.tag("Forbidden")), ({ error }) => { + .with(P.Ok(), ({ value }) => 200) + .with(P.Err(P.tag("NotFound")), () => 404) + .with(P.Err(P.tag("Forbidden")), ({ error }) => { audit(error.user); // narrowed to Forbidden — payload available return 403; }) - .with(P.defect(), ({ cause }) => 500) + .with(P.Defect(), ({ cause }) => 500) .exhaustive(); ``` -- `P.ok(sub?)` / `P.err(sub?)` / `P.defect(sub?)` — match a channel; pass a +- `P.Ok(sub?)` / `P.Err(sub?)` / `P.Defect(sub?)` — match a channel; pass a sub-pattern to constrain or select the payload: a literal, or any `ts-pattern` pattern. -- `P.tag(t)` — sugar for `{ _tag: t }`; nested in `P.err(...)` it narrows to the +- `P.tag(t)` — sugar for `{ _tag: t }`; nested in `P.Err(...)` it narrows to the matching tagged-error variant, payload and all. Above, `P` is `@unthrown/pattern`. To also use `ts-pattern`'s own patterns @@ -74,7 +74,7 @@ import { match, P as t } from "ts-pattern"; import * as P from "@unthrown/pattern"; match(result) - .with(P.ok(t.select()), (value) => value) // t.select() is ts-pattern's + .with(P.Ok(t.select()), (value) => value) // t.select() is ts-pattern's .otherwise(() => 0); ``` @@ -87,9 +87,9 @@ result is a plain, matchable `Result`: ```ts const status = match(await asyncResult) - .with(P.ok(), () => 200) - .with(P.err(), () => 400) - .with(P.defect(), () => 500) + .with(P.Ok(), () => 200) + .with(P.Err(), () => 400) + .with(P.Defect(), () => 500) .exhaustive(); ``` diff --git a/docs/guide/recipes.md b/docs/guide/recipes.md index 857fd79..a4f53d2 100644 --- a/docs/guide/recipes.md +++ b/docs/guide/recipes.md @@ -26,13 +26,13 @@ modeled statuses are mapped in a `flatMap` (a `throw` there, like an unexpected status or malformed JSON, becomes a `Defect`): ```ts -import { fromPromise, err, defect } from "unthrown"; +import { fromPromise, Err, Defect } from "unthrown"; const loadProfile = (id: string) => // A network error (a rejected fetch) is unexpected → defect. fromPromise(fetch(`/api/users/${id}`), defect).flatMap((res) => { - if (res.status === 404) return err(new NotFound({ id })); - if (res.status === 403) return err(new Forbidden()); + if (res.status === 404) return Err(new NotFound({ id })); + if (res.status === 403) return Err(new Forbidden()); if (!res.ok) throw new Error(`unexpected status ${res.status}`); // → Defect return fromPromise(res.json() as Promise, defect); // malformed JSON → Defect }); @@ -61,11 +61,11 @@ Third-party code that throws is bridged once, at the boundary, with or a defect. ```ts -import { fromThrowable, defect } from "unthrown"; +import { fromThrowable, Defect } from "unthrown"; const parseProfile = fromThrowable( (raw: string) => JSON.parse(raw) as Profile, - (cause) => (cause instanceof SyntaxError ? new InvalidProfile({ field: "json" }) : defect(cause)), + (cause) => (cause instanceof SyntaxError ? new InvalidProfile({ field: "json" }) : Defect(cause)), ); parseProfile('{"name":"Ada"}'); // Result diff --git a/docs/guide/testing.md b/docs/guide/testing.md index 054dd76..fb9b7c7 100644 --- a/docs/guide/testing.md +++ b/docs/guide/testing.md @@ -33,18 +33,18 @@ import "@unthrown/vitest"; ## The matchers ```ts -import { ok, err } from "unthrown"; +import { Ok, Err } from "unthrown"; import { expect, test } from "vitest"; test("matchers", () => { - expect(ok(1)).toBeOk(); - expect(ok(1)).toBeOkWith(1); // deep equality on the value - expect(err("e")).toBeErr(); - expect(err(new NotFound())).toBeErrTagged("NotFound"); + expect(Ok(1)).toBeOk(); + expect(Ok(1)).toBeOkWith(1); // deep equality on the value + expect(Err("e")).toBeErr(); + expect(Err(new NotFound())).toBeErrTagged("NotFound"); expect(aDefect).toBeDefect(); // negations work too - expect(ok(1)).not.toBeErr(); + expect(Ok(1)).not.toBeErr(); }); ``` @@ -54,19 +54,19 @@ sets. A plain object matches it **exactly**; an asymmetric matcher matches it **partially**: ```ts -import { err, TaggedError } from "unthrown"; +import { Err, TaggedError } from "unthrown"; import { expect } from "vitest"; class NotFound extends TaggedError("NotFound")<{ id: number; msg: string }> {} // exact — every payload field must match -expect(err(new NotFound({ id: 1, msg: "nope" }))).toBeErrTagged("NotFound", { +expect(Err(new NotFound({ id: 1, msg: "nope" }))).toBeErrTagged("NotFound", { id: 1, msg: "nope", }); // partial — only the listed fields are checked -expect(err(new NotFound({ id: 1, msg: "nope" }))).toBeErrTagged( +expect(Err(new NotFound({ id: 1, msg: "nope" }))).toBeErrTagged( "NotFound", expect.objectContaining({ id: 1 }), ); diff --git a/docs/guide/the-defect-channel.md b/docs/guide/the-defect-channel.md index 2c999a6..71dcaca 100644 --- a/docs/guide/the-defect-channel.md +++ b/docs/guide/the-defect-channel.md @@ -14,7 +14,7 @@ Any value thrown by a callback inside a combinator is **caught and converted to a defect**, never allowed to escape: ```ts -const r = ok(1).map(() => { +const r = Ok(1).map(() => { throw new Error("boom"); }); @@ -30,7 +30,7 @@ A defect passes through **every** method untouched — _except_ `match` and `recoverDefect`. The success and error combinators never see it: ```ts -const d = ok(1).map(() => { +const d = Ok(1).map(() => { throw boom; }); @@ -87,7 +87,7 @@ only combinator that can observe a defect, and it re-enters the modeled world by returning a `Result`: ```ts -d.recoverDefect((cause) => (cause instanceof RangeError ? err("out_of_range") : err("unknown"))); +d.recoverDefect((cause) => (cause instanceof RangeError ? Err("out_of_range") : Err("unknown"))); ``` Use `tapDefect` to observe a defect's cause (e.g. logging) without changing it. diff --git a/docs/index.md b/docs/index.md index bcfadac..f923a0b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -39,18 +39,18 @@ features: ## At a glance ```ts -import { ok, err, fromPromise, type Result } from "unthrown"; +import { Ok, Err, fromPromise, type Result } from "unthrown"; class NotFound extends TaggedError("NotFound") {} function findUser(id: string): Result { const user = users.get(id); - return user ? ok(user) : err(new NotFound()); + return user ? Ok(user) : Err(new NotFound()); } // Cross an async boundary — every rejection MUST be triaged. const profile = fromPromise(fetch(`/u/${id}`), (cause) => - cause instanceof Response ? new NotFound() : defect(cause), + cause instanceof Response ? new NotFound() : Defect(cause), ); // Handle every channel once, at the edge — no surrounding try/catch. diff --git a/packages/boxed/README.md b/packages/boxed/README.md index 48112a4..3138707 100644 --- a/packages/boxed/README.md +++ b/packages/boxed/README.md @@ -16,11 +16,11 @@ Boxed's `Result` has two channels (`Ok`/`Error`) and no defect channel. Coming with `onDefect` — no defect is ever silently folded into your domain error type. ```ts -import { ok } from "unthrown"; +import { Ok } from "unthrown"; import { toBoxed, fromBoxed } from "@unthrown/boxed"; import { Result } from "@bloodyowl/boxed"; -toBoxed(ok(1), (cause) => ({ _tag: "Bug", cause })); // Result.Ok(1) +toBoxed(Ok(1), (cause) => ({ _tag: "Bug", cause })); // Result.Ok(1) fromBoxed(Result.Ok(1)); // Result ``` diff --git a/packages/boxed/src/index.spec.ts b/packages/boxed/src/index.spec.ts index e970b41..e372d8f 100644 --- a/packages/boxed/src/index.spec.ts +++ b/packages/boxed/src/index.spec.ts @@ -1,23 +1,23 @@ import { Future, Result as BoxedResult } from "@bloodyowl/boxed"; -import { err, ok, type Result } from "unthrown"; +import { Err, Ok, type Result } from "unthrown"; import { describe, expect, it } from "vitest"; import { fromBoxed, fromBoxedFuture, toBoxed, toBoxedFuture } from "./index.js"; const boom = new Error("boom"); -const aDefect: Result = ok(0).map(() => { +const aDefect: Result = Ok(0).map(() => { throw boom; }); describe("toBoxed", () => { it("maps Ok and Err across", () => { - const okR = toBoxed(ok(1), () => "x"); + const okR = toBoxed(Ok(1), () => "x"); expect(okR.isOk() && okR.get()).toBe(1); - const errR = toBoxed(err("nope") as Result, () => "x"); + const errR = toBoxed(Err("nope") as Result, () => "x"); expect(errR.isError() && errR.getError()).toBe("nope"); }); - it("forces a defect to be triaged into the error channel", () => { + it("forces a Defect to be triaged into the error channel", () => { const r = toBoxed(aDefect, (cause) => `bug:${String(cause)}`); expect(r.isError() && r.getError()).toBe(`bug:${String(boom)}`); }); @@ -31,8 +31,8 @@ describe("fromBoxed", () => { }); describe("toBoxedFuture", () => { - it("maps Ok across and triages a defect", async () => { - const okR = await toBoxedFuture(ok(1).toAsync(), () => "x").toPromise(); + it("maps Ok across and triages a Defect", async () => { + const okR = await toBoxedFuture(Ok(1).toAsync(), () => "x").toPromise(); expect(okR.isOk() && okR.get()).toBe(1); const defR = await toBoxedFuture( aDefect.toAsync(), diff --git a/packages/boxed/src/index.ts b/packages/boxed/src/index.ts index 89f7c7f..742b029 100644 --- a/packages/boxed/src/index.ts +++ b/packages/boxed/src/index.ts @@ -1,34 +1,34 @@ // @unthrown/boxed — interop between unthrown's `Result`/`AsyncResult` and // Boxed's `Result`/`Future`. // -// Boxed's `Result` has two channels (`Ok`/`Error`) and no defect channel. +// Boxed's `Result` has two channels (`Ok`/`Error`) and no Defect channel. // Coming *in*, every Boxed result is an `Ok` or `Error` — never a `Defect`. // Going *out*, a `Defect` has nowhere to live, so `toBoxed` forces you to triage -// it with `onDefect` (Thesis #3): no defect is ever silently folded into your +// it with `onDefect` (Thesis #3): no Defect is ever silently folded into your // domain error type. // -// import { ok } from "unthrown"; +// import { Ok } from "unthrown"; // import { toBoxed, fromBoxed } from "@unthrown/boxed"; // -// toBoxed(ok(1), (cause) => ({ _tag: "Bug", cause })); // Result.Ok(1) +// toBoxed(Ok(1), (cause) => ({ _tag: "Bug", cause })); // Result.Ok(1) // fromBoxed(Result.Ok(1)); // Result import { Future, Result as BoxedResult } from "@bloodyowl/boxed"; -import { err, fromSafePromise, ok } from "unthrown"; +import { Err, fromSafePromise, Ok } from "unthrown"; import type { AsyncResult, Result } from "unthrown"; /** - * Convert a `Result` into a Boxed `Result`, triaging any defect. + * Convert a `Result` into a Boxed `Result`, triaging any Defect. * * @remarks - * Boxed's `Result` has no defect channel, so `onDefect` **must** fold a + * Boxed's `Result` has no Defect channel, so `onDefect` **must** fold a * `Defect`'s cause into a modeled error `E` (an `Error`). `Ok → Result.Ok`, * `Err → Result.Error`, `Defect → Result.Error(onDefect(cause))`. * * @typeParam T - the success value type. * @typeParam E - the modeled error type. * @param result - the result to convert. - * @param onDefect - folds a defect's unknown cause into a modeled `E`. + * @param onDefect - folds a Defect's unknown cause into a modeled `E`. */ export function toBoxed( result: Result, @@ -45,7 +45,7 @@ export function toBoxed( * Convert a Boxed `Result` into a `Result`. * * @remarks - * `Result.Ok → Ok`, `Result.Error → Err`. Boxed's `Result` carries no defect, so + * `Result.Ok → Ok`, `Result.Error → Err`. Boxed's `Result` carries no Defect, so * the result is never a `Defect`. * * @typeParam T - the success value type. @@ -54,14 +54,14 @@ export function toBoxed( */ export function fromBoxed(result: BoxedResult): Result { return result.match({ - Ok: (value) => ok(value), - Error: (error) => err(error), + Ok: (value) => Ok(value), + Error: (error) => Err(error), }); } /** * Convert an `AsyncResult` into a Boxed `Future`, triaging any - * defect. + * Defect. * * @remarks * The async counterpart of {@link toBoxed}: `onDefect` is required for the same @@ -71,7 +71,7 @@ export function fromBoxed(result: BoxedResult): Result { * @typeParam T - the success value type. * @typeParam E - the modeled error type. * @param asyncResult - the async result to convert. - * @param onDefect - folds a defect's unknown cause into a modeled `E`. + * @param onDefect - folds a Defect's unknown cause into a modeled `E`. */ export function toBoxedFuture( asyncResult: AsyncResult, diff --git a/packages/core/README.md b/packages/core/README.md index 9878d23..e686957 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -11,10 +11,10 @@ pnpm add unthrown ``` ```ts -import { ok, err, fromPromise, defect, type Result } from "unthrown"; +import { Ok, Err, fromPromise, Defect, type Result } from "unthrown"; const user = fromPromise(fetchUser(id), (cause) => - cause instanceof NotFoundError ? new NotFound() : defect(cause), + cause instanceof NotFoundError ? new NotFound() : Defect(cause), ); const status = await user.match({ diff --git a/packages/core/src/aggregate.spec.ts b/packages/core/src/aggregate.spec.ts index 78d9009..3f67599 100644 --- a/packages/core/src/aggregate.spec.ts +++ b/packages/core/src/aggregate.spec.ts @@ -6,22 +6,22 @@ import { allFromDict, allFromDictAsync, type AsyncResult, - err, + Err, fromPromise, fromSafePromise, - ok, + Ok, type Result, } from "./index.js"; const boom = new Error("boom"); const defectOf = (cause: unknown): Result => - ok(0).map(() => { + Ok(0).map(() => { throw cause; }); describe("all", () => { it("collects a tuple of Ok values, preserving positional types", () => { - const r = all([ok(1), ok("two"), ok(true)]); + const r = all([Ok(1), Ok("two"), Ok(true)]); expect(r.unwrap()).toEqual([1, "two", true]); }); @@ -34,46 +34,46 @@ describe("all", () => { }); it("short-circuits on the first Err", () => { - const r = all([ok(1), err("first"), err("second")]); + const r = all([Ok(1), Err("first"), Err("second")]); expect(r.unwrapErr()).toBe("first"); }); it("lets any Defect dominate, even over an earlier Err", () => { - const r = all([ok(1), err("e"), defectOf(boom)]); + const r = all([Ok(1), Err("e"), defectOf(boom)]); expect(r.isDefect()).toBe(true); - expect(r.recoverDefect((c) => ok(c === boom)).unwrap()).toBe(true); + expect(r.recoverDefect((c) => Ok(c === boom)).unwrap()).toBe(true); }); it("keeps the first Defect when several are present", () => { const first = new Error("first"); const r = all([defectOf(first), defectOf(new Error("second"))]); - expect(r.recoverDefect((c) => ok(c === first)).unwrap()).toBe(true); + expect(r.recoverDefect((c) => Ok(c === first)).unwrap()).toBe(true); }); it("collapses a dynamic Result[] to Result without a cast", () => { // The array overload: no `as` needed, even in a generic-feeling shape. const combine = (rs: Result[]): Result => all(rs); - const r = combine([ok(1), ok(2), ok(3)] as Result[]); + const r = combine([Ok(1), Ok(2), Ok(3)] as Result[]); expect(r.unwrap()).toEqual([1, 2, 3]); - const e = combine([ok(1), err("bad")] as Result[]); + const e = combine([Ok(1), Err("bad")] as Result[]); expect(e.unwrapErr()).toBe("bad"); }); }); describe("allFromDict", () => { it("collects a record of Ok values into a record, keyed by name", () => { - const r = allFromDict({ id: ok(1), name: ok("ada"), admin: ok(true) }); + const r = allFromDict({ id: Ok(1), name: Ok("ada"), admin: Ok(true) }); // Typed `Result<{ id: number; name: string; admin: boolean }, never>`. const value: { id: number; name: string; admin: boolean } = r.unwrap(); expect(value).toEqual({ id: 1, name: "ada", admin: true }); }); it("short-circuits a record on the first Err and lets a Defect dominate", () => { - expect(allFromDict({ a: ok(1), b: err("bad") }).unwrapErr()).toBe("bad"); - const d = allFromDict({ a: ok(1), b: err("e"), c: defectOf(boom) }); + expect(allFromDict({ a: Ok(1), b: Err("bad") }).unwrapErr()).toBe("bad"); + const d = allFromDict({ a: Ok(1), b: Err("e"), c: defectOf(boom) }); expect(d.isDefect()).toBe(true); - expect(d.recoverDefect((c) => ok(c === boom)).unwrap()).toBe(true); + expect(d.recoverDefect((c) => Ok(c === boom)).unwrap()).toBe(true); }); it("returns Ok({}) for an empty record", () => { @@ -81,7 +81,7 @@ describe("allFromDict", () => { }); it("does not let a `__proto__` key pollute the prototype", () => { - const r = allFromDict({ ["__proto__"]: ok({ polluted: true }), safe: ok(1) }); + const r = allFromDict({ ["__proto__"]: Ok({ polluted: true }), safe: Ok(1) }); expect(r.isOk()).toBe(true); // The dangerous key lands as a normal own property, not on Object.prototype. expect(({} as Record)["polluted"]).toBeUndefined(); @@ -116,7 +116,7 @@ describe("allAsync", () => { fromSafePromise(Promise.reject(boom)), ]); expect(r.isDefect()).toBe(true); - expect((await r.toAsync().recoverDefect((c) => ok(c === boom))).unwrap()).toBe(true); + expect((await r.toAsync().recoverDefect((c) => Ok(c === boom))).unwrap()).toBe(true); }); it("never rejects — await always yields a Result", async () => { diff --git a/packages/core/src/async-result.spec.ts b/packages/core/src/async-result.spec.ts index 3e72939..db742b3 100644 --- a/packages/core/src/async-result.spec.ts +++ b/packages/core/src/async-result.spec.ts @@ -1,12 +1,12 @@ import { describe, expect, it, vi } from "vitest"; -import { type AsyncResult, defect, err, fromPromise, fromSafePromise, ok } from "./index.js"; +import { type AsyncResult, Defect, Err, fromPromise, fromSafePromise, Ok } from "./index.js"; const boom = new Error("boom"); -const asyncOk = (v: T): AsyncResult => ok(v).toAsync(); -const asyncErr = (e: E): AsyncResult => err(e).toAsync(); +const asyncOk = (v: T): AsyncResult => Ok(v).toAsync(); +const asyncErr = (e: E): AsyncResult => Err(e).toAsync(); const asyncDefect = (): AsyncResult => - ok(0) + Ok(0) .toAsync() .map(() => { throw boom; @@ -33,7 +33,7 @@ describe("AsyncResult is awaitable and never rejects", () => { const asErr = await fromPromise(Promise.reject("modeled"), (c) => c as string); expect(asErr.unwrapErr()).toBe("modeled"); - const asDefect = await fromPromise(Promise.reject(boom), (c) => defect(c)); + const asDefect = await fromPromise(Promise.reject(boom), (c) => Defect(c)); expect(asDefect.isDefect()).toBe(true); }); @@ -89,7 +89,7 @@ describe("AsyncResult success channel", () => { }); it("flatMap composes with a Result", async () => { - expect((await asyncOk(2).flatMap((n) => ok(n * 5))).unwrap()).toBe(10); + expect((await asyncOk(2).flatMap((n) => Ok(n * 5))).unwrap()).toBe(10); }); it("flatMap composes further async work via a qualified boundary", async () => { @@ -105,12 +105,12 @@ describe("AsyncResult success channel", () => { }); it("flatTap keeps the original value when the effect succeeds", async () => { - const r = await asyncOk(5).flatTap((n) => ok(n * 100)); + const r = await asyncOk(5).flatTap((n) => Ok(n * 100)); expect(r.unwrap()).toBe(5); // original, not 500 }); it("flatTap short-circuits to the effect's Err", async () => { - expect((await asyncOk(5).flatTap(() => err("denied"))).unwrapErr()).toBe("denied"); + expect((await asyncOk(5).flatTap(() => Err("denied"))).unwrapErr()).toBe("denied"); }); it("flatTap composes an async effect via a qualified boundary, keeping the value", async () => { @@ -119,7 +119,7 @@ describe("AsyncResult success channel", () => { }); it("flatTap does not run the effect on Err or Defect", async () => { - const f = vi.fn(() => ok(1)); + const f = vi.fn(() => Ok(1)); expect((await asyncErr("e").flatTap(f)).unwrapErr()).toBe("e"); expect((await asyncDefect().flatTap(f)).isDefect()).toBe(true); expect(f).not.toHaveBeenCalled(); @@ -138,7 +138,7 @@ describe("AsyncResult error channel", () => { }); it("orElse recovers an Err", async () => { - expect((await asyncErr("e").orElse(() => ok(9))).unwrap()).toBe(9); + expect((await asyncErr("e").orElse(() => Ok(9))).unwrap()).toBe(9); }); it("orElse composes async recovery via a qualified boundary", async () => { @@ -160,7 +160,7 @@ describe("AsyncResult error channel", () => { }); }); -describe("AsyncResult defect channel", () => { +describe("AsyncResult Defect channel", () => { it("a Defect flows through the success and error combinators untouched", async () => { const f = vi.fn(); expect((await asyncDefect().map(f)).isDefect()).toBe(true); @@ -170,7 +170,7 @@ describe("AsyncResult defect channel", () => { }); it("recoverDefect is the only door — it replaces the Defect", async () => { - const r = await asyncDefect().recoverDefect((c) => ok(c === boom)); + const r = await asyncDefect().recoverDefect((c) => Ok(c === boom)); expect(r.unwrap()).toBe(true); }); @@ -201,7 +201,7 @@ describe("AsyncResult eliminators", () => { }); it("unwrapOr / unwrapOrElse recover an Err", async () => { - const e: AsyncResult = err("e").toAsync(); + const e: AsyncResult = Err("e").toAsync(); await expect(e.unwrapOr(9)).resolves.toBe(9); await expect(e.unwrapOrElse((s) => s.length)).resolves.toBe(1); }); diff --git a/packages/core/src/constructors.spec.ts b/packages/core/src/constructors.spec.ts index eb5014a..89ee897 100644 --- a/packages/core/src/constructors.spec.ts +++ b/packages/core/src/constructors.spec.ts @@ -1,32 +1,32 @@ import { describe, expect, it } from "vitest"; -import { defect, err, isDefect, isErr, isOk, ok, type Result, UnwrapError } from "./index.js"; +import { Defect, Err, isDefect, isErr, isOk, Ok, type Result, UnwrapError } from "./index.js"; const boom = new Error("boom"); const defectOf = (cause: unknown): Result => - ok(0).map(() => { + Ok(0).map(() => { throw cause; }); describe("constructors", () => { - it("ok wraps a value in the success channel", () => { - const r = ok(42); + it("Ok wraps a value in the success channel", () => { + const r = Ok(42); expect(r.isOk()).toBe(true); expect(r.isErr()).toBe(false); expect(r.isDefect()).toBe(false); expect(r.unwrap()).toBe(42); }); - it("err wraps a modeled error", () => { - const r = err("nope"); + it("Err wraps a modeled error", () => { + const r = Err("nope"); expect(r.isErr()).toBe(true); expect(r.isOk()).toBe(false); expect(r.isDefect()).toBe(false); expect(r.unwrapErr()).toBe("nope"); }); - it("defect wraps a cause as a qualify-time marker (not a Result)", () => { - const marker = defect(boom); + it("Defect wraps a cause as a qualify-time marker (not a Result)", () => { + const marker = Defect(boom); // The marker is opaque; it carries the cause for the boundary to triage. expect(marker).toMatchObject({ cause: boom }); // It is NOT a Result — it has no Result methods. @@ -36,7 +36,7 @@ describe("constructors", () => { describe("standalone guards narrow and expose the relevant field", () => { it("isOk narrows to OkView and exposes value", () => { - const r: Result = ok(7); + const r: Result = Ok(7); expect(isOk(r)).toBe(true); if (isOk(r)) { expect(r.value).toBe(7); @@ -44,7 +44,7 @@ describe("standalone guards narrow and expose the relevant field", () => { }); it("isErr narrows to ErrView and exposes error", () => { - const r: Result = err("bad"); + const r: Result = Err("bad"); expect(isErr(r)).toBe(true); if (isErr(r)) { expect(r.error).toBe("bad"); @@ -60,8 +60,8 @@ describe("standalone guards narrow and expose the relevant field", () => { }); it("guards are mutually exclusive", () => { - const o = ok(1); - const e = err("e"); + const o = Ok(1); + const e = Err("e"); const d = defectOf(boom); expect([isOk(o), isErr(o), isDefect(o)]).toEqual([true, false, false]); expect([isOk(e), isErr(e), isDefect(e)]).toEqual([false, true, false]); @@ -71,7 +71,7 @@ describe("standalone guards narrow and expose the relevant field", () => { describe("method guards narrow (parity with the standalone guards)", () => { it("r.isOk() narrows to OkView and exposes value", () => { - const r: Result = ok(7); + const r: Result = Ok(7); if (r.isOk()) { // Only compiles because `.isOk()` is a `this is OkView` predicate. expect(r.value).toBe(7); @@ -81,7 +81,7 @@ describe("method guards narrow (parity with the standalone guards)", () => { }); it("r.isErr() narrows to ErrView and exposes error", () => { - const r: Result = err("bad"); + const r: Result = Err("bad"); if (r.isErr()) { expect(r.error).toBe("bad"); } else { diff --git a/packages/core/src/constructors.ts b/packages/core/src/constructors.ts index 909124c..6a9974e 100644 --- a/packages/core/src/constructors.ts +++ b/packages/core/src/constructors.ts @@ -11,11 +11,11 @@ import type { DefectView, ErrView, OkView, Result } from "./types.js"; * * @example * ```ts - * import { ok } from "unthrown"; - * ok(42).unwrap(); // 42 + * import { Ok } from "unthrown"; + * Ok(42).unwrap(); // 42 * ``` */ -export function ok(value: T): Result { +export function Ok(value: T): Result { return okRes(value); } @@ -27,11 +27,11 @@ export function ok(value: T): Result { * * @example * ```ts - * import { err } from "unthrown"; - * err("not_found").unwrapErr(); // "not_found" + * import { Err } from "unthrown"; + * Err("not_found").unwrapErr(); // "not_found" * ``` */ -export function err(error: E): Result { +export function Err(error: E): Result { return errRes(error); } diff --git a/packages/core/src/defect.ts b/packages/core/src/defect.ts index 22c2d15..c237b6e 100644 --- a/packages/core/src/defect.ts +++ b/packages/core/src/defect.ts @@ -1,6 +1,6 @@ // Defect marker plumbing. -const DEFECT: unique symbol = Symbol("unthrown/defect"); +const DEFECT: unique symbol = Symbol("unthrown/Defect"); /** * The marker a `qualify` function returns to triage a cause as **unexpected**. @@ -8,7 +8,7 @@ const DEFECT: unique symbol = Symbol("unthrown/defect"); * @remarks * `qualify` (passed to {@link fromPromise} / {@link fromThrowable}) returns * `E | Defect`: either a modeled domain error, or a `Defect` produced by - * {@link defect} to say "this failure is not modeled". A `Defect` is opaque — + * {@link Defect} to say "this failure is not modeled". A `Defect` is opaque — * it carries the original cause for the boundary to convert into the third * runtime state of a `Result`. */ @@ -22,18 +22,18 @@ export type Defect = { * function when a failure is **not** a modeled domain error. * * @param cause - the original thrown/rejected value. - * @returns an opaque defect marker carrying `cause`. + * @returns an opaque Defect marker carrying `cause`. * * @example * ```ts - * import { fromPromise, defect } from "unthrown"; + * import { fromPromise, Defect } from "unthrown"; * * const user = fromPromise(fetchUser(id), (cause) => - * cause instanceof NotFoundError ? cause : defect(cause), + * cause instanceof NotFoundError ? cause : Defect(cause), * ); * ``` */ -export function defect(cause: unknown): Defect { +export function Defect(cause: unknown): Defect { return { [DEFECT]: true, cause }; } diff --git a/packages/core/src/do.spec.ts b/packages/core/src/do.spec.ts index 95eba16..64b0737 100644 --- a/packages/core/src/do.spec.ts +++ b/packages/core/src/do.spec.ts @@ -1,10 +1,10 @@ import { describe, expect, it, vi } from "vitest"; -import { Do, err, fromSafePromise, ok, type Result } from "./index.js"; +import { Do, Err, fromSafePromise, Ok, type Result } from "./index.js"; const boom = new Error("boom"); const defectOf = (cause: unknown): Result => - ok(0).map(() => { + Ok(0).map(() => { throw cause; }); @@ -15,30 +15,30 @@ describe("Do / bind / let", () => { it("accumulates bound Results and pure lets into a named scope", () => { const r = Do() - .bind("a", () => ok(1)) - .bind("b", ({ a }) => ok(a + 1)) + .bind("a", () => Ok(1)) + .bind("b", ({ a }) => Ok(a + 1)) .let("c", ({ a, b }) => a + b); expect(r.unwrap()).toEqual({ a: 1, b: 2, c: 3 }); }); it("short-circuits on the first Err and skips later steps", () => { - const later = vi.fn(() => ok(2)); + const later = vi.fn(() => Ok(2)); const r = Do() - .bind("a", () => err("denied")) + .bind("a", () => Err("denied")) .bind("b", later); expect(r.unwrapErr()).toBe("denied"); expect(later).not.toHaveBeenCalled(); }); it("unions the error types across binds", () => { - const a: Result<{ x: number }, "e1"> = Do().bind("x", () => err<"e1">("e1")); - const b = a.bind("y", () => err<"e2">("e2")); + const a: Result<{ x: number }, "e1"> = Do().bind("x", () => Err<"e1">("e1")); + const b = a.bind("y", () => Err<"e2">("e2")); // `b` is Result<{ x; y }, "e1" | "e2"> — exercised at runtime here: expect(b.unwrapErr()).toBe("e1"); }); it("propagates a Defect and does not run later steps", () => { - const later = vi.fn(() => ok(1)); + const later = vi.fn(() => Ok(1)); const r = Do() .bind("a", () => defectOf(boom)) .let("b", later); @@ -69,7 +69,7 @@ describe("Do / bind / let — async", () => { const r = await Do() .toAsync() .bind("a", () => fromSafePromise(Promise.resolve(1))) - .bind("b", ({ a }) => ok(a + 1)) // a sync Result is accepted too + .bind("b", ({ a }) => Ok(a + 1)) // a sync Result is accepted too .let("c", ({ a, b }) => a + b); expect(r.unwrap()).toEqual({ a: 1, b: 2, c: 3 }); }); @@ -77,8 +77,8 @@ describe("Do / bind / let — async", () => { it("short-circuits on an async Err", async () => { const r = await Do() .toAsync() - .bind("a", () => err("denied")) - .bind("b", () => ok(1)); + .bind("a", () => Err("denied")) + .bind("b", () => Ok(1)); expect(r.unwrapErr()).toBe("denied"); }); diff --git a/packages/core/src/do.ts b/packages/core/src/do.ts index d40b54e..9da648e 100644 --- a/packages/core/src/do.ts +++ b/packages/core/src/do.ts @@ -2,7 +2,7 @@ // `AsyncResult` method surface (core.ts); `Do()` just seeds an empty object // scope to grow. -import { ok } from "./constructors.js"; +import { Ok } from "./constructors.js"; import type { Result } from "./types.js"; /** @@ -17,7 +17,7 @@ import type { Result } from "./types.js"; * * @example * ```ts - * import { Do, ok } from "unthrown"; + * import { Do, Ok } from "unthrown"; * * const result = Do() * .bind("user", () => findUser(id)) // Result @@ -28,5 +28,5 @@ import type { Result } from "./types.js"; * ``` */ export function Do(): Result<{}, never> { - return ok({}); + return Ok({}); } diff --git a/packages/core/src/facade.spec.ts b/packages/core/src/facade.spec.ts index 4020410..8d61455 100644 --- a/packages/core/src/facade.spec.ts +++ b/packages/core/src/facade.spec.ts @@ -1,15 +1,15 @@ import { describe, expect, it } from "vitest"; -import { all, err, fromNullable, isDefect, isErr, isOk, ok, Result } from "./index.js"; +import { all, Err, fromNullable, isDefect, isErr, isOk, Ok, Result } from "./index.js"; const boom = new Error("boom"); describe("Result facade mirrors the free functions", () => { it("exposes the same constructors", () => { - expect(Result.ok(5).unwrap()).toBe(5); - expect(Result.err("e").unwrapErr()).toBe("e"); - expect(Result.ok).toBe(ok); - expect(Result.err).toBe(err); + expect(Result.Ok(5).unwrap()).toBe(5); + expect(Result.Err("e").unwrapErr()).toBe("e"); + expect(Result.Ok).toBe(Ok); + expect(Result.Err).toBe(Err); }); it("exposes the same guards", () => { @@ -17,11 +17,11 @@ describe("Result facade mirrors the free functions", () => { expect(Result.isErr).toBe(isErr); expect(Result.isDefect).toBe(isDefect); - const d = Result.ok(1).map(() => { + const d = Result.Ok(1).map(() => { throw boom; }); - expect(Result.isOk(Result.ok(1))).toBe(true); - expect(Result.isErr(Result.err("e"))).toBe(true); + expect(Result.isOk(Result.Ok(1))).toBe(true); + expect(Result.isErr(Result.Err("e"))).toBe(true); expect(Result.isDefect(d)).toBe(true); }); @@ -29,6 +29,6 @@ describe("Result facade mirrors the free functions", () => { expect(Result.fromNullable).toBe(fromNullable); expect(Result.all).toBe(all); expect(Result.fromNullable(null, () => "absent").unwrapErr()).toBe("absent"); - expect(Result.all([Result.ok(1), Result.ok(2)]).unwrap()).toEqual([1, 2]); + expect(Result.all([Result.Ok(1), Result.Ok(2)]).unwrap()).toEqual([1, 2]); }); }); diff --git a/packages/core/src/facade.ts b/packages/core/src/facade.ts index 4a383ca..91feb73 100644 --- a/packages/core/src/facade.ts +++ b/packages/core/src/facade.ts @@ -1,11 +1,11 @@ // Result facade — a discoverable namespace alias for the standalone entry // points. The free functions remain the primary, tree-shakeable API; this -// object is a separate export, so `import { ok }` never pulls it in. The value +// object is a separate export, so `import { Ok }` never pulls it in. The value // `Result` and the type `Result` (types.ts) share a name — the // companion-object pattern. See CLAUDE.md → "Internal design". -import { err, isDefect, isErr, isOk, ok } from "./constructors.js"; -import { defect } from "./defect.js"; +import { Err, isDefect, isErr, isOk, Ok } from "./constructors.js"; +import { Defect } from "./defect.js"; import { Do } from "./do.js"; import { all, @@ -21,8 +21,8 @@ import type { Result as ResultType } from "./types.js"; /** * Companion object grouping the standalone entry points under a single, - * discoverable namespace: {@link Result.ok}, {@link Result.err}, - * {@link Result.defect}, {@link Result.fromNullable}, {@link Result.fromThrowable}, + * discoverable namespace: {@link Result.Ok}, {@link Result.Err}, + * {@link Result.Defect}, {@link Result.fromNullable}, {@link Result.fromThrowable}, * {@link Result.fromPromise}, {@link Result.fromSafePromise}, {@link Result.all}, * {@link Result.allAsync}, {@link Result.allFromDict}, * {@link Result.allFromDictAsync}, {@link Result.isOk}, {@link Result.isErr}, @@ -31,19 +31,19 @@ import type { Result as ResultType } from "./types.js"; * @remarks * Purely additive sugar — each member **is** the corresponding free function. * The free functions remain the primary, tree-shakeable API; importing only - * `{ ok }` never pulls this object in. The value `Result` and the type + * `{ Ok }` never pulls this object in. The value `Result` and the type * {@link Result} share one name (the companion-object pattern). * * @example * ```ts * import { Result } from "unthrown"; - * Result.ok(1).flatMap((n) => Result.ok(n + 1)).unwrap(); // 2 + * Result.Ok(1).flatMap((n) => Result.Ok(n + 1)).unwrap(); // 2 * ``` */ export const Result = { - ok, - err, - defect, + Ok, + Err, + Defect, Do, fromNullable, fromThrowable, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e6b0985..a6efbba 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,6 +1,6 @@ -export { err, isDefect, isErr, isOk, ok } from "./constructors.js"; +export { Err, isDefect, isErr, isOk, Ok } from "./constructors.js"; export { UnwrapError } from "./core.js"; -export { defect } from "./defect.js"; +export { Defect } from "./defect.js"; export { Do } from "./do.js"; export { Result } from "./facade.js"; export { @@ -15,7 +15,6 @@ export { } from "./interop.js"; export { matchTags, TaggedError } from "./tagged.js"; -export type { Defect } from "./defect.js"; export type { TaggedErrorConstructor, TaggedErrorInstance, TagHandlers } from "./tagged.js"; export type { AsyncErrOf, diff --git a/packages/core/src/interop.spec.ts b/packages/core/src/interop.spec.ts index b38e0b4..996d759 100644 --- a/packages/core/src/interop.spec.ts +++ b/packages/core/src/interop.spec.ts @@ -2,11 +2,11 @@ import { describe, expect, it } from "vitest"; import { type AsyncResult, - defect, + Defect, fromNullable, fromPromise, fromThrowable, - ok, + Ok, type Result, } from "./index.js"; @@ -44,17 +44,17 @@ describe("fromThrowable", () => { expect(parse('{"a":1}').unwrap()).toEqual({ a: 1 }); }); - it("triages a thrown cause into a Defect when qualify returns a defect marker", () => { + it("triages a thrown cause into a Defect when qualify returns a Defect marker", () => { const fn = fromThrowable( () => { throw boom; }, - (c) => defect(c), + (c) => Defect(c), ); const r = fn(); expect(r.isDefect()).toBe(true); - // the original cause is preserved on the defect channel - expect(r.recoverDefect((c) => ok(c === boom)).unwrap()).toBe(true); + // the original cause is preserved on the Defect channel + expect(r.recoverDefect((c) => Ok(c === boom)).unwrap()).toBe(true); }); it("treats a throw inside qualify as a Defect", () => { @@ -69,10 +69,10 @@ describe("fromThrowable", () => { expect(fn().isDefect()).toBe(true); }); - it("subtracts Defect from the error channel: a defect-only qualify yields E = never", () => { + it("subtracts Defect from the error channel: a Defect-only qualify yields E = never", () => { const fn = fromThrowable( (): number => 1, - (c) => defect(c), + (c) => Defect(c), ); // Compiles only if `E` is `never` — `Defect` must not leak into the channel. const r: Result = fn(); @@ -82,7 +82,7 @@ describe("fromThrowable", () => { it("keeps the modeled arm while subtracting Defect: mixed qualify yields just E", () => { const fn = fromThrowable( (): number => 1, - (c) => (c === boom ? ("known" as const) : defect(c)), + (c) => (c === boom ? ("known" as const) : Defect(c)), ); const r: Result = fn(); expect(r.unwrap()).toBe(1); @@ -90,15 +90,15 @@ describe("fromThrowable", () => { }); describe("fromPromise — error-channel inference", () => { - it("a defect-only qualify yields AsyncResult", async () => { - const ar = fromPromise(Promise.reject(boom), (c) => defect(c)); + it("a Defect-only qualify yields AsyncResult", async () => { + const ar = fromPromise(Promise.reject(boom), (c) => Defect(c)); const typed: AsyncResult = ar; expect((await typed).isDefect()).toBe(true); }); it("a mixed qualify keeps only the modeled arm in E", async () => { const ar = fromPromise(Promise.reject(boom), (c) => - c === boom ? ("known" as const) : defect(c), + c === boom ? ("known" as const) : Defect(c), ); const typed: AsyncResult = ar; // `boom` matches the modeled arm, so it lands in Err — not a Defect. diff --git a/packages/core/src/interop.ts b/packages/core/src/interop.ts index 27b5634..5dccb41 100644 --- a/packages/core/src/interop.ts +++ b/packages/core/src/interop.ts @@ -4,7 +4,7 @@ import { AsyncRes, defectRes, errRes, okRes } from "./core.js"; import { type Defect, isDefectMarker } from "./defect.js"; -import { err, ok } from "./constructors.js"; +import { Err, Ok } from "./constructors.js"; import type { AsyncErrOf, AsyncOkOf, AsyncResult, ErrOf, OkOf, Result } from "./types.js"; /** @@ -12,7 +12,7 @@ import type { AsyncErrOf, AsyncOkOf, AsyncResult, ErrOf, OkOf, Result } from "./ * `Err`. The sanctioned alternative to an `Option` type. * * @remarks - * `null` and `undefined` map to `err(onAbsent())`; any other value (including + * `null` and `undefined` map to `Err(onAbsent())`; any other value (including * falsy ones like `0`, `""`, `false`) maps to `Ok`. * * @typeParam T - the (nullable) value type. @@ -30,7 +30,7 @@ export function fromNullable( value: T | null | undefined, onAbsent: () => E, ): Result, E> { - return value === null || value === undefined ? err(onAbsent()) : ok(value as NonNullable); + return value === null || value === undefined ? Err(onAbsent()) : Ok(value as NonNullable); } /** @@ -39,14 +39,14 @@ export function fromNullable( * * @remarks * `qualify` **must** triage every thrown cause into a modeled error `E` or a - * {@link Defect} (via {@link defect}) — there is no path that leaves `unknown` + * {@link Defect} (via {@link Defect}) — there is no path that leaves `unknown` * in `E`. A throw inside `qualify` itself is treated as a `Defect`. * * The modeled error type is `Exclude` — the `Defect` arm of * `qualify`'s return is **subtracted** from `E`, never inferred into it. So a - * `qualify` that returns *only* `defect(cause)` yields `E = never` (a defect is + * `qualify` that returns *only* `Defect(cause)` yields `E = never` (a Defect is * out-of-band and must not pollute the error channel); reach for - * {@link fromSafePromise} when every failure is a defect. + * {@link fromSafePromise} when every failure is a Defect. * * @typeParam A - the wrapped function's argument tuple. * @typeParam T - the wrapped function's return type. @@ -58,8 +58,8 @@ export function fromNullable( * * @example * ```ts - * import { fromThrowable, defect } from "unthrown"; - * const parse = fromThrowable(JSON.parse, (cause) => defect(cause)); + * import { fromThrowable, Defect } from "unthrown"; + * const parse = fromThrowable(JSON.parse, (cause) => Defect(cause)); * parse("{}").unwrap(); * ``` */ @@ -71,7 +71,7 @@ export function fromThrowable( const triage = qualify as (cause: unknown) => E | Defect; return (...args: A): Result => { try { - return ok(fn(...args)) as Result; + return Ok(fn(...args)) as Result; } catch (cause) { return qualifyToResult(cause, triage); } @@ -90,8 +90,8 @@ export function fromThrowable( * * The modeled error type is `Exclude` — the `Defect` arm of * `qualify`'s return is **subtracted** from `E`, never inferred into it. So a - * `qualify` that returns *only* `defect(cause)` yields `E = never`; when every - * rejection is a defect, prefer {@link fromSafePromise}. + * `qualify` that returns *only* `Defect(cause)` yields `E = never`; when every + * rejection is a Defect, prefer {@link fromSafePromise}. * * @typeParam T - the resolved value type. * @typeParam R - `qualify`'s return type; the modeled error `E` is @@ -101,9 +101,9 @@ export function fromThrowable( * * @example * ```ts - * import { fromPromise, defect } from "unthrown"; + * import { fromPromise, Defect } from "unthrown"; * const user = await fromPromise(fetchUser(id), (cause) => - * cause instanceof NotFoundError ? ("not_found" as const) : defect(cause), + * cause instanceof NotFoundError ? ("not_found" as const) : Defect(cause), * ); * ``` */ @@ -152,7 +152,7 @@ function qualifyToResult( const q = qualify(cause); return isDefectMarker(q) ? defectRes(q.cause) : errRes(q); } catch (qErr) { - // a throw inside qualify is itself a defect + // a throw inside qualify is itself a Defect return defectRes(qErr); } } @@ -199,7 +199,7 @@ function foldArray(results: readonly Result[]): Result { configurable: true, }); } - return firstDefect ?? firstErr ?? ok(values); + return firstDefect ?? firstErr ?? Ok(values); } /** @@ -234,16 +234,16 @@ function foldRecord(results: ResultRecord): Result { * @remarks * Short-circuits on the **first** `Err` (later entries are not inspected for * their error); any `Defect` present **dominates**, winning even over an earlier - * `Err`. A **fixed tuple** keeps its positional types — `all([ok(1), ok("a")])` + * `Err`. A **fixed tuple** keeps its positional types — `all([Ok(1), Ok("a")])` * is `Result<[number, string], …>` — while a **dynamic array** `Result[]` * collapses to `Result` with no cast. For a **record** keyed by name, * use {@link allFromDict}. * * @example * ```ts - * import { all, ok } from "unthrown"; - * all([ok(1), ok("a"), ok(true)]).unwrap(); // [1, "a", true] (typed [number, string, boolean]) - * all([ok(1), ok(2)] as Result[]).unwrap(); // number[] + * import { all, Ok } from "unthrown"; + * all([Ok(1), Ok("a"), Ok(true)]).unwrap(); // [1, "a", true] (typed [number, string, boolean]) + * all([Ok(1), Ok(2)] as Result[]).unwrap(); // number[] * ``` */ export function all[]>( @@ -267,8 +267,8 @@ export function all[]>( * * @example * ```ts - * import { allFromDict, ok } from "unthrown"; - * allFromDict({ id: ok(1), name: ok("ada") }).unwrap(); // { id: 1, name: "ada" } + * import { allFromDict, Ok } from "unthrown"; + * allFromDict({ id: Ok(1), name: Ok("ada") }).unwrap(); // { id: 1, name: "ada" } * ``` */ export function allFromDict( diff --git a/packages/core/src/invariants.spec.ts b/packages/core/src/invariants.spec.ts index 4476085..a58c3ea 100644 --- a/packages/core/src/invariants.spec.ts +++ b/packages/core/src/invariants.spec.ts @@ -3,11 +3,11 @@ import { describe, expect, it, vi } from "vitest"; -import { Do, err, fromSafePromise, ok, type Result, UnwrapError } from "./index.js"; +import { Do, Err, fromSafePromise, Ok, type Result, UnwrapError } from "./index.js"; const boom = new Error("boom"); const defectOf = (cause: unknown): Result => - ok(0).map(() => { + Ok(0).map(() => { throw cause; }); @@ -16,16 +16,16 @@ describe("Invariant 1: throw inside any combinator becomes a Defect", () => { const t = () => { throw boom; }; - expect(ok(1).map(t).isDefect()).toBe(true); - expect(ok(1).flatMap(t).isDefect()).toBe(true); - expect(ok(1).tap(t).isDefect()).toBe(true); - expect(ok(1).flatTap(t).isDefect()).toBe(true); + expect(Ok(1).map(t).isDefect()).toBe(true); + expect(Ok(1).flatMap(t).isDefect()).toBe(true); + expect(Ok(1).tap(t).isDefect()).toBe(true); + expect(Ok(1).flatTap(t).isDefect()).toBe(true); expect(Do().bind("a", t).isDefect()).toBe(true); expect(Do().let("a", t).isDefect()).toBe(true); - expect(err("e").mapErr(t).isDefect()).toBe(true); - expect(err("e").orElse(t).isDefect()).toBe(true); - expect(err("e").recover(t).isDefect()).toBe(true); - expect(err("e").tapErr(t).isDefect()).toBe(true); + expect(Err("e").mapErr(t).isDefect()).toBe(true); + expect(Err("e").orElse(t).isDefect()).toBe(true); + expect(Err("e").recover(t).isDefect()).toBe(true); + expect(Err("e").tapErr(t).isDefect()).toBe(true); expect(defectOf(boom).recoverDefect(t).isDefect()).toBe(true); expect(defectOf(boom).tapDefect(t).isDefect()).toBe(true); }); @@ -51,7 +51,7 @@ describe("Invariant 2: a Defect flows through every method except match() and re expect(f).not.toHaveBeenCalled(); }); - it("the recovering eliminators still THROW on a Defect (they recover Err, not a defect)", () => { + it("the recovering eliminators still THROW on a Defect (they recover Err, not a Defect)", () => { const d = defectOf(boom); expect(() => d.unwrapOr(0)).toThrow(); expect(() => d.unwrapOrElse(() => 0)).toThrow(); @@ -59,11 +59,11 @@ describe("Invariant 2: a Defect flows through every method except match() and re expect(() => d.getOrUndefined()).toThrow(); }); - it("only match() and recoverDefect() observe the defect", () => { + it("only match() and recoverDefect() observe the Defect", () => { expect(defectOf(boom).match({ ok: () => "o", err: () => "e", defect: () => "d" })).toBe("d"); expect( defectOf(boom) - .recoverDefect(() => ok("handled")) + .recoverDefect(() => Ok("handled")) .unwrap(), ).toBe("handled"); }); @@ -72,7 +72,7 @@ describe("Invariant 2: a Defect flows through every method except match() and re describe("Invariant 3: unwrap() is asymmetric", () => { it("on Err throws an UnwrapError carrying E", () => { try { - err("modeled").unwrap(); + Err("modeled").unwrap(); expect.unreachable(); } catch (e) { expect(e).toBeInstanceOf(UnwrapError); diff --git a/packages/core/src/result.spec.ts b/packages/core/src/result.spec.ts index 27aed50..4ac204e 100644 --- a/packages/core/src/result.spec.ts +++ b/packages/core/src/result.spec.ts @@ -1,17 +1,17 @@ import { describe, expect, it, vi } from "vitest"; -import { err, ok, type Result } from "./index.js"; +import { Err, Ok, type Result } from "./index.js"; const boom = new Error("boom"); const defectOf = (cause: unknown): Result => - ok(0).map(() => { + Ok(0).map(() => { throw cause; }); describe("Result.map", () => { it("maps the Ok value", () => { expect( - ok(2) + Ok(2) .map((n) => n + 1) .unwrap(), ).toBe(3); @@ -19,7 +19,7 @@ describe("Result.map", () => { it("passes Err through untouched without calling the callback", () => { const f = vi.fn(); - const r = err("e").map(f); + const r = Err("e").map(f); expect(f).not.toHaveBeenCalled(); expect(r.unwrapErr()).toBe("e"); }); @@ -32,34 +32,34 @@ describe("Result.map", () => { }); it("converts a throw into a Defect carrying the cause", () => { - const r = ok(1).map(() => { + const r = Ok(1).map(() => { throw boom; }); expect(r.isDefect()).toBe(true); - expect(r.recoverDefect((c) => ok(c === boom)).unwrap()).toBe(true); + expect(r.recoverDefect((c) => Ok(c === boom)).unwrap()).toBe(true); }); }); describe("Result.flatMap", () => { it("chains into another Ok", () => { expect( - ok(2) - .flatMap((n) => ok(n * 10)) + Ok(2) + .flatMap((n) => Ok(n * 10)) .unwrap(), ).toBe(20); }); it("chains into an Err, widening the error type", () => { expect( - ok(2) - .flatMap(() => err("downstream")) + Ok(2) + .flatMap(() => Err("downstream")) .unwrapErr(), ).toBe("downstream"); }); it("passes Err through and does not call the callback", () => { const f = vi.fn(); - expect(err("e").flatMap(f).unwrapErr()).toBe("e"); + expect(Err("e").flatMap(f).unwrapErr()).toBe("e"); expect(f).not.toHaveBeenCalled(); }); @@ -71,7 +71,7 @@ describe("Result.flatMap", () => { it("converts a throw into a Defect", () => { expect( - ok(1) + Ok(1) .flatMap(() => { throw boom; }) @@ -83,21 +83,21 @@ describe("Result.flatMap", () => { describe("Result.tap", () => { it("runs the side effect on Ok and returns the same value", () => { const seen: number[] = []; - const r = ok(5).tap((n) => seen.push(n)); + const r = Ok(5).tap((n) => seen.push(n)); expect(seen).toEqual([5]); expect(r.unwrap()).toBe(5); }); it("does not run on Err or Defect", () => { const f = vi.fn(); - expect(err("e").tap(f).isErr()).toBe(true); + expect(Err("e").tap(f).isErr()).toBe(true); expect(defectOf(boom).tap(f).isDefect()).toBe(true); expect(f).not.toHaveBeenCalled(); }); it("converts a throw into a Defect", () => { expect( - ok(1) + Ok(1) .tap(() => { throw boom; }) @@ -109,34 +109,34 @@ describe("Result.tap", () => { describe("Result.flatTap", () => { it("runs the failable effect on Ok and keeps the original value on success", () => { const seen: number[] = []; - const r = ok(5).flatTap((n) => { + const r = Ok(5).flatTap((n) => { seen.push(n); - return ok("ignored"); + return Ok("ignored"); }); expect(seen).toEqual([5]); expect(r.unwrap()).toBe(5); // original value preserved, not "ignored" }); it("short-circuits to the effect's Err", () => { - const r = ok(5).flatTap(() => err("denied")); + const r = Ok(5).flatTap(() => Err("denied")); expect(r.unwrapErr()).toBe("denied"); }); it("propagates a Defect from the effect", () => { - const r = ok(5).flatTap(() => defectOf(boom)); + const r = Ok(5).flatTap(() => defectOf(boom)); expect(r.isDefect()).toBe(true); }); it("does not run on Err or Defect", () => { - const f = vi.fn(() => ok(1)); - expect(err("e").flatTap(f).isErr()).toBe(true); + const f = vi.fn(() => Ok(1)); + expect(Err("e").flatTap(f).isErr()).toBe(true); expect(defectOf(boom).flatTap(f).isDefect()).toBe(true); expect(f).not.toHaveBeenCalled(); }); it("converts a throw into a Defect", () => { expect( - ok(1) + Ok(1) .flatTap(() => { throw boom; }) @@ -147,11 +147,11 @@ describe("Result.flatTap", () => { describe("Result.as", () => { it("replaces the Ok value", () => { - expect(ok(1).as("x").unwrap()).toBe("x"); + expect(Ok(1).as("x").unwrap()).toBe("x"); }); it("passes Err and Defect through", () => { - expect(err("e").as("x").unwrapErr()).toBe("e"); + expect(Err("e").as("x").unwrapErr()).toBe("e"); expect(defectOf(boom).as("x").isDefect()).toBe(true); }); }); @@ -159,7 +159,7 @@ describe("Result.as", () => { describe("Result.mapErr", () => { it("maps the Err value", () => { expect( - err("e") + Err("e") .mapErr((s) => `${s}!`) .unwrapErr(), ).toBe("e!"); @@ -167,7 +167,7 @@ describe("Result.mapErr", () => { it("passes Ok through and does not call the callback", () => { const f = vi.fn(); - expect(ok(1).mapErr(f).unwrap()).toBe(1); + expect(Ok(1).mapErr(f).unwrap()).toBe(1); expect(f).not.toHaveBeenCalled(); }); @@ -179,7 +179,7 @@ describe("Result.mapErr", () => { it("converts a throw into a Defect", () => { expect( - err("e") + Err("e") .mapErr(() => { throw boom; }) @@ -191,23 +191,23 @@ describe("Result.mapErr", () => { describe("Result.orElse", () => { it("recovers an Err into an Ok", () => { expect( - err("e") - .orElse(() => ok(99)) + Err("e") + .orElse(() => Ok(99)) .unwrap(), ).toBe(99); }); it("recovers an Err into another Err", () => { expect( - err("e") - .orElse(() => err("e2")) + Err("e") + .orElse(() => Err("e2")) .unwrapErr(), ).toBe("e2"); }); it("passes Ok through and does not call the callback", () => { const f = vi.fn(); - expect(ok(1).orElse(f).unwrap()).toBe(1); + expect(Ok(1).orElse(f).unwrap()).toBe(1); expect(f).not.toHaveBeenCalled(); }); @@ -219,7 +219,7 @@ describe("Result.orElse", () => { it("converts a throw into a Defect", () => { expect( - err("e") + Err("e") .orElse(() => { throw boom; }) @@ -231,7 +231,7 @@ describe("Result.orElse", () => { describe("Result.recover", () => { it("turns an Err into an Ok", () => { expect( - err("e") + Err("e") .recover(() => 7) .unwrap(), ).toBe(7); @@ -239,7 +239,7 @@ describe("Result.recover", () => { it("passes Ok through and does not call the callback", () => { const f = vi.fn(); - expect(ok(1).recover(f).unwrap()).toBe(1); + expect(Ok(1).recover(f).unwrap()).toBe(1); expect(f).not.toHaveBeenCalled(); }); @@ -252,7 +252,7 @@ describe("Result.recover", () => { it("converts a throw into a Defect", () => { expect( - err("e") + Err("e") .recover(() => { throw boom; }) @@ -264,14 +264,14 @@ describe("Result.recover", () => { describe("Result.tapErr", () => { it("runs the side effect on Err and returns the same error", () => { const seen: string[] = []; - const r = err("e").tapErr((s) => seen.push(s)); + const r = Err("e").tapErr((s) => seen.push(s)); expect(seen).toEqual(["e"]); expect(r.unwrapErr()).toBe("e"); }); it("does not run on Ok or Defect", () => { const f = vi.fn(); - expect(ok(1).tapErr(f).unwrap()).toBe(1); + expect(Ok(1).tapErr(f).unwrap()).toBe(1); expect(defectOf(boom).tapErr(f).isDefect()).toBe(true); expect(f).not.toHaveBeenCalled(); }); @@ -281,7 +281,7 @@ describe("Result.recoverDefect (the only door to a Defect)", () => { it("replaces a Defect with an Ok", () => { expect( defectOf(boom) - .recoverDefect((c) => ok(c === boom ? "handled" : "other")) + .recoverDefect((c) => Ok(c === boom ? "handled" : "other")) .unwrap(), ).toBe("handled"); }); @@ -289,15 +289,15 @@ describe("Result.recoverDefect (the only door to a Defect)", () => { it("replaces a Defect with an Err", () => { expect( defectOf(boom) - .recoverDefect(() => err("modeled now")) + .recoverDefect(() => Err("modeled now")) .unwrapErr(), ).toBe("modeled now"); }); it("passes Ok and Err through and does not call the callback", () => { const f = vi.fn(); - expect(ok(1).recoverDefect(f).unwrap()).toBe(1); - expect(err("e").recoverDefect(f).unwrapErr()).toBe("e"); + expect(Ok(1).recoverDefect(f).unwrap()).toBe(1); + expect(Err("e").recoverDefect(f).unwrapErr()).toBe("e"); expect(f).not.toHaveBeenCalled(); }); @@ -322,8 +322,8 @@ describe("Result.tapDefect", () => { it("does not run on Ok or Err", () => { const f = vi.fn(); - expect(ok(1).tapDefect(f).unwrap()).toBe(1); - expect(err("e").tapDefect(f).unwrapErr()).toBe("e"); + expect(Ok(1).tapDefect(f).unwrap()).toBe(1); + expect(Err("e").tapDefect(f).unwrapErr()).toBe("e"); expect(f).not.toHaveBeenCalled(); }); }); @@ -336,17 +336,17 @@ describe("Result.match", () => { err: (e) => `err:${e}`, defect: (c) => `defect:${(c as Error).message}`, }); - expect(fold(ok(1))).toBe("ok:1"); - expect(fold(err("e"))).toBe("err:e"); + expect(fold(Ok(1))).toBe("ok:1"); + expect(fold(Err("e"))).toBe("err:e"); expect(fold(defectOf(boom))).toBe("defect:boom"); }); }); describe("Result eliminators on Ok / Err", () => { it("unwrap returns the Ok value; throws UnwrapError on Err", () => { - expect(ok(1).unwrap()).toBe(1); + expect(Ok(1).unwrap()).toBe(1); try { - err("e").unwrap(); + Err("e").unwrap(); expect.unreachable(); } catch (e) { expect(e).toBeInstanceOf(Error); @@ -356,9 +356,9 @@ describe("Result eliminators on Ok / Err", () => { }); it("unwrapErr returns the Err; throws UnwrapError on Ok; rethrows the cause on a Defect", () => { - expect(err("e").unwrapErr()).toBe("e"); + expect(Err("e").unwrapErr()).toBe("e"); try { - ok(1).unwrapErr(); + Ok(1).unwrapErr(); expect.unreachable(); } catch (e) { expect((e as { name: string }).name).toBe("UnwrapError"); @@ -373,24 +373,24 @@ describe("Result eliminators on Ok / Err", () => { }); it("unwrapOr / unwrapOrElse recover an Err", () => { - const e: Result = err("e"); + const e: Result = Err("e"); expect(e.unwrapOr(9)).toBe(9); expect(e.unwrapOrElse((s) => s.length)).toBe(1); - expect(ok(3).unwrapOr(9)).toBe(3); + expect(Ok(3).unwrapOr(9)).toBe(3); }); it("getOrNull / getOrUndefined return the value or the empty sentinel on Err", () => { - expect(ok(3).getOrNull()).toBe(3); - expect(err("e").getOrNull()).toBe(null); - expect(ok(3).getOrUndefined()).toBe(3); - expect(err("e").getOrUndefined()).toBe(undefined); + expect(Ok(3).getOrNull()).toBe(3); + expect(Err("e").getOrNull()).toBe(null); + expect(Ok(3).getOrUndefined()).toBe(3); + expect(Err("e").getOrUndefined()).toBe(undefined); }); }); describe("Result.toAsync", () => { it("lifts a Result into an awaitable AsyncResult", async () => { - expect((await ok(5).toAsync()).unwrap()).toBe(5); - expect((await err("e").toAsync()).unwrapErr()).toBe("e"); + expect((await Ok(5).toAsync()).unwrap()).toBe(5); + expect((await Err("e").toAsync()).unwrapErr()).toBe("e"); expect((await defectOf(boom).toAsync()).isDefect()).toBe(true); }); }); diff --git a/packages/core/src/tagged.spec.ts b/packages/core/src/tagged.spec.ts index e4fd8ca..8a75e95 100644 --- a/packages/core/src/tagged.spec.ts +++ b/packages/core/src/tagged.spec.ts @@ -2,10 +2,10 @@ import { describe, expect, it } from "vitest"; import { type AsyncResult, - err, + Err, fromSafePromise, matchTags, - ok, + Ok, type Result, TaggedError, } from "./index.js"; @@ -66,7 +66,7 @@ describe("TaggedError", () => { it("still dispatches on the namespaced _tag in matchTags", () => { class Retryable extends TaggedError("@my-lib/Retryable", { name: "Retryable" }) {} - const out = matchTags(err(new Retryable()) as Result, { + const out = matchTags(Err(new Retryable()) as Result, { Ok: (n) => `ok:${n}`, Defect: () => "defect", "@my-lib/Retryable": (e) => `retry:${e.name}`, @@ -86,24 +86,24 @@ describe("matchTags", () => { }); it("dispatches Ok to the Ok handler", () => { - expect(fold(ok(7))).toBe("ok:7"); + expect(fold(Ok(7))).toBe("ok:7"); }); it("dispatches each tagged error to the handler matching its _tag", () => { - expect(fold(err(new NotFound()))).toBe("not-found"); - expect(fold(err(new Forbidden({ user: "bob" })))).toBe("forbidden:bob"); - expect(fold(err(new HttpError({ status: 503, message: "down" })))).toBe("http:503"); + expect(fold(Err(new NotFound()))).toBe("not-found"); + expect(fold(Err(new Forbidden({ user: "bob" })))).toBe("forbidden:bob"); + expect(fold(Err(new HttpError({ status: 503, message: "down" })))).toBe("http:503"); }); it("narrows each error variant to its payload in its handler", () => { // `e.user` / `e.status` below only typecheck because the variant is narrowed. - const r: Result = err(new Forbidden({ user: "alice" })); + const r: Result = Err(new Forbidden({ user: "alice" })); expect(fold(r)).toBe("forbidden:alice"); }); it("dispatches a Defect to the Defect handler", () => { const boom = new Error("kaboom"); - const r = ok(0).map(() => { + const r = Ok(0).map(() => { throw boom; }) as Result; expect(fold(r)).toBe(`defect:${String(boom)}`); diff --git a/packages/core/src/types.test-d.ts b/packages/core/src/types.test-d.ts index 9382f24..d88d1ab 100644 --- a/packages/core/src/types.test-d.ts +++ b/packages/core/src/types.test-d.ts @@ -14,17 +14,17 @@ import { type AsyncErrOf, type AsyncOkOf, type AsyncResult, - defect, + Defect, Do, type ErrOf, - err, + Err, fromPromise, fromThrowable, isDefect, isErr, isOk, matchTags, - ok, + Ok, type OkOf, type Result, TaggedError, @@ -38,15 +38,15 @@ type Expect = T; // --- constructors ------------------------------------------------------------ -const okV = ok(1); +const okV = Ok(1); type _ok = Expect>>; -const errV = err("e"); +const errV = Err("e"); type _err = Expect>>; // --- aggregation: `all` keeps positional tuple types ------------------------- -const tuple = all([ok(1), ok("x"), ok(true)]); +const tuple = all([Ok(1), Ok("x"), Ok(true)]); type _tuple = Expect>>; // the empty tuple stays `[]`, not `never[]` (regression guard) @@ -65,32 +65,32 @@ const mixedErr = all([r1, r2]); type _mixedErr = Expect>>; // `allFromDict` produces a record of values, keyed by name -const dict = allFromDict({ id: ok(1), name: ok("ada") }); +const dict = allFromDict({ id: Ok(1), name: Ok("ada") }); type _dict = Expect>>; // async counterparts const tupleAsync = allAsync([r1.toAsync(), r2.toAsync()]); type _tupleAsync = Expect>>; -const dictAsync = allFromDictAsync({ id: ok(1).toAsync(), name: ok("ada").toAsync() }); +const dictAsync = allFromDictAsync({ id: Ok(1).toAsync(), name: Ok("ada").toAsync() }); type _dictAsync = Expect>>; // --- boundaries: `Defect` is subtracted from the error channel --------------- -// a defect-only qualify yields `E = never` -const defectOnly = fromPromise(Promise.resolve(1), (c) => defect(c)); +// a Defect-only qualify yields `E = never` +const defectOnly = fromPromise(Promise.resolve(1), (c) => Defect(c)); type _defectOnly = Expect>>; // a mixed qualify keeps only the modeled arm const mixedQualify = fromPromise(Promise.resolve(1), (c) => - c === 1 ? ("nf" as const) : defect(c), + c === 1 ? ("nf" as const) : Defect(c), ); type _mixedQualify = Expect>>; // the same subtraction on `fromThrowable` const throwable = fromThrowable( (s: string) => s.length, - (c) => defect(c), + (c) => Defect(c), ); type _throwable = Expect, Result>>; @@ -101,11 +101,11 @@ fromPromise(Promise.resolve(1)); // --- combinators ------------------------------------------------------------- // flatMap widens the error channel -const flatMapped = r1.flatMap(() => err<"e2">("e2")); +const flatMapped = r1.flatMap(() => Err<"e2">("e2")); type _flatMapped = Expect>>; // flatTap KEEPS the value type and widens the error channel -const flatTapped = r1.flatTap(() => err<"e2">("e2")); +const flatTapped = r1.flatTap(() => Err<"e2">("e2")); type _flatTapped = Expect>>; // recover empties the error channel (to `never`) @@ -122,8 +122,8 @@ type _errMapped = Expect>>; // the accumulated scope is readonly (it mustn't be mutated mid-chain) const doChain = Do() - .bind("a", () => ok(1)) - .bind("b", ({ a }) => (a > 0 ? ok("x") : err<"e1">("e1"))) + .bind("a", () => Ok(1)) + .bind("b", ({ a }) => (a > 0 ? Ok("x") : Err<"e1">("e1"))) .let("c", ({ a, b }) => a + b.length); type _doChain = Expect< Equal< @@ -134,7 +134,7 @@ type _doChain = Expect< // the bound scope is typed in each step's callback (compiles → keys are present) const doScoped = Do() - .bind("user", () => ok({ name: "ada" })) + .bind("user", () => Ok({ name: "ada" })) .let("upper", ({ user }) => user.name.toUpperCase()); type _doScoped = Expect< Equal> @@ -143,7 +143,7 @@ type _doScoped = Expect< // async do-notation accumulates the same way const doAsync = Do() .toAsync() - .bind("a", () => ok(1)) + .bind("a", () => Ok(1)) .let("b", ({ a }) => a + 1); type _doAsync = Expect< Equal> @@ -151,8 +151,8 @@ type _doAsync = Expect< // re-binding a key OVERWRITES it (not an unsound `number & string` intersection) const doRebind = Do() - .bind("a", () => ok(1)) - .bind("a", () => ok("x")); + .bind("a", () => Ok(1)) + .bind("a", () => Ok("x")); type _doRebind = Expect>>; // --- guards narrow (methods AND standalone) ---------------------------------- diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 1b8d8bf..02aee44 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -170,12 +170,12 @@ export type ResultMethods = { * @remarks * Runs `f` only when a `Defect` is present, re-entering the modeled world by * returning a `Result` (an `Ok` or a fresh `Err`). `Ok` and `Err` pass - * through. Recovering a defect should be rare: usually you let it bubble to + * through. Recovering a Defect should be rare: usually you let it bubble to * the edge. If `f` throws, the throw becomes a new `Defect`. * * @typeParam U - a success type the recovery may produce. * @typeParam E2 - an error type the recovery may produce. - * @param f - maps the defect's unknown cause to a recovering `Result`. + * @param f - maps the Defect's unknown cause to a recovering `Result`. */ recoverDefect(f: (cause: unknown) => Result): Result; /** @@ -190,9 +190,9 @@ export type ResultMethods = { * Exhaustively fold all three runtime states into a single value of type `R`. * * @remarks - * Exactly one handler runs. Together with the throw-to-defect guarantee, this + * Exactly one handler runs. Together with the throw-to-Defect guarantee, this * is typically the single place a pipeline is handled at the edge — mapping - * `ok`/`err`/`defect` to (for example) 2xx / 4xx / 5xx with no `try`/`catch`. + * `Ok`/`Err`/`Defect` to (for example) 2xx / 4xx / 5xx with no `try`/`catch`. * (For richer matching, a `Result` is also a discriminated union — branch on * its `tag` property, e.g. with `ts-pattern`.) * @@ -206,7 +206,7 @@ export type ResultMethods = { * @returns the `Ok` value. * @throws On `Err`, an {@link UnwrapError} carrying the error. On a `Defect`, * re-throws the **original cause** with its original stack, so an unhandled - * defect surfaces at the global handler as the real failure. + * Defect surfaces at the global handler as the real failure. */ unwrap(): T; /** @@ -221,7 +221,7 @@ export type ResultMethods = { * The success value, or `fallback` on `Err`. * * @param fallback - returned when the result is an `Err`. - * @throws Re-throws on a `Defect` — a defect is a bug, not an absent value, so + * @throws Re-throws on a `Defect` — a Defect is a bug, not an absent value, so * it is never silently replaced. */ unwrapOr(fallback: T): T; @@ -282,7 +282,7 @@ export type DefectView = ResultMethods & { * * - **`Ok`** — a success carrying a `value: T`. * - **`Err`** — a modeled, anticipated failure carrying an `error: E`. - * - **`Defect`** — an *unmodeled* failure carrying an unknown `cause`. A defect + * - **`Defect`** — an *unmodeled* failure carrying an unknown `cause`. A Defect * never appears in `E`; it is the library's third, out-of-band channel. * * Because it is a real union, you can match it natively (a `switch` on `tag`, or @@ -296,10 +296,10 @@ export type DefectView = ResultMethods & { * * @example * ```ts - * import { ok, err, type Result } from "unthrown"; + * import { Ok, Err, type Result } from "unthrown"; * * function half(n: number): Result { - * return n % 2 === 0 ? ok(n / 2) : err("odd"); + * return n % 2 === 0 ? Ok(n / 2) : Err("odd"); * } * * const message = half(10).match({ diff --git a/packages/effect/README.md b/packages/effect/README.md index 524c77e..9ebeca3 100644 --- a/packages/effect/README.md +++ b/packages/effect/README.md @@ -16,12 +16,12 @@ failure (`Cause.fail` ↔ `Err`) from an unexpected one (`Cause.die` ↔ `Defect So `Result ↔ Exit` is a genuine **bijection**. ```ts -import { ok, err } from "unthrown"; +import { Ok, Err } from "unthrown"; import { toExit, fromEffect, toEither } from "@unthrown/effect"; import { Effect } from "effect"; -toExit(ok(1)); // Exit.succeed(1) -toExit(err("e")); // Exit.fail("e") — a modeled Cause.fail +toExit(Ok(1)); // Exit.succeed(1) +toExit(Err("e")); // Exit.fail("e") — a modeled Cause.fail // Run an Effect and collect its outcome (die/interrupt become a Defect): await fromEffect(Effect.succeed(1)).match({ ok, err, defect: String }); diff --git a/packages/effect/src/index.spec.ts b/packages/effect/src/index.spec.ts index 4cdf5ca..b9bcd25 100644 --- a/packages/effect/src/index.spec.ts +++ b/packages/effect/src/index.spec.ts @@ -1,22 +1,22 @@ import { Cause, Effect, Either, Exit, FiberId, Option } from "effect"; -import { err, ok, type Result } from "unthrown"; +import { Err, Ok, type Result } from "unthrown"; import { describe, expect, it } from "vitest"; import { fromEffect, fromEither, fromExit, toEffect, toEither, toExit } from "./index.js"; const boom = new Error("boom"); -const aDefect: Result = ok(0).map(() => { +const aDefect: Result = Ok(0).map(() => { throw boom; }); describe("toExit", () => { it("maps Ok to a success", () => { - const exit = toExit(ok(1)); + const exit = toExit(Ok(1)); expect(Exit.isSuccess(exit) ? exit.value : undefined).toBe(1); }); it("maps Err to a modeled failure (Cause.fail)", () => { - const exit = toExit(err("nope") as Result); + const exit = toExit(Err("nope") as Result); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(Option.getOrNull(Cause.failureOption(exit.cause))).toBe("nope"); @@ -59,8 +59,8 @@ describe("fromExit", () => { }); it("round-trips Ok/Err/Defect through toExit", () => { - expect(fromExit(toExit(ok(1)))).toMatchObject({ tag: "Ok", value: 1 }); - expect(fromExit(toExit(err("nope") as Result))).toMatchObject({ + expect(fromExit(toExit(Ok(1)))).toMatchObject({ tag: "Ok", value: 1 }); + expect(fromExit(toExit(Err("nope") as Result))).toMatchObject({ tag: "Err", error: "nope", }); @@ -70,12 +70,12 @@ describe("fromExit", () => { describe("toEither", () => { it("maps Ok to Right and Err to Left", () => { - expect(Either.getOrNull(toEither(ok(1), () => "x"))).toBe(1); - const left = toEither(err("nope") as Result, () => "x"); + expect(Either.getOrNull(toEither(Ok(1), () => "x"))).toBe(1); + const left = toEither(Err("nope") as Result, () => "x"); expect(Either.isLeft(left) ? left.left : undefined).toBe("nope"); }); - it("forces a defect to be triaged into the error channel", () => { + it("forces a Defect to be triaged into the error channel", () => { const left = toEither(aDefect, (cause) => `bug:${String(cause)}`); expect(Either.isLeft(left) ? left.left : undefined).toBe(`bug:${String(boom)}`); }); @@ -90,8 +90,8 @@ describe("fromEither", () => { describe("toEffect", () => { it("maps a Result's three channels to succeed/fail/die", () => { - expect(Effect.runSyncExit(toEffect(ok(1)))).toStrictEqual(Exit.succeed(1)); - expect(Effect.runSyncExit(toEffect(err("nope") as Result))).toStrictEqual( + expect(Effect.runSyncExit(toEffect(Ok(1)))).toStrictEqual(Exit.succeed(1)); + expect(Effect.runSyncExit(toEffect(Err("nope") as Result))).toStrictEqual( Exit.fail("nope"), ); const exit = Effect.runSyncExit(toEffect(aDefect)); @@ -99,7 +99,7 @@ describe("toEffect", () => { }); it("accepts an AsyncResult (the AsyncResult -> Effect direction)", async () => { - expect(await Effect.runPromise(toEffect(ok(1).toAsync()))).toBe(1); + expect(await Effect.runPromise(toEffect(Ok(1).toAsync()))).toBe(1); const exit = await Effect.runPromiseExit(toEffect(aDefect.toAsync())); expect(Exit.isFailure(exit) && Option.getOrNull(Cause.dieOption(exit.cause))).toBe(boom); }); diff --git a/packages/effect/src/index.ts b/packages/effect/src/index.ts index 56390f9..05ebf70 100644 --- a/packages/effect/src/index.ts +++ b/packages/effect/src/index.ts @@ -6,18 +6,18 @@ // modeled failure (`Cause.fail`, ↔ `Err`) from an unexpected one (`Cause.die`, // ↔ `Defect`). So `Result ↔ Exit` is a genuine bijection — the showcase here. // -// import { ok } from "unthrown"; +// import { Ok } from "unthrown"; // import { toExit, fromEffect } from "@unthrown/effect"; // -// toExit(ok(1)); // Exit.succeed(1) +// toExit(Ok(1)); // Exit.succeed(1) // await fromEffect(Effect.succeed(1)).match({ ok, err, defect }); // // `Either` has only two channels, so converting a `Result` *into* an `Either` -// forces you to triage the defect with `onDefect` (Thesis #3): there is no +// forces you to triage the Defect with `onDefect` (Thesis #3): there is no // silent path that drops it. import { Cause, Effect, Either, Exit, Option } from "effect"; -import { defect, err, fromSafePromise, fromThrowable, ok } from "unthrown"; +import { Defect, Err, fromSafePromise, fromThrowable, Ok } from "unthrown"; import type { AsyncResult, Result } from "unthrown"; /** @@ -35,9 +35,9 @@ import type { AsyncResult, Result } from "unthrown"; * * @example * ```ts - * import { ok } from "unthrown"; + * import { Ok } from "unthrown"; * import { toExit } from "@unthrown/effect"; - * toExit(ok(1)); // Exit.succeed(1) + * toExit(Ok(1)); // Exit.succeed(1) * ``` */ export function toExit(result: Result): Exit.Exit { @@ -69,12 +69,12 @@ export function toExit(result: Result): Exit.Exit { */ export function fromExit(exit: Exit.Exit): Result { return Exit.match(exit, { - onSuccess: (value) => ok(value), + onSuccess: (value) => Ok(value), onFailure: (cause) => { const die = Cause.dieOption(cause); if (Option.isSome(die)) return dieToResult(die.value); const failure = Cause.failureOption(cause); - if (Option.isSome(failure)) return err(failure.value); + if (Option.isSome(failure)) return Err(failure.value); // No modeled failure and no die: a pure interruption (or empty cause). return dieToResult(Cause.squash(cause)); }, @@ -82,10 +82,10 @@ export function fromExit(exit: Exit.Exit): Result { } /** - * Convert a `Result` into an Effect `Either`, triaging any defect. + * Convert a `Result` into an Effect `Either`, triaging any Defect. * * @remarks - * `Either` has no defect channel, so a `Defect` cannot pass through silently — + * `Either` has no Defect channel, so a `Defect` cannot pass through silently — * `onDefect` **must** fold its cause into a modeled error `E` (a `Left`). This * is the boundary-qualification rule (Thesis #3) applied on the way out: * `Ok → Right`, `Err → Left`, `Defect → Left(onDefect(cause))`. @@ -93,7 +93,7 @@ export function fromExit(exit: Exit.Exit): Result { * @typeParam T - the success value type. * @typeParam E - the modeled error type. * @param result - the result to convert. - * @param onDefect - folds a defect's unknown cause into a modeled `E`. + * @param onDefect - folds a Defect's unknown cause into a modeled `E`. */ export function toEither( result: Result, @@ -110,7 +110,7 @@ export function toEither( * Convert an Effect `Either` into a `Result`. * * @remarks - * `Right → Ok`, `Left → Err`. An `Either` carries no defect, so the result is + * `Right → Ok`, `Left → Err`. An `Either` carries no Defect, so the result is * never a `Defect`. * * @typeParam T - the success value type. @@ -119,8 +119,8 @@ export function toEither( */ export function fromEither(either: Either.Either): Result { return Either.match(either, { - onLeft: (error) => err(error), - onRight: (value) => ok(value), + onLeft: (error) => Err(error), + onRight: (value) => Ok(value), }); } @@ -176,13 +176,13 @@ function resultToEffect(result: Result): Effect.Effect { // Effect's `die`/interruption channel is an un-triaged failure crossing into // unthrown; replaying it through the throwable boundary lands it in the `Defect` -// state — the sanctioned (boundary-only) way to mint a defect `Result`. +// state — the sanctioned (boundary-only) way to mint a Defect `Result`. function dieToResult(cause: unknown): Result { - // The thunk always throws, so its `T` return is honest; `qualify` is `defect`, + // The thunk always throws, so its `T` return is honest; `qualify` is `Defect`, // so the modeled error is `never` — widened to `E` here (there is no `Err`). return fromThrowable((): T => { throw cause; - }, defect)() as Result; + }, Defect)() as Result; } function settle(asyncResult: AsyncResult): Promise> { diff --git a/packages/neverthrow/README.md b/packages/neverthrow/README.md index 72041fb..1903a5b 100644 --- a/packages/neverthrow/README.md +++ b/packages/neverthrow/README.md @@ -16,11 +16,11 @@ every neverthrow result is an `Ok` or `Err` — never a `Defect`. Going **out**, `onDefect` — no defect is ever silently folded into your domain error type. ```ts -import { ok } from "unthrown"; +import { Ok } from "unthrown"; import { toNeverthrow, fromNeverthrow } from "@unthrown/neverthrow"; import { ok as ntOk } from "neverthrow"; -toNeverthrow(ok(1), (cause) => ({ _tag: "Bug", cause })); // neverthrow Ok(1) +toNeverthrow(Ok(1), (cause) => ({ _tag: "Bug", cause })); // neverthrow Ok(1) fromNeverthrow(ntOk(1)); // Result ``` diff --git a/packages/neverthrow/src/index.spec.ts b/packages/neverthrow/src/index.spec.ts index 94a4945..33ca9ab 100644 --- a/packages/neverthrow/src/index.spec.ts +++ b/packages/neverthrow/src/index.spec.ts @@ -5,40 +5,40 @@ import { err as neverthrowErr, ok as neverthrowOk, } from "neverthrow"; -import { err, ok, type Result } from "unthrown"; +import { Err, Ok, type Result } from "unthrown"; import { describe, expect, it } from "vitest"; import { fromNeverthrow, fromNeverthrowAsync, toNeverthrow, toNeverthrowAsync } from "./index.js"; const boom = new Error("boom"); -const aDefect: Result = ok(0).map(() => { +const aDefect: Result = Ok(0).map(() => { throw boom; }); describe("toNeverthrow", () => { it("maps Ok and Err across", () => { - const okR = toNeverthrow(ok(1), () => "x"); + const okR = toNeverthrow(Ok(1), () => "x"); expect(okR.isOk() && okR.value).toBe(1); - const errR = toNeverthrow(err("nope") as Result, () => "x"); + const errR = toNeverthrow(Err("nope") as Result, () => "x"); expect(errR.isErr() && errR.error).toBe("nope"); }); - it("forces a defect to be triaged into the error channel", () => { + it("forces a Defect to be triaged into the error channel", () => { const r = toNeverthrow(aDefect, (cause) => `bug:${String(cause)}`); expect(r.isErr() && r.error).toBe(`bug:${String(boom)}`); }); }); describe("fromNeverthrow", () => { - it("maps ok to Ok and err to Err — never a Defect", () => { + it("maps Ok to Ok and Err to Err — never a Defect", () => { expect(fromNeverthrow(neverthrowOk(1))).toMatchObject({ tag: "Ok", value: 1 }); expect(fromNeverthrow(neverthrowErr("nope"))).toMatchObject({ tag: "Err", error: "nope" }); }); }); describe("toNeverthrowAsync", () => { - it("maps Ok across and triages a defect", async () => { - const okR = await toNeverthrowAsync(ok(1).toAsync(), () => "x"); + it("maps Ok across and triages a Defect", async () => { + const okR = await toNeverthrowAsync(Ok(1).toAsync(), () => "x"); expect(okR.isOk() && okR.value).toBe(1); const defR = await toNeverthrowAsync(aDefect.toAsync(), (cause) => `bug:${String(cause)}`); expect(defR.isErr() && defR.error).toBe(`bug:${String(boom)}`); diff --git a/packages/neverthrow/src/index.ts b/packages/neverthrow/src/index.ts index 67cab5c..997946f 100644 --- a/packages/neverthrow/src/index.ts +++ b/packages/neverthrow/src/index.ts @@ -4,13 +4,13 @@ // neverthrow has two channels (`Ok`/`Err`), so it cannot represent unthrown's // third one. Coming *in*, every neverthrow result is an `Ok` or `Err` — never a // `Defect`. Going *out*, a `Defect` has nowhere to live, so `toNeverthrow` -// forces you to triage it with `onDefect` (Thesis #3): no defect is ever +// forces you to triage it with `onDefect` (Thesis #3): no Defect is ever // silently folded into your domain error type. // -// import { ok } from "unthrown"; +// import { Ok } from "unthrown"; // import { toNeverthrow, fromNeverthrow } from "@unthrown/neverthrow"; // -// toNeverthrow(ok(1), (cause) => ({ _tag: "Bug", cause })); +// toNeverthrow(Ok(1), (cause) => ({ _tag: "Bug", cause })); // fromNeverthrow(neverthrowOk(1)); // Result import { @@ -19,21 +19,21 @@ import { ResultAsync as NeverthrowResultAsync, } from "neverthrow"; import type { Result as NeverthrowResult } from "neverthrow"; -import { err, fromSafePromise, ok } from "unthrown"; +import { Err, fromSafePromise, Ok } from "unthrown"; import type { AsyncResult, Result } from "unthrown"; /** - * Convert a `Result` into a neverthrow `Result`, triaging any defect. + * Convert a `Result` into a neverthrow `Result`, triaging any Defect. * * @remarks - * neverthrow has no defect channel, so `onDefect` **must** fold a `Defect`'s - * cause into a modeled error `E` (an `Err`). `Ok → ok`, `Err → err`, - * `Defect → err(onDefect(cause))`. + * neverthrow has no Defect channel, so `onDefect` **must** fold a `Defect`'s + * cause into a modeled error `E` (an `Err`). `Ok → Ok`, `Err → Err`, + * `Defect → Err(onDefect(cause))`. * * @typeParam T - the success value type. * @typeParam E - the modeled error type. * @param result - the result to convert. - * @param onDefect - folds a defect's unknown cause into a modeled `E`. + * @param onDefect - folds a Defect's unknown cause into a modeled `E`. */ export function toNeverthrow( result: Result, @@ -50,7 +50,7 @@ export function toNeverthrow( * Convert a neverthrow `Result` into a `Result`. * * @remarks - * `ok → Ok`, `err → Err`. neverthrow carries no defect, so the result is never a + * `Ok → Ok`, `Err → Err`. neverthrow carries no Defect, so the result is never a * `Defect`. * * @typeParam T - the success value type. @@ -58,12 +58,12 @@ export function toNeverthrow( * @param result - the neverthrow result to convert. */ export function fromNeverthrow(result: NeverthrowResult): Result { - return result.isOk() ? ok(result.value) : err(result.error); + return result.isOk() ? Ok(result.value) : Err(result.error); } /** * Convert an `AsyncResult` into a neverthrow `ResultAsync`, triaging any - * defect. + * Defect. * * @remarks * The async counterpart of {@link toNeverthrow}: `onDefect` is required for the @@ -73,7 +73,7 @@ export function fromNeverthrow(result: NeverthrowResult): Result( asyncResult: AsyncResult, diff --git a/packages/pattern/README.md b/packages/pattern/README.md index b5ad4a1..85d2a1d 100644 --- a/packages/pattern/README.md +++ b/packages/pattern/README.md @@ -19,18 +19,18 @@ import { match } from "ts-pattern"; import * as P from "@unthrown/pattern"; match(result) - .with(P.ok(), ({ value }) => `ok: ${value}`) - .with(P.err(P.tag("Forbidden")), ({ error }) => `403 ${error.user}`) - .with(P.err(), () => "error") - .with(P.defect(), () => "bug") + .with(P.Ok(), ({ value }) => `ok: ${value}`) + .with(P.Err(P.tag("Forbidden")), ({ error }) => `403 ${error.user}`) + .with(P.Err(), () => "error") + .with(P.Defect(), () => "bug") .exhaustive(); ``` -- `P.ok(sub?)` / `P.err(sub?)` / `P.defect(sub?)` — match a channel; pass a +- `P.Ok(sub?)` / `P.Err(sub?)` / `P.Defect(sub?)` — match a channel; pass a sub-pattern to constrain or select the payload: a literal, or any `ts-pattern` pattern (e.g. `ts-pattern`'s own `P.string` / `P.select()`, imported from `ts-pattern`). (Or skip the sugar and match `{ tag: "Ok", … }` directly.) -- `P.tag(t)` — sugar for `{ _tag: t }`; nested in `P.err(...)` it narrows to the +- `P.tag(t)` — sugar for `{ _tag: t }`; nested in `P.Err(...)` it narrows to the matching `TaggedError` variant, including its payload. For the everyday exhaustive case, `matchTags` in core is simpler. Reach for this diff --git a/packages/pattern/src/index.spec.ts b/packages/pattern/src/index.spec.ts index 30484a4..d937704 100644 --- a/packages/pattern/src/index.spec.ts +++ b/packages/pattern/src/index.spec.ts @@ -1,5 +1,5 @@ import { match, P as TP } from "ts-pattern"; -import { err, ok, type Result, TaggedError } from "unthrown"; +import { Err, Ok, type Result, TaggedError } from "unthrown"; import { describe, expect, it } from "vitest"; import * as P from "./index.js"; @@ -9,7 +9,7 @@ class Forbidden extends TaggedError("Forbidden")<{ user: string }> {} type ApiError = NotFound | Forbidden; const boom = new Error("boom"); -const aDefect: Result = ok(0).map(() => { +const aDefect: Result = Ok(0).map(() => { throw boom; }); @@ -22,8 +22,8 @@ describe("a Result is natively matchable", () => { .with({ tag: "Defect" }, ({ cause }) => `defect:${String(cause)}`) .exhaustive(); - expect(fold(ok(2))).toBe("ok:2"); - expect(fold(err("x"))).toBe("err:x"); + expect(fold(Ok(2))).toBe("ok:2"); + expect(fold(Err("x"))).toBe("err:x"); expect(fold(aDefect)).toBe(`defect:${String(boom)}`); }); }); @@ -31,34 +31,34 @@ describe("a Result is natively matchable", () => { describe("pattern constructors", () => { const fold = (r: Result): string => match(r) - .with(P.ok(), ({ value }) => `ok:${value}`) + .with(P.Ok(), ({ value }) => `ok:${value}`) // `error.user` only type-checks because P.tag narrows to Forbidden. - .with(P.err(P.tag("Forbidden")), ({ error }) => `forbidden:${error.user}`) - .with(P.err(P.tag("NotFound")), () => "not-found") - .with(P.defect(), () => "defect") + .with(P.Err(P.tag("Forbidden")), ({ error }) => `forbidden:${error.user}`) + .with(P.Err(P.tag("NotFound")), () => "not-found") + .with(P.Defect(), () => "defect") .exhaustive(); - it("P.ok / P.err / P.defect narrow each channel", () => { - expect(fold(ok(5))).toBe("ok:5"); - expect(fold(err(new NotFound()))).toBe("not-found"); - expect(fold(err(new Forbidden({ user: "bob" })))).toBe("forbidden:bob"); + it("P.Ok / P.Err / P.Defect narrow each channel", () => { + expect(fold(Ok(5))).toBe("ok:5"); + expect(fold(Err(new NotFound()))).toBe("not-found"); + expect(fold(Err(new Forbidden({ user: "bob" })))).toBe("forbidden:bob"); }); - it("P.ok(pattern) constrains and selects the value", () => { - const out = match(ok(42) as Result) - .with(P.ok(TP.select()), (v) => v + 1) + it("P.Ok(pattern) constrains and selects the value", () => { + const out = match(Ok(42) as Result) + .with(P.Ok(TP.select()), (v) => v + 1) .otherwise(() => 0); expect(out).toBe(43); }); it("constructors return plain ts-pattern object patterns", () => { - expect(P.ok()).toEqual({ tag: "Ok" }); - expect(P.err()).toEqual({ tag: "Err" }); - expect(P.defect()).toEqual({ tag: "Defect" }); + expect(P.Ok()).toEqual({ tag: "Ok" }); + expect(P.Err()).toEqual({ tag: "Err" }); + expect(P.Defect()).toEqual({ tag: "Defect" }); // with a sub-pattern argument on each channel - expect(P.ok(1)).toEqual({ tag: "Ok", value: 1 }); - expect(P.err("boom")).toEqual({ tag: "Err", error: "boom" }); - expect(P.defect("cause")).toEqual({ tag: "Defect", cause: "cause" }); + expect(P.Ok(1)).toEqual({ tag: "Ok", value: 1 }); + expect(P.Err("boom")).toEqual({ tag: "Err", error: "boom" }); + expect(P.Defect("cause")).toEqual({ tag: "Defect", cause: "cause" }); expect(P.tag("X")).toEqual({ _tag: "X" }); }); }); diff --git a/packages/pattern/src/index.ts b/packages/pattern/src/index.ts index 6f36924..8fba123 100644 --- a/packages/pattern/src/index.ts +++ b/packages/pattern/src/index.ts @@ -3,17 +3,17 @@ // A `Result` is a discriminated union (`{ tag: "Ok" | "Err" | "Defect" }`), so // `ts-pattern` matches it directly — narrowing, selection, and `.exhaustive()` // all work out of the box. This package is just sugar: pattern constructors so -// you can write `P.ok(...)` instead of the raw `{ tag: "Ok", value: ... }` +// you can write `P.Ok(...)` instead of the raw `{ tag: "Ok", value: ... }` // object pattern, plus `tag` for matching a `TaggedError` by its `_tag`. // // import { match } from "ts-pattern"; // import * as P from "@unthrown/pattern"; // // match(result) -// .with(P.ok(), ({ value }) => `ok: ${value}`) -// .with(P.err(P.tag("NotFound")), () => "404") -// .with(P.err(), ({ error }) => `error: ${error}`) -// .with(P.defect(), ({ cause }) => `bug: ${String(cause)}`) +// .with(P.Ok(), ({ value }) => `ok: ${value}`) +// .with(P.Err(P.tag("NotFound")), () => "404") +// .with(P.Err(), ({ error }) => `error: ${error}`) +// .with(P.Defect(), ({ cause }) => `bug: ${String(cause)}`) // .exhaustive(); // // Here `P` is THIS package. To also use ts-pattern's own patterns (wildcards, @@ -21,7 +21,7 @@ // // import { match, P as t } from "ts-pattern"; // import * as P from "@unthrown/pattern"; -// match(result).with(P.ok(t.select()), (value) => value).otherwise(() => …); +// match(result).with(P.Ok(t.select()), (value) => value).otherwise(() => …); /** * A `ts-pattern` pattern matching the `Ok` variant of a `Result`. With no @@ -31,9 +31,9 @@ * * @typeParam V - the sub-pattern matched against the `Ok` value. */ -export function ok(): { tag: "Ok" }; -export function ok(value: V): { tag: "Ok"; value: V }; -export function ok(...args: [] | [unknown]): { tag: "Ok"; value?: unknown } { +export function Ok(): { tag: "Ok" }; +export function Ok(value: V): { tag: "Ok"; value: V }; +export function Ok(...args: [] | [unknown]): { tag: "Ok"; value?: unknown } { return args.length === 0 ? { tag: "Ok" } : { tag: "Ok", value: args[0] }; } @@ -44,9 +44,9 @@ export function ok(...args: [] | [unknown]): { tag: "Ok"; value?: unknown } { * * @typeParam V - the sub-pattern matched against the `Err` error. */ -export function err(): { tag: "Err" }; -export function err(error: V): { tag: "Err"; error: V }; -export function err(...args: [] | [unknown]): { tag: "Err"; error?: unknown } { +export function Err(): { tag: "Err" }; +export function Err(error: V): { tag: "Err"; error: V }; +export function Err(...args: [] | [unknown]): { tag: "Err"; error?: unknown } { return args.length === 0 ? { tag: "Err" } : { tag: "Err", error: args[0] }; } @@ -57,16 +57,16 @@ export function err(...args: [] | [unknown]): { tag: "Err"; error?: unknown } { * * @typeParam V - the sub-pattern matched against the `Defect` cause. */ -export function defect(): { tag: "Defect" }; -export function defect(cause: V): { tag: "Defect"; cause: V }; -export function defect(...args: [] | [unknown]): { tag: "Defect"; cause?: unknown } { +export function Defect(): { tag: "Defect" }; +export function Defect(cause: V): { tag: "Defect"; cause: V }; +export function Defect(...args: [] | [unknown]): { tag: "Defect"; cause?: unknown } { return args.length === 0 ? { tag: "Defect" } : { tag: "Defect", cause: args[0] }; } /** * A `ts-pattern` pattern matching any value whose `_tag` equals `value` (e.g. a * `TaggedError`). Equivalent to the object pattern `{ _tag: value }`, but reads - * better nested inside an {@link err} pattern and narrows to the matching + * better nested inside an {@link Err} pattern and narrows to the matching * variant — including its payload. * * @typeParam Tag - the string literal tag to match. @@ -74,7 +74,7 @@ export function defect(...args: [] | [unknown]): { tag: "Defect"; cause?: unknow * * @example * ```ts - * .with(P.err(P.tag("Forbidden")), ({ error }) => error.user) + * .with(P.Err(P.tag("Forbidden")), ({ error }) => error.user) * ``` */ export function tag(value: Tag): { _tag: Tag } { diff --git a/packages/standard-schema/src/index.ts b/packages/standard-schema/src/index.ts index ec29869..8abf006 100644 --- a/packages/standard-schema/src/index.ts +++ b/packages/standard-schema/src/index.ts @@ -5,7 +5,7 @@ // on failure. `fromSchema` turns that into a validator returning // `Result`; `fromSchemaAsync` is the async counterpart // (and also accepts synchronous schemas). The `issues` array is the modeled `E` -// — a failed validation is an anticipated outcome, never a defect. +// — a failed validation is an anticipated outcome, never a Defect. // // import { fromSchema } from "@unthrown/standard-schema"; // import { z } from "zod"; @@ -14,7 +14,7 @@ // parseUser(input); // Result<{ id: string }, readonly StandardSchemaV1.Issue[]> import type { StandardSchemaV1 } from "@standard-schema/spec"; -import { defect, err, fromSafePromise, fromThrowable, ok } from "unthrown"; +import { Defect, Err, fromSafePromise, fromThrowable, Ok } from "unthrown"; import type { AsyncResult, Result } from "unthrown"; /** The error channel both entry points produce: a schema's validation issues. */ @@ -26,7 +26,7 @@ export type SchemaIssues = readonly StandardSchemaV1.Issue[]; * * @remarks * Validation issues are the modeled error `E` — `Result` — - * because a failed validation is an *anticipated* outcome, not a defect. Works + * because a failed validation is an *anticipated* outcome, not a Defect. Works * with any Standard Schema implementation (Zod, Valibot, ArkType, …). * * A validator that **throws** (rather than returning issues) becomes a `Defect` @@ -53,10 +53,10 @@ export function fromSchema( ): (input: unknown) => Result, SchemaIssues> { type Output = StandardSchemaV1.InferOutput; // Run `validate` at a boundary so a *throwing* validator lands in the Defect - // channel instead of escaping; `qualify` only ever mints a defect, so E = never. + // channel instead of escaping; `qualify` only ever mints a Defect, so E = never. const validate = fromThrowable( (input: unknown) => schema["~standard"].validate(input), - (cause) => defect(cause), + (cause) => Defect(cause), ); return (input) => { const settled = validate(input); @@ -68,7 +68,7 @@ export function fromSchema( } return settled.flatMap((result) => { const sync = result as StandardSchemaV1.Result; - return sync.issues ? err(sync.issues) : ok(sync.value); + return sync.issues ? Err(sync.issues) : Ok(sync.value); }); }; } @@ -99,6 +99,6 @@ export function fromSchemaAsync( ): (input: unknown) => AsyncResult, SchemaIssues> { return (input) => fromSafePromise(async () => schema["~standard"].validate(input)).flatMap((result) => - result.issues ? err(result.issues) : ok(result.value), + result.issues ? Err(result.issues) : Ok(result.value), ); } diff --git a/packages/vitest/README.md b/packages/vitest/README.md index eee1f18..ebb3389 100644 --- a/packages/vitest/README.md +++ b/packages/vitest/README.md @@ -17,15 +17,15 @@ import "@unthrown/vitest"; ``` ```ts -expect(ok(1)).toBeOk(); -expect(ok(1)).toBeOkWith(1); -expect(err(new NotFound())).toBeErrTagged("NotFound"); +expect(Ok(1)).toBeOk(); +expect(Ok(1)).toBeOkWith(1); +expect(Err(new NotFound())).toBeErrTagged("NotFound"); expect(aDefect).toBeDefect(); // toBeErrTagged also takes an optional payload: a plain object matches exactly, // an asymmetric matcher matches partially. -expect(err(new NotFound({ id: 1 }))).toBeErrTagged("NotFound", { id: 1 }); -expect(err(new NotFound({ id: 1, msg: "x" }))).toBeErrTagged( +expect(Err(new NotFound({ id: 1 }))).toBeErrTagged("NotFound", { id: 1 }); +expect(Err(new NotFound({ id: 1, msg: "x" }))).toBeErrTagged( "NotFound", expect.objectContaining({ id: 1 }), ); diff --git a/packages/vitest/src/index.spec.ts b/packages/vitest/src/index.spec.ts index 2d2acde..9bfc4af 100644 --- a/packages/vitest/src/index.spec.ts +++ b/packages/vitest/src/index.spec.ts @@ -1,4 +1,4 @@ -import { err, fromSafePromise, ok, type Result, TaggedError } from "unthrown"; +import { Err, fromSafePromise, Ok, type Result, TaggedError } from "unthrown"; import { describe, expect, it } from "vitest"; // Registers the matchers and brings the `Matchers` augmentation into scope. @@ -7,41 +7,41 @@ import "./index.js"; class MyError extends TaggedError("MyError")<{ code: number }> {} const boom = new Error("boom"); -const aDefect: Result = ok(0).map(() => { +const aDefect: Result = Ok(0).map(() => { throw boom; }); describe("toBeOk / toBeOkWith", () => { it("passes on Ok and fails otherwise", () => { - expect(ok(1)).toBeOk(); - expect(err("e")).not.toBeOk(); + expect(Ok(1)).toBeOk(); + expect(Err("e")).not.toBeOk(); expect(aDefect).not.toBeOk(); }); it("toBeOkWith compares the success value deeply", () => { - expect(ok(1)).toBeOkWith(1); - expect(ok({ a: [1, 2] })).toBeOkWith({ a: [1, 2] }); - expect(ok(1)).not.toBeOkWith(2); - expect(err("e")).not.toBeOkWith(1); + expect(Ok(1)).toBeOkWith(1); + expect(Ok({ a: [1, 2] })).toBeOkWith({ a: [1, 2] }); + expect(Ok(1)).not.toBeOkWith(2); + expect(Err("e")).not.toBeOkWith(1); }); }); describe("toBeErr / toBeErrTagged", () => { it("passes on Err and fails otherwise", () => { - expect(err("e")).toBeErr(); - expect(ok(1)).not.toBeErr(); + expect(Err("e")).toBeErr(); + expect(Ok(1)).not.toBeErr(); expect(aDefect).not.toBeErr(); }); it("toBeErrTagged matches a tagged error by its _tag", () => { - const r: Result = err(new MyError({ code: 42 })); + const r: Result = Err(new MyError({ code: 42 })); expect(r).toBeErrTagged("MyError"); expect(r).not.toBeErrTagged("Other"); - expect(err("plain")).not.toBeErrTagged("MyError"); + expect(Err("plain")).not.toBeErrTagged("MyError"); }); it("toBeErrTagged matches the payload exactly when given a plain object", () => { - const r: Result = err(new MyError({ code: 42 })); + const r: Result = Err(new MyError({ code: 42 })); expect(r).toBeErrTagged("MyError", { code: 42 }); expect(r).not.toBeErrTagged("MyError", { code: 99 }); // exact: an extra/missing field fails @@ -50,7 +50,7 @@ describe("toBeErr / toBeErrTagged", () => { it("toBeErrTagged matches the payload partially with an asymmetric matcher", () => { class Multi extends TaggedError("Multi")<{ id: number; msg: string }> {} - const r: Result = err(new Multi({ id: 1, msg: "boom" })); + const r: Result = Err(new Multi({ id: 1, msg: "boom" })); expect(r).toBeErrTagged("Multi", expect.objectContaining({ id: 1 })); expect(r).toBeErrTagged("Multi", { id: 1, msg: "boom" }); expect(r).not.toBeErrTagged("Multi", expect.objectContaining({ id: 2 })); @@ -59,7 +59,7 @@ describe("toBeErr / toBeErrTagged", () => { }); it("toBeErrTagged with a payload still requires the right tag", () => { - const r: Result = err(new MyError({ code: 42 })); + const r: Result = Err(new MyError({ code: 42 })); expect(r).not.toBeErrTagged("Other", { code: 42 }); }); }); @@ -67,8 +67,8 @@ describe("toBeErr / toBeErrTagged", () => { describe("toBeDefect", () => { it("passes on a Defect and fails otherwise", () => { expect(aDefect).toBeDefect(); - expect(ok(1)).not.toBeDefect(); - expect(err("e")).not.toBeDefect(); + expect(Ok(1)).not.toBeDefect(); + expect(Err("e")).not.toBeDefect(); }); }); @@ -91,20 +91,20 @@ describe("non-Result input", () => { describe("failure messages", () => { // A failing positive assertion renders the actual result it received. it("renders the actual Ok / Err / Defect in the 'but got …' message", () => { - expect(() => expect(ok(1)).toBeErr()).toThrowError(/to be Err, but got Ok\(1\)/); - expect(() => expect(err("e")).toBeOk()).toThrowError(/to be Ok, but got Err\("e"\)/); + expect(() => expect(Ok(1)).toBeErr()).toThrowError(/to be Err, but got Ok\(1\)/); + expect(() => expect(Err("e")).toBeOk()).toThrowError(/to be Ok, but got Err\("e"\)/); expect(() => expect(aDefect).toBeOk()).toThrowError(/to be Ok, but got Defect\(/); }); it("reports the expected value/tag on the other matchers", () => { - expect(() => expect(ok(1)).toBeOkWith(2)).toThrowError(/to be Ok\(2\), but got Ok\(1\)/); - expect(() => expect(ok(1)).toBeErrTagged("Nope")).toThrowError( + expect(() => expect(Ok(1)).toBeOkWith(2)).toThrowError(/to be Ok\(2\), but got Ok\(1\)/); + expect(() => expect(Ok(1)).toBeErrTagged("Nope")).toThrowError( /to be Err tagged "Nope", but got Ok\(1\)/, ); expect(() => - expect(err(new MyError({ code: 1 }))).toBeErrTagged("MyError", { code: 2 }), + expect(Err(new MyError({ code: 1 }))).toBeErrTagged("MyError", { code: 2 }), ).toThrowError(/to be Err tagged "MyError" matching/); - expect(() => expect(ok(1)).toBeDefect()).toThrowError(/to be a Defect, but got Ok\(1\)/); + expect(() => expect(Ok(1)).toBeDefect()).toThrowError(/to be a Defect, but got Ok\(1\)/); }); it("reports a clear message for a non-Result value", () => { @@ -113,12 +113,12 @@ describe("failure messages", () => { // A failing negated assertion renders the inverse ('not to be …') message. it("renders the inverse message when a negated assertion fails", () => { - expect(() => expect(ok(1)).not.toBeOk()).toThrowError(/not to be Ok, but it was Ok\(1\)/); - expect(() => expect(ok(1)).not.toBeOkWith(1)).toThrowError(/not to be Ok\(1\)/); - expect(() => expect(err("e")).not.toBeErr()).toThrowError( + expect(() => expect(Ok(1)).not.toBeOk()).toThrowError(/not to be Ok, but it was Ok\(1\)/); + expect(() => expect(Ok(1)).not.toBeOkWith(1)).toThrowError(/not to be Ok\(1\)/); + expect(() => expect(Err("e")).not.toBeErr()).toThrowError( /not to be Err, but it was Err\("e"\)/, ); - expect(() => expect(err(new MyError({ code: 1 }))).not.toBeErrTagged("MyError")).toThrowError( + expect(() => expect(Err(new MyError({ code: 1 }))).not.toBeErrTagged("MyError")).toThrowError( /not to be Err tagged "MyError"/, ); expect(() => expect(aDefect).not.toBeDefect()).toThrowError(/not to be a Defect/); diff --git a/packages/vitest/src/index.ts b/packages/vitest/src/index.ts index b4f92b5..a1e8c5a 100644 --- a/packages/vitest/src/index.ts +++ b/packages/vitest/src/index.ts @@ -4,7 +4,7 @@ // and pull in the type augmentation: // // import "@unthrown/vitest"; -// expect(ok(1)).toBeOk(); +// expect(Ok(1)).toBeOk(); // await expect(fromSafePromise(p)).toBeOk(); // AsyncResult — `await` REQUIRED // // IMPORTANT: for an AsyncResult the matcher is asynchronous, so you MUST `await` From 19b4ec19a7a7a67246af6e403dcae235c92891de Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sat, 27 Jun 2026 23:42:27 +0200 Subject: [PATCH 5/5] fix(core): fail-fast Defect when bind/let gets a non-object scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bind/let live on the general Result surface, so a primitive Ok (e.g. Ok(5).bind(...)) could reach the scope merge and `{ ...5 }` would silently collapse to `{}`, dropping the prior scope. Route the merge through a `scopeOf` guard that throws on a non-object value; the surrounding try turns it into a Defect — misuse surfaced as the bug it is, not a silent drop. Covers sync + async bind/let. A `this: object` constraint was rejected: TS does not hard-enforce a constraint inferred solely from `this`, and it breaks `AsyncRes implements AsyncResult`. Also fix two doc snippets to import the constructors they use (do-notation `Ok`, index `Defect`). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/guide/do-notation.md | 2 ++ docs/index.md | 2 +- packages/core/src/core.ts | 33 +++++++++++++++++++++++++++++---- packages/core/src/do.spec.ts | 27 +++++++++++++++++++++++++++ packages/core/src/types.ts | 4 +++- 5 files changed, 62 insertions(+), 6 deletions(-) diff --git a/docs/guide/do-notation.md b/docs/guide/do-notation.md index da4ae2c..d9bae97 100644 --- a/docs/guide/do-notation.md +++ b/docs/guide/do-notation.md @@ -39,6 +39,8 @@ the normal surface, so you can mix in `map`, `flatMap`, `match`, and the rest freely, and a thrown callback still becomes a `Defect`: ```ts +import { Do, Ok } from "unthrown"; + Do() .bind("n", () => Ok(2)) .let("doubled", ({ n }) => n * 2) diff --git a/docs/index.md b/docs/index.md index f923a0b..3b10482 100644 --- a/docs/index.md +++ b/docs/index.md @@ -39,7 +39,7 @@ features: ## At a glance ```ts -import { Ok, Err, fromPromise, type Result } from "unthrown"; +import { Ok, Err, Defect, fromPromise, type Result } from "unthrown"; class NotFound extends TaggedError("NotFound") {} diff --git a/packages/core/src/core.ts b/packages/core/src/core.ts index eb20989..1446804 100644 --- a/packages/core/src/core.ts +++ b/packages/core/src/core.ts @@ -99,7 +99,7 @@ class Res { if (r.tag !== "Ok") return passThrough(r); // The merged scope can't be spelled at the type level (a computed key // widens to an index signature), so the constructed Ok is cast to `Bound`. - return okRes({ ...(this.value as object), [name]: r.value }) as unknown as Result< + return okRes({ ...scopeOf(this.value), [name]: r.value }) as unknown as Result< Bound, E | E2 >; @@ -115,7 +115,7 @@ class Res { ): Result, E> { if (this.tag !== "Ok") return passThrough(this); try { - return okRes({ ...(this.value as object), [name]: f(this.value) }) as unknown as Result< + return okRes({ ...scopeOf(this.value), [name]: f(this.value) }) as unknown as Result< Bound, E >; @@ -321,6 +321,31 @@ function passThrough(self: Result): Result { return self as unknown as Result; } +/** + * Validate that a `bind`/`let` scope is a real (non-null) object before merging a + * key into it. + * + * @remarks + * Do-notation accumulates an **object** scope: a chain starts at `Do()` (an + * empty object) and every `bind`/`let` returns an object, so in typed code the + * scope is always an object. The method lives on the general `Result` surface, + * though, so a primitive `Ok` (e.g. `Ok(5).bind(...)`, or a chain whose value was + * `map`-ped away from its scope) could reach it. Rather than let `{ ...5 }` + * silently collapse to `{}` and drop the prior scope, we throw here — the + * surrounding `try` turns it into a {@link Defect}, surfacing the misuse as the + * bug it is (a defect is a bug, not an absent value). A `this: object` constraint + * was rejected: TypeScript does not hard-enforce a constraint inferred solely + * from `this`, and it breaks `AsyncRes implements AsyncResult`. + * + * @internal + */ +function scopeOf(value: unknown): object { + if (typeof value !== "object" || value === null) { + throw new TypeError("bind/let requires an object scope — start a do-chain with Do()"); + } + return value; +} + /** * The sole runtime implementation of {@link AsyncResult}: wraps a * `Promise` constructed never to reject. Operates on the public `Result` @@ -406,7 +431,7 @@ export class AsyncRes implements AsyncResult { try { const inner = await f(r.value); if (inner.tag !== "Ok") return passThrough(inner); - return okRes({ ...(r.value as object), [name]: inner.value }) as unknown as Result< + return okRes({ ...scopeOf(r.value), [name]: inner.value }) as unknown as Result< Bound, E | E2 >; @@ -422,7 +447,7 @@ export class AsyncRes implements AsyncResult { this.promise.then((r) => { if (r.tag !== "Ok") return passThrough(r); try { - return okRes({ ...(r.value as object), [name]: f(r.value) }) as unknown as Result< + return okRes({ ...scopeOf(r.value), [name]: f(r.value) }) as unknown as Result< Bound, E >; diff --git a/packages/core/src/do.spec.ts b/packages/core/src/do.spec.ts index 64b0737..a33e338 100644 --- a/packages/core/src/do.spec.ts +++ b/packages/core/src/do.spec.ts @@ -62,6 +62,21 @@ describe("Do / bind / let", () => { .isDefect(), ).toBe(true); }); + + it("turns bind/let on a non-object scope into a Defect (misuse, not a silent drop)", () => { + // `bind`/`let` live on the general surface; calling them on a primitive `Ok` + // would otherwise spread `{ ...5 }` into `{}` and silently drop the value. + expect( + Ok(5) + .bind("a", () => Ok(1)) + .isDefect(), + ).toBe(true); + expect( + Ok(5) + .let("a", () => 1) + .isDefect(), + ).toBe(true); + }); }); describe("Do / bind / let — async", () => { @@ -97,4 +112,16 @@ describe("Do / bind / let — async", () => { }); expect(fromLet.isDefect()).toBe(true); }); + + it("turns an async bind/let on a non-object scope into a Defect", async () => { + const fromBind = await Ok(5) + .toAsync() + .bind("a", () => Ok(1)); + expect(fromBind.isDefect()).toBe(true); + + const fromLet = await Ok(5) + .toAsync() + .let("a", () => 1); + expect(fromLet.isDefect()).toBe(true); + }); }); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 02aee44..2a32d66 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -84,7 +84,9 @@ export type ResultMethods = { * step. `f` receives the scope accumulated so far and returns a `Result`; on * `Ok` the value is added as `{ ...scope, [name]: value }`, on `Err`/`Defect` * the chain short-circuits. Errors union (`E | E2`). A throw becomes a - * `Defect`. (`let` is the pure-value counterpart.) + * `Defect` — as does calling `bind` on a non-object scope (e.g. `Ok(5).bind`), + * which is misuse: the scope is always an object inside a real `Do()` chain. + * (`let` is the pure-value counterpart.) * * @typeParam K - the key the bound value is stored under. * @typeParam U - the bound value type.