@onrails/result 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/DESIGN.md +12 -1
  2. package/README.md +55 -8
  3. package/RECIPES.md +5 -5
  4. package/dist/async.d.cts +7 -4
  5. package/dist/async.d.ts +7 -4
  6. package/dist/compat/neverthrow.cjs +234 -48
  7. package/dist/compat/neverthrow.cjs.map +1 -1
  8. package/dist/compat/neverthrow.d.cts +38 -4
  9. package/dist/compat/neverthrow.d.ts +38 -4
  10. package/dist/compat/neverthrow.js +230 -49
  11. package/dist/compat/neverthrow.js.map +1 -1
  12. package/dist/extra.cjs +43 -2
  13. package/dist/extra.cjs.map +1 -1
  14. package/dist/extra.js +43 -2
  15. package/dist/extra.js.map +1 -1
  16. package/dist/fluent.cjs +88 -14
  17. package/dist/fluent.cjs.map +1 -1
  18. package/dist/fluent.js +88 -14
  19. package/dist/fluent.js.map +1 -1
  20. package/dist/index.cjs +120 -37
  21. package/dist/index.cjs.map +1 -1
  22. package/dist/index.d.cts +2 -2
  23. package/dist/index.d.ts +2 -2
  24. package/dist/index.js +120 -38
  25. package/dist/index.js.map +1 -1
  26. package/dist/internal/ctor.d.cts +8 -9
  27. package/dist/internal/ctor.d.ts +8 -9
  28. package/dist/internal/payload.d.cts +17 -0
  29. package/dist/internal/payload.d.ts +17 -0
  30. package/dist/internal/proto.d.cts +3 -0
  31. package/dist/internal/proto.d.ts +3 -0
  32. package/dist/railway.cjs +105 -31
  33. package/dist/railway.cjs.map +1 -1
  34. package/dist/railway.js +105 -31
  35. package/dist/railway.js.map +1 -1
  36. package/dist/result.d.cts +12 -10
  37. package/dist/result.d.ts +12 -10
  38. package/dist/try-gen.cjs +43 -6
  39. package/dist/try-gen.cjs.map +1 -1
  40. package/dist/try-gen.js +43 -6
  41. package/dist/try-gen.js.map +1 -1
  42. package/dist/types.d.cts +67 -9
  43. package/dist/types.d.ts +67 -9
  44. package/package.json +2 -1
  45. package/dist/internal/lock.d.cts +0 -16
  46. package/dist/internal/lock.d.ts +0 -16
package/DESIGN.md CHANGED
@@ -29,6 +29,17 @@ type Result<T, E> =
29
29
  - Optional dot-chaining via `fluent(r)` in `@onrails/result/fluent`. Mirrors every instance-appropriate core transform; `test/parity.spec.ts` enforces the mirror (both directions) across core, fluent, and the neverthrow compat shim. `ResultAsync` is already fluent (its methods return `ResultAsync`) — no `fluentAsync` wrapper; wrapping it would just be a one-line delegate to itself.
30
30
  - The fluent wrapper stays local: `fluent(r)` opens and a terminal (`toResult`/`toString`/`match`/`unwrapOr`) closes within one expression — it's a closure over data, not data, so it must never be a return type, an exported binding, a stored field, or an argument to a serialize/`postMessage`/cache call. `@onrails/eslint-plugin` and `@onrails/biome-plugin` enforce this via `fluent-stays-local` (RFC 0002 §9).
31
31
 
32
+ ## Prototype carriers (breaking)
33
+
34
+ `ok` / `err` return live carriers: `_tag` plus `value` / `error` as own enumerable properties, with `map`, `mapErr`, `flatMap`/`andThen`, `recover`, `tap`, `tapErr`, `match` and `unwrapOr` on a shared non-enumerable prototype (`src/internal/proto.ts`). `r.map(f).flatMap(g)` works on any core `Result`, with core semantics (`tap` propagates throws, `recover` keeps `T`). The neverthrow-semantics names (`andTee`, `orElse`, ...) stay in compat.
35
+
36
+ - **Serialization:** `JSON.stringify` output is unchanged (same keys, same order). `JSON.parse`, `structuredClone`, spread and `postMessage` return plain data without the methods, while the type still claims them. Lift at every such sink with `Result.from`. `ResultData<T, E>` is the plain shape `from` accepts. Suite `test/prototype-core.spec.ts` covers all four routes.
37
+ - **Literals:** a hand-written `{ _tag: "Ok", value }` is no longer a `Result` (it lacks the methods). `onrails-codemod --literals` rewrites them to `ok` / `err`.
38
+ - **Narrowed branches:** `Err<T, E>` now carries `T` through its methods, so returning a narrowed `Err` as a `Result` with a different `T` needs `err(r.error)` (the same as neverthrow).
39
+ - **Redux, immer:** carriers are not plain objects (prototype is not `Object.prototype`). Redux Toolkit's serializability check flags them, and immer will not draft them. Store `ResultData` (or call `Result.from` on read) and convert at the edge.
40
+ - **Compat shim:** `CompatResult` still wraps a core carrier (`inner`) and now delegates `map`, `mapErr`, `andThen` and `unwrapOr` to the prototype methods. It cannot collapse into the core carrier: its semantics differ by design (`orElse` widens, `andTee` swallows throws, `value` / `error` getters throw), and the differential suite (`neverthrow-diff.spec.ts`) pins exactly that.
41
+ - **Packages:** `result` does not know `maybe`. `Maybe` has the mirrored prototype and `Maybe.from`; `toResult` / `fromResult` stay in `@onrails/maybe/interop`.
42
+
32
43
  ## Sync / async interop
33
44
 
34
45
  Two distinct lift paths:
@@ -102,7 +113,7 @@ Use `flatMap` for dependent checks where later checks need earlier successful va
102
113
 
103
114
  ## v1.0 gate
104
115
 
105
- - bun tests: core ops, FL functor/monad laws (sync), neverthrow-compat fixtures.
116
+ - bun tests: core ops, functor/monad/error-functor laws on `Result` and `ResultAsync` (`test/result.laws.spec.ts`), JSON and `structuredClone` round-trips, and a neverthrow differential (`test/neverthrow-diff.spec.ts`): a surface diff against the installed package plus randomised op chains run through both libraries.
106
117
  - README with tagged-error guidance.
107
118
  - **Not** in v1.0: npm publish.
108
119
 
package/README.md CHANGED
@@ -139,14 +139,20 @@ err({ kind: "not_found", id }); // Result<never, { readonly kind: "not_found";
139
139
  ok("ready"); // Result<"ready", never>
140
140
  ```
141
141
 
142
- Locking is inference-only — a value that already has a type is left alone (`ok(name)` where `name: string` is still `Result<string, never>`), and an array literal payload stays assignable to a mutable array (`ok([1, 2])` satisfies `Result<number[], E>`).
142
+ Locking is inference-only — a value that already has a type is left alone (`ok(name)` where `name: string` is still `Result<string, never>`), and an array literal payload stays assignable to a mutable array at every depth (`ok([1, 2])` satisfies `Result<number[], E>`).
143
143
 
144
- Pass **both** type arguments to opt out. Generic code that has to produce `Result<T, E>` for an unresolved `T` needs that form:
144
+ Generic code needs no type arguments — an unresolved `T` passes through unchanged, whatever its constraint:
145
145
 
146
146
  ```ts
147
- const lift = <T, E>(value: T): Result<T, E> => ok<T, E>(value);
147
+ const lift = <T, E>(value: T): Result<T, E> => ok(value);
148
+ const toAsync = <T, E>(r: Result<T, E>): ResultAsync<T, E> =>
149
+ isErr(r) ? errAsync(r.error) : okAsync(r.value);
148
150
  ```
149
151
 
152
+ Passing **both** type arguments (`ok<T, E>(value)`) opts out of locking.
153
+
154
+ Array-literal locking needs TypeScript ≥ 5.3. Older compilers widen array literals (`ok([1, 2])` is `Result<number[], never>`) and lock every other literal as usual.
155
+
150
156
  ### Naming the error channel
151
157
 
152
158
  One explicit type argument on `err` names the **error**:
@@ -350,14 +356,30 @@ Both cap at **12 steps**. That cap is deliberate: a single recursive variadic si
350
356
 
351
357
  ## Breaking changes
352
358
 
359
+ ### Carriers have methods; plain data needs `Result.from`
360
+
361
+ `ok` / `err` (and `some` / `none` in `@onrails/maybe`) now return carriers with `map`, `mapErr`, `flatMap` / `andThen`, `recover`, `tap`, `tapErr`, `match` and `unwrapOr` on a non-enumerable prototype, so `ok(2).map(f).flatMap(g)` works with no wrapper. `_tag` and `value` / `error` are still the only own keys, and `JSON.stringify` output is byte-identical. The module functions (`map(r, f)`, `map(f)(r)`) are unchanged.
362
+
363
+ | Before | After |
364
+ |---|---|
365
+ | `{ _tag: "Ok", value: x }` | `ok(x)` — run `onrails-codemod <dir> --literals` |
366
+ | `{ _tag: "Err", error: e }` | `err(e)` |
367
+ | `{ _tag: "Some", value: x }` / `{ _tag: "None" }` | `some(x)` / `none()` |
368
+ | `JSON.parse(raw) as Result<T, E>` | `Result.from<T, E>(JSON.parse(raw))` |
369
+ | `structuredClone(r)` used as a `Result` | `Result.from(structuredClone(r))` |
370
+ | `if (isErr(r)) return r;` returning a different `T` | `if (isErr(r)) return err(r.error);` |
371
+
372
+ `JSON.parse`, `structuredClone`, object spread, `postMessage` and IndexedDB hand back plain data without the methods, while the type still claims them. `Result.from` (and `Maybe.from`) lift it back; `ResultData<T, E>` / `MaybeData<T>` name the plain shape. `@onrails/eslint-plugin` and `@onrails/biome-plugin` ship `carrier-sinks-need-from` to flag the cast forms.
373
+
374
+ **Redux Toolkit.** Carriers are not plain objects (their prototype is not `Object.prototype`), so the serializability middleware warns on them in actions and state. Keep `ResultData` / `MaybeData` in the store and call `Result.from` / `Maybe.from` in selectors,. Both are cheap: `from` is one allocation.
375
+
376
+ **immer.** `produce` only drafts plain objects and arrays, so a carrier inside state is treated as an opaque value: replace it wholesale (`draft.user = ok(next)`), do not mutate through it. Carriers are never mutated by the library, so sharing them across drafts is safe.
377
+
353
378
  ### `ok` / `err` lock inline literals
354
379
 
355
- Inline literals no longer widen: `ok("a")` is `Result<"a", never>` and `err({ kind: "io" })` carries `{ readonly kind: "io" }`. Annotated values are unaffected. Two call shapes need a change:
380
+ Inline literals no longer widen: `ok("a")` is `Result<"a", never>` and `err({ kind: "io" })` carries `{ readonly kind: "io" }`. Annotated values and generic type parameters are unaffected. One call shape needs a change:
356
381
 
357
382
  ```ts
358
- // generic code — pass both type arguments to opt out of locking
359
- const lift = <T, E>(value: T): Result<T, E> => ok<T, E>(value);
360
-
361
383
  // a type-level assertion that expected the widened type
362
384
  expectType<TypeEqual<typeof ok(1), Result<number, never>>>(true); // before
363
385
  expectType<TypeEqual<typeof value, Result<1, never>>>(true); // after
@@ -402,7 +424,28 @@ import { ResultAsync, Result, ok, err, okAsync, errAsync } from "@onrails/result
402
424
  - `Result` / `ResultAsync` are class-shaped (`CompatResult` / `CompatResultAsync`).
403
425
  - `await ra` resolves to a `CompatResult<T, E>` (thenable), so `.isOk()`, `.value`, `.error`, `.match()`, `.unwrapOr()` all work without an extra `.resolve()` call.
404
426
  - `andThen` / `chain` / `flatMap` / `orElse` accept any of `CompatResultAsync` / `ResultAsync` / `CompatResult` / tagged `Result` returns and union the error type.
405
- - Supported: `andThen`, `asyncAndThen`, `chain`, `flatMap`, `map`, `mapErr`, `orElse`, `match`, `unwrapOr`, `isOk`, `isErr`, `andTee`, `orTee`, `Result.combine`, `Result.fromThrowable`, `ResultAsync.combine`, `ResultAsync.fromPromise`, `ResultAsync.fromSafePromise`, `ResultAsync.fromThrowable`, `_unsafeUnwrap` / `_unsafeUnwrapErr`.
427
+ - Supported: every public member of neverthrow 8.x. `bun test` diffs the shim's surface against the real package (`test/neverthrow-diff.spec.ts`), so a new neverthrow member fails CI until it is covered.
428
+ - Guards: `isOk()` / `isErr()` are type guards; `.value` / `.error` are visible only after narrowing, as in neverthrow. The `else` branch of an `isOk()` check is not narrowed to the error side (the shim is one class, not an `Ok | Err` union) — use `match` or `isErr()`.
429
+ - `Ok` / `Err` support `new` and `instanceof`; `safeTry` accepts sync and async generators using `.safeUnwrap()`.
430
+
431
+ #### Compat name to core name
432
+
433
+ | neverthrow / compat | core (`@onrails/result`) | Notes |
434
+ |---|---|---|
435
+ | `r.map(f)` / `r.mapErr(f)` | `map(r, f)` / `mapErr(r, f)` | same |
436
+ | `r.andThen(f)` / `chain` / `flatMap` | `flatMap(r, f)` | `flatMap` is canonical |
437
+ | `r.orElse(f)` | `recover(r, f)` | |
438
+ | `r.andTee(f)` / `r.orTee(f)` | `tap(r, f)` / `tapErr(r, f)` | `tap` takes a pure side effect; compat also swallows a throwing callback, core does not |
439
+ | `r.andThrough(f)` | `flatMap(r, (v) => map(f(v), () => v))` | keeps the original `Ok` value, surfaces the callback's `Err` |
440
+ | `r.asyncMap(f)` | `ResultAsync.fromResult(r).map(f)` | |
441
+ | `r.match(ok, err)` | `match(r, ok, err)` | |
442
+ | `r.unwrapOr(d)` | `unwrapOr(r, d)` | |
443
+ | `r._unsafeUnwrap()` | `unwrapOk(r)` | assertion tier |
444
+ | `Result.fromThrowable(f, h)` | `fromThrowable(f, h)` | |
445
+ | `Result.combine` / `combineWithAllErrors` | `combineTuple` / `validateTuple` | |
446
+ | `safeTry(function* () { ... })` | `tryGen(() => { ... })` | `yield* r.safeUnwrap()` becomes `$(r)`; `tryGen` is sync, use `ResultAsync` chains for async bodies |
447
+ | `ok(1) instanceof Ok` | `isOk(r)` | |
448
+
406
449
  - Treat the compat surface as a migration step, not the destination — once a package migrates, switch its imports to `@onrails/result` and `@onrails/result/fluent`.
407
450
 
408
451
  ## Subpaths
@@ -418,4 +461,8 @@ import { ResultAsync, Result, ok, err, okAsync, errAsync } from "@onrails/result
418
461
  | `@onrails/result/try-gen` | `tryGen`, `yieldResult`, `$` |
419
462
  | `@onrails/result/compat/neverthrow` | Migration shim |
420
463
 
464
+ ## Benchmarks
465
+
466
+ Runtime + fair import-graph size vs `neverthrow`: see [`../bench/RESULTS.md`](../bench/RESULTS.md). Regenerated with `bun run bench:md` from the repo root.
467
+
421
468
  See [DESIGN.md](./DESIGN.md).
package/RECIPES.md CHANGED
@@ -54,7 +54,7 @@ const stepResult = tryGen(() => {
54
54
  const user = $(authenticate(req));
55
55
  const post = $(fetchPost(postId));
56
56
  if (post.authorId !== user.id && !user.isAdmin) {
57
- return err({ kind: "unauthorized" as const });
57
+ return err({ kind: "unauthorized" });
58
58
  }
59
59
  return ok(post);
60
60
  });
@@ -117,7 +117,7 @@ const saveOrder = (rawJson: string) =>
117
117
  pipe(
118
118
  rawJson,
119
119
  parseUser, // Result<User, ParseError | SchemaError>
120
- flatMap((u) => (u.active ? ok(u) : err({ kind: "inactive" as const }))),
120
+ flatMap((u) => (u.active ? ok(u) : err({ kind: "inactive" }))),
121
121
  mapErr((e) => ({ kind: "input" as const, cause: e })),
122
122
  tap((u) => log.info({ msg: "validated", userId: u.id })),
123
123
  asyncAfter((u) =>
@@ -239,10 +239,10 @@ type CharsError = { kind: "chars"; bad: string };
239
239
  type TooShortError = { kind: "too_short"; min: number };
240
240
 
241
241
  const requireMin = (min: number) => (s: string) =>
242
- s.length >= min ? ok(s) : err({ kind: "len" as const, min });
242
+ s.length >= min ? ok(s) : err({ kind: "len", min });
243
243
 
244
244
  const requireAscii = (s: string) =>
245
- /^[\x20-\x7e]*$/.test(s) ? ok(s) : err({ kind: "chars" as const, bad: s });
245
+ /^[\x20-\x7e]*$/.test(s) ? ok(s) : err({ kind: "chars", bad: s });
246
246
 
247
247
  const validateUsername = flow(
248
248
  (raw: string) => ok(raw.trim()),
@@ -250,7 +250,7 @@ const validateUsername = flow(
250
250
  flatMap(requireAscii),
251
251
  recover(
252
252
  (e: LengthError | CharsError): Result<string, TooShortError | CharsError> =>
253
- e.kind === "len" ? err({ kind: "too_short" as const, min: e.min }) : err(e),
253
+ e.kind === "len" ? err({ kind: "too_short", min: e.min }) : err(e),
254
254
  ),
255
255
  );
256
256
  // (raw: string) => Result<string, TooShortError | CharsError>
package/dist/async.d.cts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { InferErr, InferOk } from "./internal/infer.js";
2
- import type { Locked } from "./internal/lock.js";
2
+ import type { Payload } from "./internal/payload.js";
3
3
  import type { Result } from "./types.js";
4
4
  import { UnexpectedError } from "./types.js";
5
5
  type PromiseFactory<T, E> = () => Promise<Result<T, E>>;
@@ -98,13 +98,15 @@ export declare class ResultAsync<T, E> {
98
98
  * const r = ResultAsync.ok(42); // ResultAsync<number, never>
99
99
  * ```
100
100
  */
101
- static ok<const T>(value: T): ResultAsync<Locked<T>, never>;
101
+ static ok<const T extends Payload[]>(value: T): ResultAsync<T, never>;
102
+ static ok<const T>(value: T): ResultAsync<T, never>;
102
103
  static ok<T, E>(value: T): ResultAsync<T, E>;
103
104
  /**
104
105
  * Fantasy Land `pure` — alias of {@link ResultAsync.ok}. One lift name
105
106
  * shared across the trio (`of` / `Maybe.of` / `ResultAsync.of`).
106
107
  */
107
- static of<const T>(value: T): ResultAsync<Locked<T>, never>;
108
+ static of<const T extends Payload[]>(value: T): ResultAsync<T, never>;
109
+ static of<const T>(value: T): ResultAsync<T, never>;
108
110
  static of<T, E>(value: T): ResultAsync<T, E>;
109
111
  /**
110
112
  * Lifts an error into an `Err` async result.
@@ -114,7 +116,8 @@ export declare class ResultAsync<T, E> {
114
116
  * const r = ResultAsync.err({ kind: "not_found" as const });
115
117
  * ```
116
118
  */
117
- static err<const E>(error: E): ResultAsync<never, Locked<E>>;
119
+ static err<const E extends Payload[]>(error: E): ResultAsync<never, E>;
120
+ static err<const E>(error: E): ResultAsync<never, E>;
118
121
  static err<T, E>(error: E): ResultAsync<T, E>;
119
122
  /**
120
123
  * Lifts an existing `Promise<Result<T, E>>` (e.g. from interop code) into a
package/dist/async.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { InferErr, InferOk } from "./internal/infer.js";
2
- import type { Locked } from "./internal/lock.js";
2
+ import type { Payload } from "./internal/payload.js";
3
3
  import type { Result } from "./types.js";
4
4
  import { UnexpectedError } from "./types.js";
5
5
  type PromiseFactory<T, E> = () => Promise<Result<T, E>>;
@@ -98,13 +98,15 @@ export declare class ResultAsync<T, E> {
98
98
  * const r = ResultAsync.ok(42); // ResultAsync<number, never>
99
99
  * ```
100
100
  */
101
- static ok<const T>(value: T): ResultAsync<Locked<T>, never>;
101
+ static ok<const T extends Payload[]>(value: T): ResultAsync<T, never>;
102
+ static ok<const T>(value: T): ResultAsync<T, never>;
102
103
  static ok<T, E>(value: T): ResultAsync<T, E>;
103
104
  /**
104
105
  * Fantasy Land `pure` — alias of {@link ResultAsync.ok}. One lift name
105
106
  * shared across the trio (`of` / `Maybe.of` / `ResultAsync.of`).
106
107
  */
107
- static of<const T>(value: T): ResultAsync<Locked<T>, never>;
108
+ static of<const T extends Payload[]>(value: T): ResultAsync<T, never>;
109
+ static of<const T>(value: T): ResultAsync<T, never>;
108
110
  static of<T, E>(value: T): ResultAsync<T, E>;
109
111
  /**
110
112
  * Lifts an error into an `Err` async result.
@@ -114,7 +116,8 @@ export declare class ResultAsync<T, E> {
114
116
  * const r = ResultAsync.err({ kind: "not_found" as const });
115
117
  * ```
116
118
  */
117
- static err<const E>(error: E): ResultAsync<never, Locked<E>>;
119
+ static err<const E extends Payload[]>(error: E): ResultAsync<never, E>;
120
+ static err<const E>(error: E): ResultAsync<never, E>;
118
121
  static err<T, E>(error: E): ResultAsync<T, E>;
119
122
  /**
120
123
  * Lifts an existing `Promise<Result<T, E>>` (e.g. from interop code) into a