@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.
- package/DESIGN.md +12 -1
- package/README.md +55 -8
- package/RECIPES.md +5 -5
- package/dist/async.d.cts +7 -4
- package/dist/async.d.ts +7 -4
- package/dist/compat/neverthrow.cjs +234 -48
- package/dist/compat/neverthrow.cjs.map +1 -1
- package/dist/compat/neverthrow.d.cts +38 -4
- package/dist/compat/neverthrow.d.ts +38 -4
- package/dist/compat/neverthrow.js +230 -49
- package/dist/compat/neverthrow.js.map +1 -1
- package/dist/extra.cjs +43 -2
- package/dist/extra.cjs.map +1 -1
- package/dist/extra.js +43 -2
- package/dist/extra.js.map +1 -1
- package/dist/fluent.cjs +88 -14
- package/dist/fluent.cjs.map +1 -1
- package/dist/fluent.js +88 -14
- package/dist/fluent.js.map +1 -1
- package/dist/index.cjs +120 -37
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +120 -38
- package/dist/index.js.map +1 -1
- package/dist/internal/ctor.d.cts +8 -9
- package/dist/internal/ctor.d.ts +8 -9
- package/dist/internal/payload.d.cts +17 -0
- package/dist/internal/payload.d.ts +17 -0
- package/dist/internal/proto.d.cts +3 -0
- package/dist/internal/proto.d.ts +3 -0
- package/dist/railway.cjs +105 -31
- package/dist/railway.cjs.map +1 -1
- package/dist/railway.js +105 -31
- package/dist/railway.js.map +1 -1
- package/dist/result.d.cts +12 -10
- package/dist/result.d.ts +12 -10
- package/dist/try-gen.cjs +43 -6
- package/dist/try-gen.cjs.map +1 -1
- package/dist/try-gen.js +43 -6
- package/dist/try-gen.js.map +1 -1
- package/dist/types.d.cts +67 -9
- package/dist/types.d.ts +67 -9
- package/package.json +2 -1
- package/dist/internal/lock.d.cts +0 -16
- 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,
|
|
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
|
-
|
|
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
|
|
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.
|
|
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:
|
|
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"
|
|
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"
|
|
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"
|
|
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"
|
|
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"
|
|
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 {
|
|
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<
|
|
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<
|
|
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,
|
|
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 {
|
|
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<
|
|
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<
|
|
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,
|
|
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
|