diff --git a/.changeset/flat-tap-err-is-result.md b/.changeset/flat-tap-err-is-result.md new file mode 100644 index 0000000..b891ca3 --- /dev/null +++ b/.changeset/flat-tap-err-is-result.md @@ -0,0 +1,17 @@ +--- +"unthrown": minor +--- + +Add two members, closing the only structural gaps surfaced by comparing the +surface against boxed / neverthrow / byethrow: + +- **`flatTapErr`** (on `Result` and `AsyncResult`) — the error-channel mirror of + `flatTap`: runs a `Result`-returning effect on the error, keeps the original + error on the effect's success, and threads the effect's error otherwise + (`Result`). A throw becomes a `Defect`, like every other combinator. + Use it for a failable effect _during_ error handling (e.g. writing the error to + an audit log that may itself fail). +- **`isResult(x)`** — a standalone type guard narrowing an `unknown` to + `Result` (and `Result.isResult`). It checks the value carries + the `Result` prototype, so a plain `{ tag: "Ok" }` look-alike is not matched; + an `AsyncResult` is not a `Result`. For untyped interop boundaries. diff --git a/CLAUDE.md b/CLAUDE.md index b146a3f..a195881 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,7 +47,7 @@ was planned). - **Throw → defect.** Any value thrown by a callback inside a combinator (`map`, `flatMap`, `flatTap`, `bind`, `let`, `mapErr`, `orElse`, `recover`, - `tap*`, `recoverDefect`) is caught and converted to a `Defect`. Nothing + `tap*`, `flatTapErr`, `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`**. @@ -93,14 +93,18 @@ async work re-enters via `fromPromise` / `fromSafePromise` and composes with 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` +- error: `mapErr`, `orElse`, `recover`, `tapErr`, `flatTapErr` (the error-channel + mirror of `flatTap` — runs a `Result`-returning effect on the error, keeps the + original error, threads the effect's error) - defect: `recoverDefect`, `tapDefect` - eliminate: `match`, `unwrap`, `unwrapErr`, `unwrapOr`, `unwrapOrElse`, `getOrNull`, `getOrUndefined` - guards: methods `isOk`/`isErr`/`isDefect` **and** standalone `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. + One narrowing concept, two call styles. Plus the standalone `isResult(x)` — + narrows an `unknown` to `Result` (a prototype check, so a + plain `{ tag: "Ok" }` look-alike is not matched), for untyped boundaries. - constructors: `Ok`, `Err`, `Defect` - interop: `fromNullable`, `fromThrowable`, `fromPromise`, `fromSafePromise` - aggregate: `all` / `allAsync` take a **tuple/array** (a fixed tuple keeps diff --git a/docs/guide/choosing-a-combinator.md b/docs/guide/choosing-a-combinator.md index 7fe2c81..065507d 100644 --- a/docs/guide/choosing-a-combinator.md +++ b/docs/guide/choosing-a-combinator.md @@ -18,6 +18,7 @@ channel** and turns a thrown callback into a `Defect`. | try a fallback that returns a `Result` | `orElse` | Err | | turn an error into a success value | `recover` | Err | | run a side effect on the error | `tapErr` | Err | +| run a **failable** side effect on the error | `flatTapErr` | Err | | recover from a defect (rare) | `recoverDefect` | Defect | | observe a defect, e.g. log it | `tapDefect` | Defect | | handle all three channels at the edge | `match` | all | @@ -38,6 +39,12 @@ don't need). **replaces** the value with the callback's; `flatTap` **discards** the callback's value and keeps the original. +**`tapErr` vs `flatTapErr`** — the error-channel mirror of `tap` vs `flatTap`. +Both run only on `Err` and keep the original error on success. `tapErr` takes a +`void` callback that can't fail; `flatTapErr` takes a `Result`-returning one and +threads its error (a failable effect _during_ error handling — e.g. writing the +error to an audit log that may itself fail). + **`orElse` vs `recover`** — both run on `Err`. `recover` produces a plain success value (emptying the error channel to `never`); `orElse` produces another `Result` (which may still be an `Err`). diff --git a/docs/guide/core-concepts.md b/docs/guide/core-concepts.md index 96bae2f..e69d205 100644 --- a/docs/guide/core-concepts.md +++ b/docs/guide/core-concepts.md @@ -26,7 +26,7 @@ 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` +- **error** (runs on `Err`): `mapErr`, `orElse`, `recover`, `tapErr`, `flatTapErr` - **defect** (the only door to a `Defect`): `recoverDefect`, `tapDefect` - **eliminate**: `match`, `unwrap`, `unwrapErr`, `unwrapOr`, `unwrapOrElse`, `getOrNull`, `getOrUndefined` @@ -98,6 +98,11 @@ if (r.isErr()) { } ``` +To narrow an **`unknown`** value (e.g. at an untyped boundary) to a `Result` in +the first place, use the standalone `isResult(x)`. It checks the value carries +the `Result` prototype, so a plain look-alike like `{ tag: "Ok" }` is **not** +matched. + ## Eliminating a Result Once you are ready to leave the `Result` world, pick the right exit: diff --git a/packages/core/src/async-result.spec.ts b/packages/core/src/async-result.spec.ts index db742b3..af6c91d 100644 --- a/packages/core/src/async-result.spec.ts +++ b/packages/core/src/async-result.spec.ts @@ -71,6 +71,7 @@ describe("AsyncResult: a throw in any combinator becomes a Defect", () => { expect((await asyncErr("e").orElse(t)).isDefect()).toBe(true); expect((await asyncErr("e").recover(t)).isDefect()).toBe(true); expect((await asyncErr("e").tapErr(t)).isDefect()).toBe(true); + expect((await asyncErr("e").flatTapErr(t)).isDefect()).toBe(true); expect((await asyncDefect().recoverDefect(t)).isDefect()).toBe(true); expect((await asyncDefect().tapDefect(t)).isDefect()).toBe(true); }); @@ -158,6 +159,29 @@ describe("AsyncResult error channel", () => { expect(seen).toEqual(["e"]); expect(r.unwrapErr()).toBe("e"); }); + + it("flatTapErr keeps the original error when the effect succeeds", async () => { + const r = await asyncErr("e").flatTapErr(() => Ok("ignored")); + expect(r.unwrapErr()).toBe("e"); + }); + + it("flatTapErr threads the effect's Err", async () => { + expect((await asyncErr("e").flatTapErr(() => Err("log_failed"))).unwrapErr()).toBe( + "log_failed", + ); + }); + + it("flatTapErr composes an async effect via a qualified boundary, keeping the error", async () => { + const r = await asyncErr("e").flatTapErr(() => fromSafePromise(Promise.resolve("logged"))); + expect(r.unwrapErr()).toBe("e"); + }); + + it("flatTapErr does not run the effect on Ok or Defect", async () => { + const f = vi.fn(() => Ok(1)); + expect((await asyncOk(1).flatTapErr(f)).unwrap()).toBe(1); + expect((await asyncDefect().flatTapErr(f)).isDefect()).toBe(true); + expect(f).not.toHaveBeenCalled(); + }); }); describe("AsyncResult Defect channel", () => { diff --git a/packages/core/src/constructors.spec.ts b/packages/core/src/constructors.spec.ts index 89ee897..75ff30c 100644 --- a/packages/core/src/constructors.spec.ts +++ b/packages/core/src/constructors.spec.ts @@ -1,6 +1,16 @@ 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, + isResult, + Ok, + type Result, + UnwrapError, +} from "./index.js"; const boom = new Error("boom"); const defectOf = (cause: unknown): Result => @@ -69,6 +79,27 @@ describe("standalone guards narrow and expose the relevant field", () => { }); }); +describe("isResult narrows an unknown value to a Result", () => { + it("is true for every Result variant", () => { + expect(isResult(Ok(1))).toBe(true); + expect(isResult(Err("e"))).toBe(true); + expect(isResult(defectOf(boom))).toBe(true); + }); + + it("is false for look-alikes, primitives, and unrelated objects", () => { + expect(isResult({ tag: "Ok", value: 1 })).toBe(false); // structural look-alike + expect(isResult(null)).toBe(false); + expect(isResult(undefined)).toBe(false); + expect(isResult(42)).toBe(false); + expect(isResult("Ok")).toBe(false); + expect(isResult({})).toBe(false); + }); + + it("is false for an AsyncResult (not a Result)", () => { + expect(isResult(Ok(1).toAsync())).toBe(false); + }); +}); + describe("method guards narrow (parity with the standalone guards)", () => { it("r.isOk() narrows to OkView and exposes value", () => { const r: Result = Ok(7); diff --git a/packages/core/src/core.ts b/packages/core/src/core.ts index 1446804..f1df819 100644 --- a/packages/core/src/core.ts +++ b/packages/core/src/core.ts @@ -166,6 +166,17 @@ class Res { } } + flatTapErr(this: Result, f: (error: E) => Result): Result { + if (this.tag !== "Err") return this; + try { + const r = f(this.error); + // Keep the original error on the effect's success; an Err/Defect threads through. + return r.tag === "Ok" ? this : passThrough(r); + } catch (cause) { + return defectRes(cause); + } + } + recoverDefect( this: Result, f: (cause: unknown) => Result, @@ -307,6 +318,22 @@ export function defectRes(cause: unknown): Result { }) as DefectView; } +/** + * Type guard: is `x` a {@link Result} (any of `Ok` / `Err` / `Defect`)? + * + * @remarks + * Unlike {@link isOk} / {@link isErr} / {@link isDefect}, which narrow a value + * already known to be a `Result`, this narrows from `unknown` — useful at an + * untyped boundary. It checks the value carries the `Result` prototype, so a + * look-alike plain object (`{ tag: "Ok" }`) is **not** matched. An `AsyncResult` + * is not a `Result` and returns `false`. + * + * @returns `true` when `x` is a `Result` produced by this library. + */ +export function isResult(x: unknown): x is Result { + return x instanceof Res; +} + /** * Reuse a non-matching variant (an `Err` or `Defect`) as a differently-typed * `Result`, with no runtime work. Sound because the passed-through variant @@ -517,6 +544,23 @@ export class AsyncRes implements AsyncResult { ); } + flatTapErr( + f: (error: E) => Result | AsyncResult, + ): AsyncResult { + return new AsyncRes( + this.promise.then(async (r) => { + if (r.tag !== "Err") return passThrough(r); + try { + const inner = await f(r.error); + // Keep the original error on success; an Err/Defect from `f` wins. + return inner.tag === "Ok" ? passThrough(r) : passThrough(inner); + } catch (cause) { + return defectRes(cause); + } + }), + ); + } + recoverDefect( f: (cause: unknown) => Result | AsyncResult, ): AsyncResult { diff --git a/packages/core/src/facade.spec.ts b/packages/core/src/facade.spec.ts index 8d61455..f770d93 100644 --- a/packages/core/src/facade.spec.ts +++ b/packages/core/src/facade.spec.ts @@ -1,6 +1,6 @@ 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, isResult, Ok, Result } from "./index.js"; const boom = new Error("boom"); @@ -16,6 +16,7 @@ describe("Result facade mirrors the free functions", () => { expect(Result.isOk).toBe(isOk); expect(Result.isErr).toBe(isErr); expect(Result.isDefect).toBe(isDefect); + expect(Result.isResult).toBe(isResult); const d = Result.Ok(1).map(() => { throw boom; @@ -23,6 +24,8 @@ describe("Result facade mirrors the free functions", () => { expect(Result.isOk(Result.Ok(1))).toBe(true); expect(Result.isErr(Result.Err("e"))).toBe(true); expect(Result.isDefect(d)).toBe(true); + expect(Result.isResult(Result.Ok(1))).toBe(true); + expect(Result.isResult(42)).toBe(false); }); it("exposes the same interop and aggregate entry points", () => { diff --git a/packages/core/src/facade.ts b/packages/core/src/facade.ts index 91feb73..ad7df24 100644 --- a/packages/core/src/facade.ts +++ b/packages/core/src/facade.ts @@ -5,6 +5,7 @@ // companion-object pattern. See CLAUDE.md → "Internal design". import { Err, isDefect, isErr, isOk, Ok } from "./constructors.js"; +import { isResult } from "./core.js"; import { Defect } from "./defect.js"; import { Do } from "./do.js"; import { @@ -26,7 +27,7 @@ import type { Result as ResultType } from "./types.js"; * {@link Result.fromPromise}, {@link Result.fromSafePromise}, {@link Result.all}, * {@link Result.allAsync}, {@link Result.allFromDict}, * {@link Result.allFromDictAsync}, {@link Result.isOk}, {@link Result.isErr}, - * {@link Result.isDefect}. + * {@link Result.isDefect}, {@link Result.isResult}. * * @remarks * Purely additive sugar — each member **is** the corresponding free function. @@ -56,6 +57,7 @@ export const Result = { isOk, isErr, isDefect, + isResult, } as const; // Re-alias the Result type into this module so a single `export { Result }` diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a6efbba..f5218e1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,5 +1,5 @@ export { Err, isDefect, isErr, isOk, Ok } from "./constructors.js"; -export { UnwrapError } from "./core.js"; +export { isResult, UnwrapError } from "./core.js"; export { Defect } from "./defect.js"; export { Do } from "./do.js"; export { Result } from "./facade.js"; diff --git a/packages/core/src/invariants.spec.ts b/packages/core/src/invariants.spec.ts index a58c3ea..d11b3db 100644 --- a/packages/core/src/invariants.spec.ts +++ b/packages/core/src/invariants.spec.ts @@ -26,6 +26,7 @@ describe("Invariant 1: throw inside any combinator becomes a Defect", () => { 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").flatTapErr(t).isDefect()).toBe(true); expect(defectOf(boom).recoverDefect(t).isDefect()).toBe(true); expect(defectOf(boom).tapDefect(t).isDefect()).toBe(true); }); @@ -46,6 +47,7 @@ describe("Invariant 2: a Defect flows through every method except match() and re defectOf(boom).orElse(f), defectOf(boom).recover(f), defectOf(boom).tapErr(f), + defectOf(boom).flatTapErr(f), ]; for (const r of passesThrough) expect(r.isDefect()).toBe(true); expect(f).not.toHaveBeenCalled(); diff --git a/packages/core/src/result.spec.ts b/packages/core/src/result.spec.ts index 4ac204e..87189ed 100644 --- a/packages/core/src/result.spec.ts +++ b/packages/core/src/result.spec.ts @@ -277,6 +277,45 @@ describe("Result.tapErr", () => { }); }); +describe("Result.flatTapErr", () => { + it("runs the failable effect on Err and keeps the original error on success", () => { + const seen: string[] = []; + const r = Err("e").flatTapErr((s) => { + seen.push(s); + return Ok("ignored"); + }); + expect(seen).toEqual(["e"]); + expect(r.unwrapErr()).toBe("e"); // original error preserved + }); + + it("threads the effect's Err", () => { + const r = Err("e").flatTapErr(() => Err("log_failed")); + expect(r.unwrapErr()).toBe("log_failed"); + }); + + it("propagates a Defect from the effect", () => { + const r = Err("e").flatTapErr(() => defectOf(boom)); + expect(r.isDefect()).toBe(true); + }); + + it("does not run on Ok or Defect", () => { + const f = vi.fn(() => Ok(1)); + expect(Ok(1).flatTapErr(f).unwrap()).toBe(1); + expect(defectOf(boom).flatTapErr(f).isDefect()).toBe(true); + expect(f).not.toHaveBeenCalled(); + }); + + it("converts a throw into a Defect", () => { + expect( + Err("e") + .flatTapErr(() => { + throw boom; + }) + .isDefect(), + ).toBe(true); + }); +}); + describe("Result.recoverDefect (the only door to a Defect)", () => { it("replaces a Defect with an Ok", () => { expect( diff --git a/packages/core/src/types.test-d.ts b/packages/core/src/types.test-d.ts index d88d1ab..e741884 100644 --- a/packages/core/src/types.test-d.ts +++ b/packages/core/src/types.test-d.ts @@ -23,6 +23,7 @@ import { isDefect, isErr, isOk, + isResult, matchTags, Ok, type OkOf, @@ -108,6 +109,10 @@ type _flatMapped = Expect>>; const flatTapped = r1.flatTap(() => Err<"e2">("e2")); type _flatTapped = Expect>>; +// flatTapErr KEEPS the value type and widens the error channel (error-side mirror) +const flatTapErred = r1.flatTapErr(() => Err<"e2">("e2")); +type _flatTapErred = Expect>>; + // recover empties the error channel (to `never`) const recovered = r1.recover(() => 0); type _recovered = Expect>>; @@ -178,6 +183,12 @@ if (isDefect(g)) { type _sc = Expect>; } +// isResult narrows `unknown` to a Result +declare const u: unknown; +if (isResult(u)) { + type _isResult = Expect>>; +} + // the payload is unreachable before narrowing // @ts-expect-error - `.value` only exists on the Ok variant const _noValue = g.value; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 2a32d66..b76fdeb 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -165,6 +165,23 @@ export type ResultMethods = { * @param f - the side effect (its return value is ignored). */ tapErr(f: (error: E) => void): Result; + /** + * Run a **failable** side effect on the error, keeping the original error but + * threading the effect's own error. + * + * @remarks + * The error-channel mirror of {@link ResultMethods.flatTap | flatTap}: `f` + * returns a `Result`, but its **success value is discarded** — on the effect's + * `Ok` the original `Err` flows through unchanged, while an `Err` (or `Defect`) + * from `f` short-circuits and threads its error (`Result`). Runs only + * on `Err`; `Ok` and `Defect` pass through. If `f` throws, the throw becomes a + * `Defect`. Use it for a failable effect _during_ error handling (e.g. writing + * the error to an audit log that may itself fail). + * + * @typeParam E2 - the error type the effect may introduce. + * @param f - the failable side effect; its `Ok` value is ignored. + */ + flatTapErr(f: (error: E) => Result): Result; /** * Recover from a `Defect` — the **only** combinator that can touch one. @@ -388,6 +405,15 @@ export type AsyncResult = Awaitable> & { recover(f: (error: E) => U): AsyncResult; /** Asynchronous `tapErr`. `f` is synchronous; a throw becomes a `Defect`. */ tapErr(f: (error: E) => void): AsyncResult; + /** + * Asynchronous `flatTapErr` — a failable tap on the error that keeps the + * original error. `f` may return a `Result` **or** an `AsyncResult`; its `Ok` + * value is discarded, an `Err`/`Defect` from `f` threads through, and a throw + * becomes a `Defect`. + */ + flatTapErr( + f: (error: E) => Result | AsyncResult, + ): AsyncResult; /** Asynchronous `recoverDefect`. `f` may return a `Result` or an `AsyncResult`. */ recoverDefect(