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
17 changes: 17 additions & 0 deletions .changeset/flat-tap-err-is-result.md
Original file line number Diff line number Diff line change
@@ -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<T, E | E2>`). 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<unknown, unknown>` (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.
10 changes: 7 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`**.
Expand Down Expand Up @@ -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<unknown, unknown>` (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
Expand Down
7 changes: 7 additions & 0 deletions docs/guide/choosing-a-combinator.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand 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`).
Expand Down
7 changes: 6 additions & 1 deletion docs/guide/core-concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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:
Expand Down
24 changes: 24 additions & 0 deletions packages/core/src/async-result.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down Expand Up @@ -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", () => {
Expand Down
33 changes: 32 additions & 1 deletion packages/core/src/constructors.spec.ts
Original file line number Diff line number Diff line change
@@ -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<number, never> =>
Expand Down Expand Up @@ -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<number, string> = Ok(7);
Expand Down
44 changes: 44 additions & 0 deletions packages/core/src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,17 @@ class Res<T, E> {
}
}

flatTapErr<E2>(this: Result<T, E>, f: (error: E) => Result<unknown, E2>): Result<T, E | E2> {
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<U, E2>(
this: Result<T, E>,
f: (cause: unknown) => Result<U, E2>,
Expand Down Expand Up @@ -307,6 +318,22 @@ export function defectRes<T, E>(cause: unknown): Result<T, E> {
}) as DefectView<T, E>;
}

/**
* 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<unknown, unknown> {
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
Expand Down Expand Up @@ -517,6 +544,23 @@ export class AsyncRes<T, E> implements AsyncResult<T, E> {
);
}

flatTapErr<E2>(
f: (error: E) => Result<unknown, E2> | AsyncResult<unknown, E2>,
): AsyncResult<T, E | E2> {
return new AsyncRes<T, E | E2>(
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<U, E2>(
f: (cause: unknown) => Result<U, E2> | AsyncResult<U, E2>,
): AsyncResult<T | U, E | E2> {
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/facade.spec.ts
Original file line number Diff line number Diff line change
@@ -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");

Expand All @@ -16,13 +16,16 @@ 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;
});
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", () => {
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/facade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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.
Expand Down Expand Up @@ -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 }`
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/invariants.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand All @@ -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();
Expand Down
39 changes: 39 additions & 0 deletions packages/core/src/result.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/types.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
isDefect,
isErr,
isOk,
isResult,
matchTags,
Ok,
type OkOf,
Expand Down Expand Up @@ -108,6 +109,10 @@ type _flatMapped = Expect<Equal<typeof flatMapped, Result<never, "e1" | "e2">>>;
const flatTapped = r1.flatTap(() => Err<"e2">("e2"));
type _flatTapped = Expect<Equal<typeof flatTapped, Result<number, "e1" | "e2">>>;

// flatTapErr KEEPS the value type and widens the error channel (error-side mirror)
const flatTapErred = r1.flatTapErr(() => Err<"e2">("e2"));
type _flatTapErred = Expect<Equal<typeof flatTapErred, Result<number, "e1" | "e2">>>;

// recover empties the error channel (to `never`)
const recovered = r1.recover(() => 0);
type _recovered = Expect<Equal<typeof recovered, Result<number, never>>>;
Expand Down Expand Up @@ -178,6 +183,12 @@ if (isDefect(g)) {
type _sc = Expect<Equal<typeof g.cause, unknown>>;
}

// isResult narrows `unknown` to a Result
declare const u: unknown;
if (isResult(u)) {
type _isResult = Expect<Equal<typeof u, Result<unknown, unknown>>>;
}

// the payload is unreachable before narrowing
// @ts-expect-error - `.value` only exists on the Ok variant
const _noValue = g.value;
Expand Down
Loading
Loading