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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/capitalize-constructors.md
Original file line number Diff line number Diff line change
@@ -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`.
26 changes: 26 additions & 0 deletions .changeset/do-notation.md
Original file line number Diff line number Diff line change
@@ -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<User, NotFound>
.bind("org", ({ user }) => findOrg(user.orgId)) // Result<Org, NotFound>
.let("label", ({ user, org }) => `${user.name} @ ${org.name}`)
.map(({ user, org, label }) => render(user, org, label));
// Result<View, NotFound>
```

`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.
43 changes: 28 additions & 15 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`,
Expand All @@ -94,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<T, E>[]` / `AsyncResult<T, E>[]` collapses
Expand All @@ -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.
Expand All @@ -119,10 +126,11 @@ async work re-enters via `fromPromise` / `fromSafePromise` and composes with
per-tag` fold; has an async overload resolving to `Promise<R>`); 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)

Expand All @@ -135,25 +143,30 @@ enough that the 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<T, E>` 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<Result>`, 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<Result<T,E>>`, not `PromiseLike`.** Its `then`
stays a runtime thenable (so `await` collapses it) and forwards `onrejected`
defensively, but the type advertises no rejection channel — because the internal
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).

Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<Result>`. |
Expand Down
1 change: 1 addition & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
],
Expand Down
4 changes: 2 additions & 2 deletions docs/api/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
10 changes: 5 additions & 5 deletions docs/guide/boundaries.md
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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),
);
```

Expand All @@ -58,7 +58,7 @@ The boxed/neverthrow "original sin" is `fromPromise(p): AsyncResult<T, unknown>`

The error channel is inferred as `Exclude<R, Defect>` — the `Defect` arm of
`qualify`'s return is **subtracted**, never inferred into `E`. So a `qualify`
that returns _only_ `defect(cause)` gives `AsyncResult<T, never>`, not
that returns _only_ `Defect(cause)` gives `AsyncResult<T, never>`, not
`AsyncResult<T, Defect>` — a defect stays out-of-band. (When _every_ rejection is
a defect, reach for `fromSafePromise` below.)

Expand Down
1 change: 1 addition & 0 deletions docs/guide/choosing-a-combinator.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
40 changes: 21 additions & 19 deletions docs/guide/core-concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand All @@ -33,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
Expand All @@ -45,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<void, WriteError>
.map((u) => u.name);
// → still the original user on success; short-circuits to WriteError on failure
Expand All @@ -56,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
Expand All @@ -65,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

Expand All @@ -83,7 +85,7 @@ predicates):
```ts
import { isOk, isErr } from "unthrown";

const r: Result<number, string> = ok(7);
const r: Result<number, string> = Ok(7);

// standalone function
if (isOk(r)) {
Expand All @@ -101,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
Expand All @@ -120,19 +122,19 @@ earlier `Err`). A fixed tuple keeps its positional types; a dynamic
`Result<T, E>[]` collapses to `Result<T[], E>`:

```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<number, never>[]).unwrap(); // number[]
all([Ok(1), Ok("two"), Ok(true)]).unwrap(); // [1, "two", true] (typed [number, string, boolean])
all([Ok(1), Ok(2)] as Result<number, never>[]).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
Expand Down
Loading
Loading