@onrails/result 0.4.0 → 0.4.2

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 (73) hide show
  1. package/DESIGN.md +1 -1
  2. package/README.md +168 -12
  3. package/RECIPES.md +11 -9
  4. package/dist/async-fns.cjs +52 -0
  5. package/dist/async-fns.cjs.map +1 -0
  6. package/dist/async-fns.d.cts +143 -0
  7. package/dist/async-fns.d.ts +143 -0
  8. package/dist/async-fns.js +42 -0
  9. package/dist/async-fns.js.map +1 -0
  10. package/dist/async-lift.d.cts +109 -0
  11. package/dist/async-lift.d.ts +109 -0
  12. package/dist/{async-Ci6WSL5-.d.ts → async.d.cts} +45 -58
  13. package/dist/{async-DcXR7k9K.d.cts → async.d.ts} +45 -58
  14. package/dist/collections.d.cts +105 -0
  15. package/dist/collections.d.ts +105 -0
  16. package/dist/compat/neverthrow.cjs +57 -45
  17. package/dist/compat/neverthrow.cjs.map +1 -1
  18. package/dist/compat/neverthrow.d.cts +21 -24
  19. package/dist/compat/neverthrow.d.ts +21 -24
  20. package/dist/compat/neverthrow.js +56 -44
  21. package/dist/compat/neverthrow.js.map +1 -1
  22. package/dist/extra.cjs +3 -1
  23. package/dist/extra.cjs.map +1 -1
  24. package/dist/extra.d.cts +11 -14
  25. package/dist/extra.d.ts +11 -14
  26. package/dist/extra.js +3 -1
  27. package/dist/extra.js.map +1 -1
  28. package/dist/fluent.cjs +15 -13
  29. package/dist/fluent.cjs.map +1 -1
  30. package/dist/fluent.d.cts +4 -7
  31. package/dist/fluent.d.ts +4 -7
  32. package/dist/fluent.js +15 -13
  33. package/dist/fluent.js.map +1 -1
  34. package/dist/index.cjs +77 -52
  35. package/dist/index.cjs.map +1 -1
  36. package/dist/index.d.cts +28 -490
  37. package/dist/index.d.ts +28 -490
  38. package/dist/index.js +77 -53
  39. package/dist/index.js.map +1 -1
  40. package/dist/internal/ctor.d.cts +11 -0
  41. package/dist/internal/ctor.d.ts +11 -0
  42. package/dist/internal/dual.d.cts +42 -0
  43. package/dist/internal/dual.d.ts +42 -0
  44. package/dist/internal/infer.d.cts +40 -0
  45. package/dist/internal/infer.d.ts +40 -0
  46. package/dist/internal/payload.d.cts +17 -0
  47. package/dist/internal/payload.d.ts +17 -0
  48. package/dist/internal/to-error.d.cts +2 -0
  49. package/dist/internal/to-error.d.ts +2 -0
  50. package/dist/pipe.cjs +8 -0
  51. package/dist/pipe.cjs.map +1 -1
  52. package/dist/pipe.d.cts +58 -11
  53. package/dist/pipe.d.ts +58 -11
  54. package/dist/pipe.js +8 -1
  55. package/dist/pipe.js.map +1 -1
  56. package/dist/railway.cjs +87 -63
  57. package/dist/railway.cjs.map +1 -1
  58. package/dist/railway.d.cts +49 -19
  59. package/dist/railway.d.ts +49 -19
  60. package/dist/railway.js +87 -63
  61. package/dist/railway.js.map +1 -1
  62. package/dist/result.d.cts +317 -0
  63. package/dist/result.d.ts +317 -0
  64. package/dist/try-gen.cjs +3 -3
  65. package/dist/try-gen.cjs.map +1 -1
  66. package/dist/try-gen.d.cts +4 -7
  67. package/dist/try-gen.d.ts +4 -7
  68. package/dist/try-gen.js +3 -3
  69. package/dist/try-gen.js.map +1 -1
  70. package/dist/{types-BQ9vv0nD.d.cts → types.d.cts} +6 -8
  71. package/dist/{types-BQ9vv0nD.d.ts → types.d.ts} +6 -8
  72. package/package.json +69 -24
  73. package/skills/railway-do-notation/SKILL.md +9 -9
package/DESIGN.md CHANGED
@@ -40,7 +40,7 @@ Two distinct lift paths:
40
40
 
41
41
  ```ts
42
42
  asyncAfter(
43
- trySync(() => Schema.parse(input), toError)(),
43
+ trySync(() => Schema.parse(input), toError),
44
44
  (value) => tryAsync(save(value)),
45
45
  );
46
46
  ```
package/README.md CHANGED
@@ -11,9 +11,13 @@ bun add @onrails/result
11
11
  ## Quick start (value-first — best inference)
12
12
 
13
13
  ```ts
14
- import { err, flatMap, match, ok, trySync } from "@onrails/result";
14
+ import { err, flatMap, fromThrowable, match, ok, trySync } from "@onrails/result";
15
15
 
16
- const parse = trySync(
16
+ // Run it now — the sync mirror of `tryAsync`.
17
+ const parsed = trySync(() => JSON.parse(raw)); // Result<unknown, Error>
18
+
19
+ // Or lift the throwing function once and reuse it.
20
+ const parse = fromThrowable(
17
21
  (raw: string) => JSON.parse(raw),
18
22
  (e) => ({ kind: "parse" as const, message: String(e) }),
19
23
  );
@@ -21,24 +25,56 @@ const parse = trySync(
21
25
  const pipeline = flatMap(parse('{"v":1}'), (data) => ok(data.v));
22
26
  ```
23
27
 
24
- Every transform is dual-form: data-first `flatMap(r, fn)` (best inference) or curried `flatMap(fn)(r)` for `pipe`/`flow`. Long chains: `fluent()` from `@onrails/result/fluent`.
25
-
26
28
  For worked examples of multi-step pipelines, parser builders, validator ladders, and parallel sub-workflows see [RECIPES.md](./RECIPES.md).
27
29
 
28
- ## When to use what
30
+ ## The default path
31
+
32
+ Two forms cover almost everything. Learn these; treat the rest of this README as reference for the cases they do not fit.
33
+
34
+ | Sync | data-first module functions — `flatMap(r, fn)`, `map`, `match` |
35
+ | ----- | -------------------------------------------------------------- |
36
+ | Async | `ResultAsync` dot-chaining — `ra.flatMap(fn).map(fn).match(…)` |
37
+
38
+ ```ts
39
+ // sync
40
+ const name = match(
41
+ flatMap(parseConfig(raw), (cfg) => (cfg.name ? ok(cfg.name) : err({ kind: "missing" }))),
42
+ (n) => n,
43
+ (e) => `<${e.kind}>`,
44
+ );
45
+
46
+ // async — `.flatMap` also accepts a plain sync `Result`, so no manual lifting
47
+ const count = await loadUser(id).flatMap(loadOrders).map((os) => os.length).unwrapOr(0);
48
+ ```
49
+
50
+ Data-first gives the best inference, and `ResultAsync.flatMap` takes either carrier, so mixed sync/async chains need no lift step.
51
+
52
+ ## Other syntaxes (opt-in)
53
+
54
+ Same operations, different shape. Reach for one only when it removes real nesting or naming pain — none of them unlock behavior the default path lacks.
29
55
 
30
56
  | Shape | Reach for |
31
57
  | ---------------------------------- | -------------------------------------------------------------------- |
32
- | One or two sync steps | `flatMapResult`, `mapResult`, `match` |
33
- | One or two async steps | `ResultAsync.flatMap`, `asyncAfter` |
34
58
  | Long sync chain, value-first | `pipe(r, map(...), flatMap(...), ...)` |
59
+ | Long async chain, value-first | `pipe(ra, RA.map(...), RA.flatMap(...))` from `@onrails/result/async` |
35
60
  | Long sync chain, dot-style preferred | `fluent(r)` from `@onrails/result/fluent` |
36
61
  | Reusable composed function | `flow(...)` from `@onrails/result/pipe` |
37
62
  | Several named sync/async steps | `Railway.*` builder from `@onrails/result/railway` |
38
63
  | Linear sync with early-return feel | `tryGen` + `$` from `@onrails/result/try-gen` |
64
+
65
+ Every transform is dual-form: data-first `flatMap(r, fn)` or curried `flatMap(fn)(r)` for `pipe` / `flow`.
66
+
67
+ ## Boundaries and collections
68
+
69
+ These are not alternative syntaxes — they are the entry points into the railway.
70
+
71
+ | Shape | Reach for |
72
+ | ---------------------------------- | -------------------------------------------------------------------- |
39
73
  | Independent validations, accumulated failures | `validateAll` / `validateTuple` from `@onrails/result` |
40
74
  | Sync → async lift, keep error type | `fromResult`, `asyncAfter` (do **not** use `fromAsync` here) |
41
75
  | `Promise<Result<…>>` boundary lift | `fromAsync` / `tryAsync` |
76
+ | Throwing sync call, run it now | `trySync(() => f())` — mirrors `tryAsync(promise)` |
77
+ | Throwing sync function, reusable | `fromThrowable(f, onThrow)` — neverthrow's `Result.fromThrowable` |
42
78
 
43
79
  Rule of thumb: pick the smallest tool that removes nesting. Reach for `Railway` only when named context replaces positional tuple plumbing.
44
80
 
@@ -60,7 +96,7 @@ Use `asyncAfter` for the common "validate synchronously, then run async IO" shap
60
96
  import { asyncAfter, tryAsync, trySync } from "@onrails/result";
61
97
 
62
98
  return asyncAfter(
63
- trySync(() => ArtifactSchema.parse(artifact), toError)(),
99
+ trySync(() => ArtifactSchema.parse(artifact), toError),
64
100
  (validated) =>
65
101
  tryAsync(
66
102
  getDb()
@@ -94,6 +130,42 @@ type BotError =
94
130
 
95
131
  Helpers: `@onrails/result/extra` — `hasKind`, `mapErrKind`, `declareErrors`, `UnionErrors`, `AccumulateErrors`.
96
132
 
133
+ ### Literals stay narrow
134
+
135
+ `ok` and `err` lock an inline literal instead of widening it, so a tagged error keeps its `kind` without `as const`:
136
+
137
+ ```ts
138
+ err({ kind: "not_found", id }); // Result<never, { readonly kind: "not_found"; readonly id: string }>
139
+ ok("ready"); // Result<"ready", never>
140
+ ```
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 at every depth (`ok([1, 2])` satisfies `Result<number[], E>`).
143
+
144
+ Generic code needs no type arguments — an unresolved `T` passes through unchanged, whatever its constraint:
145
+
146
+ ```ts
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);
150
+ ```
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
+
156
+ ### Naming the error channel
157
+
158
+ One explicit type argument on `err` names the **error**:
159
+
160
+ ```ts
161
+ type AppError = { kind: "parse" } | { kind: "io" };
162
+
163
+ err<AppError>({ kind: "io" }); // Result<never, AppError>
164
+ err<number, AppError>({ kind: "io" }); // Result<number, AppError> — neverthrow's T, E order
165
+ ```
166
+
167
+ The two-argument form keeps neverthrow's order. `errAsync` / `ResultAsync.err` behave the same way.
168
+
97
169
  ## Async interop — `fromAsync`
98
170
 
99
171
  Lift `async` handlers that return `Result` without leaking `Promise<Result<…>>`:
@@ -132,6 +204,20 @@ import { match } from "ts-pattern";
132
204
  R.match(result, onOk, onErr);
133
205
  ```
134
206
 
207
+ For an object-form, self-labelling fold, use `matchTag` from [`@onrails/pattern`](../pattern/README.md) — it dispatches on `_tag` and requires one branch per tag, so the branches read as names instead of argument positions:
208
+
209
+ ```ts
210
+ import { matchTag } from "@onrails/pattern";
211
+
212
+ const label = (r: Result<number, string>) =>
213
+ matchTag(r, {
214
+ Ok: (v) => `ok:${v.value}`, // v: Ok<number> — the member, not the payload
215
+ Err: (e) => `err:${e.error}`, // e: Err<string>
216
+ });
217
+ ```
218
+
219
+ Note the difference: `match` hands each branch the *payload*, `matchTag` hands it the narrowed *member*.
220
+
135
221
  `unwrapOk` and `unwrapErr` are test/assertion helpers. Prefer `match`, `isOk`, or `isErr` in production control flow.
136
222
 
137
223
  ```ts
@@ -163,7 +249,7 @@ import { ResultAsync } from "@onrails/result";
163
249
  const combined = ResultAsync.combineTuple([
164
250
  loadSettings(),
165
251
  loadModelCatalog(),
166
- ] as const);
252
+ ]);
167
253
 
168
254
  const dto = combined.map(([settings, catalog]) =>
169
255
  buildDto(settings, catalog),
@@ -172,6 +258,25 @@ const dto = combined.map(([settings, catalog]) =>
172
258
 
173
259
  When TS only infers the first error in a generator-style flow, use `declareErrors<E1 | E2>()` from `/extra`.
174
260
 
261
+ ## Async pipelines — `@onrails/result/async`
262
+
263
+ `ResultAsync` is a class, so dot-chaining is always available and stays the shortest form for a one-off chain. When you want the sync side's *other* two syntaxes on the async carrier — value-first `pipe`, or point-free `flow` — import the data-last twins as a namespace:
264
+
265
+ ```ts
266
+ import * as RA from "@onrails/result/async";
267
+ import { pipe } from "@onrails/result";
268
+ import { flow } from "@onrails/result/pipe";
269
+
270
+ // value-first
271
+ pipe(loadUser(id), RA.flatMap(loadOrders), RA.map((os) => os.length));
272
+ // ResultAsync<number, NotFound | DbError>
273
+
274
+ // reusable point-free pipeline — no value yet
275
+ const orderCount = flow(loadUser, RA.flatMap(loadOrders), RA.map((os) => os.length));
276
+ ```
277
+
278
+ Every `ResultAsync` method has a twin here — `map`, `mapErr`, `bimap`, `flatMap`, `recover`, `tap`, `tapErr`, `match`, `unwrapOr` — each in both data-first and curried form. The terminals (`match`, `unwrapOr`) resolve to a plain `Promise`, ending the pipeline.
279
+
175
280
  ## `Railway` — named service workflows
176
281
 
177
282
  Use `Railway` from `@onrails/result/railway` when a service workflow has several named sync/async steps and would otherwise need manual context-carrying objects:
@@ -195,7 +300,15 @@ const summary = Railway.fromSync("profileId", () => ProfileIdSchema.parse(id), t
195
300
 
196
301
  Sync-only workflows return `Result<T, E>`. The first `fromPromise`, `fromAsync`, or `parallel` step upgrades the output to `ResultAsync<T, E>`.
197
302
 
198
- Use lower-level helpers (`asyncAfter`, `fromResult`, `flatMapResult`) for one or two steps where a builder would add ceremony.
303
+ Step keys must be unique. Reusing one is a compile error naming the offending key — previously it overwrote at runtime while the type silently collapsed to `never`:
304
+
305
+ ```ts
306
+ Railway.empty()
307
+ .derive("x", () => 1)
308
+ .derive("x", () => "two"); // ✗ key "x" already exists in the workflow context
309
+ ```
310
+
311
+ Use lower-level helpers (`asyncAfter`, `fromResult`, `flatMap`) for one or two steps where a builder would add ceremony.
199
312
 
200
313
  To share steps across workflows, extract plain functions of the context and plug them in via `.fromResult` / `.fromAsync`:
201
314
 
@@ -211,6 +324,8 @@ const summary = Railway.fromSync("profileId", () => ProfileIdSchema.parse(id), t
211
324
 
212
325
  ## Pipe
213
326
 
327
+ `pipe` and `flow` both live in `@onrails/result/pipe`; `pipe` is also re-exported from the package root.
328
+
214
329
  ```ts
215
330
  import { pipe } from "@onrails/result";
216
331
  import { flow } from "@onrails/result/pipe";
@@ -233,10 +348,50 @@ const parseUserName = flow(
233
348
  parseUserName(raw);
234
349
  ```
235
350
 
351
+ Both cap at **12 steps**. That cap is deliberate: a single recursive variadic signature would remove it, but it cannot give each step a contextual type from the previous step's output, so lambda parameters degrade to `unknown` (`map((cfg) => cfg.user)` stops inferring). Longer chains nest — `pipe(pipe(v, ...), ...)` — or factor a segment into a named `flow`.
352
+
236
353
  ## ESLint
237
354
 
238
355
  `@onrails/eslint-plugin` — warns on `Promise<Result<…>>` and `_unsafeUnwrap*`.
239
356
 
357
+ ## Breaking changes
358
+
359
+ ### `ok` / `err` lock inline literals
360
+
361
+ 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:
362
+
363
+ ```ts
364
+ // a type-level assertion that expected the widened type
365
+ expectType<TypeEqual<typeof ok(1), Result<number, never>>>(true); // before
366
+ expectType<TypeEqual<typeof value, Result<1, never>>>(true); // after
367
+ ```
368
+
369
+ ### One type argument on `err` now names the error
370
+
371
+ `err<AppError>(e)` used to set the *`Ok`* parameter and leave the error at `unknown`. It now returns `Result<never, AppError>`. Call sites that meant the old reading fail to compile; use `err<AppError, unknown>(e)` to restore it, or drop the argument and let inference lock the literal. The compat shim (`@onrails/result/compat/neverthrow`) is unchanged.
372
+
373
+ ### `trySync` is now eager; the lazy form is `fromThrowable`
374
+
375
+ `trySync` used to lift a throwing *function* and return a wrapper, while
376
+ `tryAsync` took a *value* and ran immediately — same prefix, opposite contract.
377
+ The two now agree, and the lifting form takes neverthrow's own name.
378
+
379
+ ```ts
380
+ // before
381
+ const parse = trySync(JSON.parse, toErr);
382
+ parse(raw);
383
+ trySync(() => Schema.parse(input), toErr)(); // note the trailing ()
384
+
385
+ // after
386
+ const parse = fromThrowable(JSON.parse, toErr);
387
+ parse(raw);
388
+ trySync(() => Schema.parse(input), toErr); // runs now, returns Result
389
+ trySync(() => Schema.parse(input)); // Result<T, Error> — onThrow optional
390
+ ```
391
+
392
+ Mechanical migration: rename `trySync` → `fromThrowable` everywhere, then drop
393
+ the trailing `()` on any call site that immediately invoked the wrapper.
394
+
240
395
  ## Migration from neverthrow
241
396
 
242
397
  See [@onrails/codemod](../codemod/README.md) for the automated codemod, and the **Compat surface** notes below.
@@ -250,7 +405,7 @@ import { ResultAsync, Result, ok, err, okAsync, errAsync } from "@onrails/result
250
405
  - `Result` / `ResultAsync` are class-shaped (`CompatResult` / `CompatResultAsync`).
251
406
  - `await ra` resolves to a `CompatResult<T, E>` (thenable), so `.isOk()`, `.value`, `.error`, `.match()`, `.unwrapOr()` all work without an extra `.resolve()` call.
252
407
  - `andThen` / `chain` / `flatMap` / `orElse` accept any of `CompatResultAsync` / `ResultAsync` / `CompatResult` / tagged `Result` returns and union the error type.
253
- - Supported: `andThen`, `asyncAndThen`, `chain`, `flatMap`, `flatMapResult`, `andThenResult`, `map`, `mapErr`, `orElse`, `match`, `unwrapOr`, `isOk`, `isErr`, `andTee`, `orTee`, `Result.combine`, `Result.fromThrowable`, `ResultAsync.combine`, `ResultAsync.fromPromise`, `ResultAsync.fromSafePromise`, `ResultAsync.fromThrowable`, `_unsafeUnwrap` / `_unsafeUnwrapErr`.
408
+ - 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`.
254
409
  - 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`.
255
410
 
256
411
  ## Subpaths
@@ -258,9 +413,10 @@ import { ResultAsync, Result, ok, err, okAsync, errAsync } from "@onrails/result
258
413
  | Path | Contents |
259
414
  |------|----------|
260
415
  | `@onrails/result` | Core + interop exports |
416
+ | `@onrails/result/async` | Data-last twins of the `ResultAsync` methods (for `pipe` / `flow`) |
261
417
  | `@onrails/result/fluent` | `fluent()` |
262
418
  | `@onrails/result/extra` | Error-type utilities |
263
- | `@onrails/result/pipe` | `flow` (variadic point-free composition) |
419
+ | `@onrails/result/pipe` | `pipe` (value-first) and `flow` (point-free), up to 12 steps |
264
420
  | `@onrails/result/railway` | `Railway` named-context workflow builder |
265
421
  | `@onrails/result/try-gen` | `tryGen`, `yieldResult`, `$` |
266
422
  | `@onrails/result/compat/neverthrow` | Migration shim |
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
  });
@@ -81,15 +81,17 @@ Define a parser factory once, instantiate per schema.
81
81
 
82
82
  ```ts
83
83
  import { flow } from "@onrails/result/pipe";
84
- import { flatMap, trySync, type Result } from "@onrails/result";
84
+ import { flatMap, fromThrowable, type Result } from "@onrails/result";
85
85
 
86
86
  type ParseError = { kind: "parse"; message: string };
87
87
  type SchemaError = { kind: "schema"; field: string };
88
88
 
89
89
  const parseJsonWith = <T>(schema: { parse: (x: unknown) => T }) =>
90
90
  flow(
91
- trySync(JSON.parse, (e): ParseError => ({ kind: "parse", message: String(e) })),
92
- flatMap(trySync(schema.parse, (e): SchemaError => ({ kind: "schema", field: String(e) }))),
91
+ fromThrowable(JSON.parse, (e): ParseError => ({ kind: "parse", message: String(e) })),
92
+ flatMap(
93
+ fromThrowable(schema.parse, (e): SchemaError => ({ kind: "schema", field: String(e) })),
94
+ ),
93
95
  );
94
96
 
95
97
  const parseUser = parseJsonWith(UserSchema);
@@ -115,7 +117,7 @@ const saveOrder = (rawJson: string) =>
115
117
  pipe(
116
118
  rawJson,
117
119
  parseUser, // Result<User, ParseError | SchemaError>
118
- flatMap((u) => (u.active ? ok(u) : err({ kind: "inactive" as const }))),
120
+ flatMap((u) => (u.active ? ok(u) : err({ kind: "inactive" }))),
119
121
  mapErr((e) => ({ kind: "input" as const, cause: e })),
120
122
  tap((u) => log.info({ msg: "validated", userId: u.id })),
121
123
  asyncAfter((u) =>
@@ -208,7 +210,7 @@ const buildSummary = (userId: string) =>
208
210
  loadProfile(userId), // ResultAsync<Profile, ProfileError>
209
211
  loadRecentMetrics(userId), // ResultAsync<Metrics, MetricsError>
210
212
  loadFeatureFlags(userId), // ResultAsync<Flags, never>
211
- ] as const),
213
+ ]),
212
214
  (combined) =>
213
215
  combined.map(([profile, metrics, flags]) => ({
214
216
  userId,
@@ -237,10 +239,10 @@ type CharsError = { kind: "chars"; bad: string };
237
239
  type TooShortError = { kind: "too_short"; min: number };
238
240
 
239
241
  const requireMin = (min: number) => (s: string) =>
240
- s.length >= min ? ok(s) : err({ kind: "len" as const, min });
242
+ s.length >= min ? ok(s) : err({ kind: "len", min });
241
243
 
242
244
  const requireAscii = (s: string) =>
243
- /^[\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 });
244
246
 
245
247
  const validateUsername = flow(
246
248
  (raw: string) => ok(raw.trim()),
@@ -248,7 +250,7 @@ const validateUsername = flow(
248
250
  flatMap(requireAscii),
249
251
  recover(
250
252
  (e: LengthError | CharsError): Result<string, TooShortError | CharsError> =>
251
- 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),
252
254
  ),
253
255
  );
254
256
  // (raw: string) => Result<string, TooShortError | CharsError>
@@ -0,0 +1,52 @@
1
+ 'use strict';
2
+
3
+ // src/internal/dual.ts
4
+ var dual = (arity, body) => {
5
+ const run = body;
6
+ const dispatcher = (...args) => args.length >= arity ? run(...args) : (self) => run(self, ...args);
7
+ return dispatcher;
8
+ };
9
+
10
+ // src/async-fns.ts
11
+ var map = dual(2, (ra, fn) => ra.map(fn));
12
+ var mapErr = dual(
13
+ 2,
14
+ (ra, fn) => ra.mapErr(fn)
15
+ );
16
+ var bimap = dual(
17
+ 3,
18
+ (ra, onOk, onErr) => ra.bimap(onOk, onErr)
19
+ );
20
+ var flatMap = dual(
21
+ 2,
22
+ (ra, fn) => ra.flatMap(fn)
23
+ );
24
+ var recover = dual(
25
+ 2,
26
+ (ra, fn) => ra.recover(fn)
27
+ );
28
+ var tap = dual(2, (ra, fn) => ra.tap(fn));
29
+ var tapErr = dual(
30
+ 2,
31
+ (ra, fn) => ra.tapErr(fn)
32
+ );
33
+ var match = dual(
34
+ 3,
35
+ (ra, onOk, onErr) => ra.match(onOk, onErr)
36
+ );
37
+ var unwrapOr = dual(
38
+ 2,
39
+ (ra, defaultValue) => ra.unwrapOr(defaultValue)
40
+ );
41
+
42
+ exports.bimap = bimap;
43
+ exports.flatMap = flatMap;
44
+ exports.map = map;
45
+ exports.mapErr = mapErr;
46
+ exports.match = match;
47
+ exports.recover = recover;
48
+ exports.tap = tap;
49
+ exports.tapErr = tapErr;
50
+ exports.unwrapOr = unwrapOr;
51
+ //# sourceMappingURL=async-fns.cjs.map
52
+ //# sourceMappingURL=async-fns.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/internal/dual.ts","../src/async-fns.ts"],"names":[],"mappings":";;;AA0CO,IAAM,IAAA,GAAO,CAAI,KAAA,EAAe,IAAA,KAA2C;AAMhF,EAAA,MAAM,GAAA,GAAM,IAAA;AACZ,EAAA,MAAM,UAAA,GAAa,CAAA,GAAI,IAAA,KACrB,IAAA,CAAK,UAAU,KAAA,GAAQ,GAAA,CAAI,GAAG,IAAI,IAAI,CAAC,IAAA,KAAkB,GAAA,CAAI,IAAA,EAAM,GAAG,IAAI,CAAA;AAG5E,EAAA,OAAO,UAAA;AACT,CAAA;;;ACbO,IAAM,GAAA,GAGT,KAAK,CAAA,EAAG,CAAU,IAAuB,EAAA,KAA2C,EAAA,CAAG,GAAA,CAAI,EAAE,CAAC;AAW3F,IAAM,MAAA,GAGT,IAAA;AAAA,EACF,CAAA;AAAA,EACA,CAAU,EAAA,EAAuB,EAAA,KAA2C,EAAA,CAAG,OAAO,EAAE;AAC1F;AAUO,IAAM,KAAA,GAUT,IAAA;AAAA,EACF,CAAA;AAAA,EACA,CACE,EAAA,EACA,IAAA,EACA,UACsB,EAAA,CAAG,KAAA,CAAM,MAAM,KAAK;AAC9C;AAWO,IAAM,OAAA,GAGT,IAAA;AAAA,EACF,CAAA;AAAA,EACA,CAAa,EAAA,EAAuB,EAAA,KAClC,EAAA,CAAG,QAAQ,EAAE;AACjB;AAWO,IAAM,OAAA,GAGT,IAAA;AAAA,EACF,CAAA;AAAA,EACA,CAAU,EAAA,EAAuB,EAAA,KAC/B,EAAA,CAAG,QAAQ,EAAE;AACjB;AAUO,IAAM,GAAA,GAGT,KAAK,CAAA,EAAG,CAAO,IAAuB,EAAA,KAA8C,EAAA,CAAG,GAAA,CAAI,EAAE,CAAC;AAU3F,IAAM,MAAA,GAGT,IAAA;AAAA,EACF,CAAA;AAAA,EACA,CAAO,EAAA,EAAuB,EAAA,KAA8C,EAAA,CAAG,OAAO,EAAE;AAC1F;AAWO,IAAM,KAAA,GAUT,IAAA;AAAA,EACF,CAAA;AAAA,EACA,CACE,EAAA,EACA,IAAA,EACA,UACqB,EAAA,CAAG,KAAA,CAAM,MAAM,KAAK;AAC7C;AAWO,IAAM,QAAA,GAGT,IAAA;AAAA,EACF,CAAA;AAAA,EACA,CAAU,EAAA,EAAuB,YAAA,KAAoC,EAAA,CAAG,SAAS,YAAY;AAC/F","file":"async-fns.cjs","sourcesContent":["/**\n * @onrails/result internal — owning module for the **dual-form idiom**.\n *\n * Every dual-form transform in this package (`map`, `flatMap`, `match`, …)\n * accepts either shape:\n *\n * data-first: `map(result, fn)` — full arity, runs immediately\n * curried: `map(fn)(result)` — arity minus the leading `self`, returns\n * a unary function awaiting the data\n *\n * {@link dual} is the single arity dispatcher those transforms derive from;\n * hand-rolled `args.length` checks are not allowed elsewhere.\n *\n * NOTE: a twin copy of this module lives at `packages/maybe/src/internal/dual.ts`\n * — keep the two in sync when editing.\n *\n * Internal only: not listed in package.json `exports` or tsup entries.\n */\n\n/**\n * Derive a dual-form (data-first / curried) function from a single\n * data-first `body`.\n *\n * When called with `args.length >= arity` the wrapper runs `body(...args)`\n * (data-first). With fewer arguments it returns `(self) => body(self, ...args)`\n * — the curried, data-last-`self` form used with `pipe`/`flow`.\n *\n * The public type is supplied entirely by the caller: annotate the exported\n * const with the exact overload set and let `F` be inferred from it.\n *\n * @example\n * ```ts\n * export const map: {\n * <T, U, E>(result: Result<T, E>, fn: (value: T) => U): Result<U, E>;\n * <T, U>(fn: (value: T) => U): <E>(result: Result<T, E>) => Result<U, E>;\n * } = dual(\n * 2,\n * <T, U, E>(result: Result<T, E>, fn: (value: T) => U): Result<U, E> =>\n * isOk(result) ? ok(fn(result.value)) : err(result.error),\n * );\n * ```\n */\nexport const dual = <F>(arity: number, body: (...args: never[]) => unknown): F => {\n // `(...args: never[]) => unknown` is the top function type, so every typed\n // body is assignable to the parameter — but it cannot be invoked with\n // `unknown` arguments. Re-view it through the variadic shape the dispatcher\n // actually forwards; the constraint is that `body` only ever receives the\n // arguments the caller's overload annotation admitted.\n const run = body as (...args: readonly unknown[]) => unknown;\n const dispatcher = (...args: readonly unknown[]): unknown =>\n args.length >= arity ? run(...args) : (self: unknown) => run(self, ...args);\n // The caller's overload annotation on the exported const supplies the real\n // public type; internally the dispatcher stays `unknown`-typed.\n return dispatcher as F;\n};\n","/**\n * `@onrails/result/async` — data-last twins of the {@link ResultAsync} methods,\n * so an async railway composes with `pipe` / `flow` the same way a sync one does.\n *\n * `ResultAsync` is a class, so dot-chaining is always available and remains the\n * shortest form for a one-off chain. Reach for this module when you want the\n * sync side's other two syntaxes on the async carrier:\n *\n * ```ts\n * import * as RA from \"@onrails/result/async\";\n * import { pipe } from \"@onrails/result\";\n * import { flow } from \"@onrails/result/pipe\";\n *\n * // value-first pipe\n * pipe(loadUser(id), RA.flatMap(loadOrders), RA.map((os) => os.length));\n *\n * // reusable point-free pipeline — no value yet\n * const orderCount = flow(loadUser, RA.flatMap(loadOrders), RA.map((os) => os.length));\n * ```\n *\n * Every export mirrors one `ResultAsync` method exactly, in both forms:\n * data-first (`RA.map(ra, fn)`) and curried (`RA.map(fn)`). Import as a\n * namespace — the names deliberately match the sync core, and `test/parity.spec.ts`\n * enforces that the mirror stays complete.\n */\n\nimport type { ResultAsync } from \"./async.js\";\nimport { dual } from \"./internal/dual.js\";\nimport type { Result } from \"./types.js\";\n\n/** A step may hand back either carrier; the async one absorbs a sync `Result`. */\ntype Step<U, F> = ResultAsync<U, F> | Result<U, F>;\n\n/**\n * Transforms the `Ok` value, passing `Err` through unchanged.\n *\n * @example\n * ```ts\n * pipe(loadUser(id), map((u) => u.name)); // ResultAsync<string, LoadError>\n * ```\n */\nexport const map: {\n <T, U, E>(ra: ResultAsync<T, E>, fn: (value: T) => U): ResultAsync<U, E>;\n <T, U>(fn: (value: T) => U): <E>(ra: ResultAsync<T, E>) => ResultAsync<U, E>;\n} = dual(2, <T, U, E>(ra: ResultAsync<T, E>, fn: (value: T) => U): ResultAsync<U, E> => ra.map(fn));\n\n/**\n * Transforms the `Err` value, passing `Ok` through unchanged — useful for\n * unifying heterogeneous failures into one app-level union.\n *\n * @example\n * ```ts\n * pipe(loadUser(id), mapErr((e): AppError => ({ kind: \"load\", cause: e })));\n * ```\n */\nexport const mapErr: {\n <T, E, F>(ra: ResultAsync<T, E>, fn: (error: E) => F): ResultAsync<T, F>;\n <E, F>(fn: (error: E) => F): <T>(ra: ResultAsync<T, E>) => ResultAsync<T, F>;\n} = dual(\n 2,\n <T, E, F>(ra: ResultAsync<T, E>, fn: (error: E) => F): ResultAsync<T, F> => ra.mapErr(fn),\n);\n\n/**\n * Transforms both tracks in one step. Exactly one of `onOk` / `onErr` runs.\n *\n * @example\n * ```ts\n * pipe(loadUser(id), bimap(toDto, (e): AppError => ({ kind: \"load\", cause: e })));\n * ```\n */\nexport const bimap: {\n <T, U, E, F>(\n ra: ResultAsync<T, E>,\n onOk: (value: T) => U,\n onErr: (error: E) => F,\n ): ResultAsync<U, F>;\n <T, U, E, F>(\n onOk: (value: T) => U,\n onErr: (error: E) => F,\n ): (ra: ResultAsync<T, E>) => ResultAsync<U, F>;\n} = dual(\n 3,\n <T, U, E, F>(\n ra: ResultAsync<T, E>,\n onOk: (value: T) => U,\n onErr: (error: E) => F,\n ): ResultAsync<U, F> => ra.bimap(onOk, onErr),\n);\n\n/**\n * Canonical bind — chains a step returning a `ResultAsync` or a sync `Result`,\n * short-circuiting on `Err`. Error types accumulate (`E | F`).\n *\n * @example\n * ```ts\n * pipe(authenticate(req), flatMap((user) => loadProfile(user.id)));\n * ```\n */\nexport const flatMap: {\n <T, U, E, F>(ra: ResultAsync<T, E>, fn: (value: T) => Step<U, F>): ResultAsync<U, E | F>;\n <T, U, F>(fn: (value: T) => Step<U, F>): <E>(ra: ResultAsync<T, E>) => ResultAsync<U, E | F>;\n} = dual(\n 2,\n <T, U, E, F>(ra: ResultAsync<T, E>, fn: (value: T) => Step<U, F>): ResultAsync<U, E | F> =>\n ra.flatMap(fn),\n);\n\n/**\n * Error-track bind — runs `fn` only on `Err`, letting a failed workflow\n * recover to `Ok` or remap the failure.\n *\n * @example\n * ```ts\n * pipe(loadUser(id), recover((e) => (e === \"notFound\" ? okAsync(guest) : errAsync(e))));\n * ```\n */\nexport const recover: {\n <T, E, F>(ra: ResultAsync<T, E>, fn: (error: E) => Step<T, F>): ResultAsync<T, F>;\n <T, E, F>(fn: (error: E) => Step<T, F>): (ra: ResultAsync<T, E>) => ResultAsync<T, F>;\n} = dual(\n 2,\n <T, E, F>(ra: ResultAsync<T, E>, fn: (error: E) => Step<T, F>): ResultAsync<T, F> =>\n ra.recover(fn),\n);\n\n/**\n * Observes the `Ok` value for side effects without changing it.\n *\n * @example\n * ```ts\n * pipe(loadUser(id), tap((u) => log.info({ msg: \"loaded\", id: u.id })));\n * ```\n */\nexport const tap: {\n <T, E>(ra: ResultAsync<T, E>, fn: (value: T) => void): ResultAsync<T, E>;\n <T>(fn: (value: T) => void): <E>(ra: ResultAsync<T, E>) => ResultAsync<T, E>;\n} = dual(2, <T, E>(ra: ResultAsync<T, E>, fn: (value: T) => void): ResultAsync<T, E> => ra.tap(fn));\n\n/**\n * Observes the `Err` value for side effects without changing it.\n *\n * @example\n * ```ts\n * pipe(loadUser(id), tapErr((e) => metrics.inc(\"user.load.fail\", { kind: e.kind })));\n * ```\n */\nexport const tapErr: {\n <T, E>(ra: ResultAsync<T, E>, fn: (error: E) => void): ResultAsync<T, E>;\n <E>(fn: (error: E) => void): <T>(ra: ResultAsync<T, E>) => ResultAsync<T, E>;\n} = dual(\n 2,\n <T, E>(ra: ResultAsync<T, E>, fn: (error: E) => void): ResultAsync<T, E> => ra.tapErr(fn),\n);\n\n/**\n * Terminal collapse — folds both tracks into a single awaited value, settling\n * the carrier exactly once. Ends the pipeline, so it returns a plain `Promise`.\n *\n * @example\n * ```ts\n * const status = await pipe(save(row), match(() => 200, (e) => (e.kind === \"conflict\" ? 409 : 500)));\n * ```\n */\nexport const match: {\n <T, E, U1, U2 = U1>(\n ra: ResultAsync<T, E>,\n onOk: (value: T) => U1,\n onErr: (error: E) => U2,\n ): Promise<U1 | U2>;\n <T, E, U1, U2 = U1>(\n onOk: (value: T) => U1,\n onErr: (error: E) => U2,\n ): (ra: ResultAsync<T, E>) => Promise<U1 | U2>;\n} = dual(\n 3,\n <T, E, U1, U2>(\n ra: ResultAsync<T, E>,\n onOk: (value: T) => U1,\n onErr: (error: E) => U2,\n ): Promise<U1 | U2> => ra.match(onOk, onErr),\n);\n\n/**\n * Terminal — resolves to the `Ok` value, or `defaultValue` on `Err`. The\n * fallback may differ from `T`; the result widens to `T | U`.\n *\n * @example\n * ```ts\n * const profile = await pipe(loadProfile(id), unwrapOr(guestProfile));\n * ```\n */\nexport const unwrapOr: {\n <T, E, U>(ra: ResultAsync<T, E>, defaultValue: U): Promise<T | U>;\n <U>(defaultValue: U): <T, E>(ra: ResultAsync<T, E>) => Promise<T | U>;\n} = dual(\n 2,\n <T, E, U>(ra: ResultAsync<T, E>, defaultValue: U): Promise<T | U> => ra.unwrapOr(defaultValue),\n);\n"]}
@@ -0,0 +1,143 @@
1
+ /**
2
+ * `@onrails/result/async` — data-last twins of the {@link ResultAsync} methods,
3
+ * so an async railway composes with `pipe` / `flow` the same way a sync one does.
4
+ *
5
+ * `ResultAsync` is a class, so dot-chaining is always available and remains the
6
+ * shortest form for a one-off chain. Reach for this module when you want the
7
+ * sync side's other two syntaxes on the async carrier:
8
+ *
9
+ * ```ts
10
+ * import * as RA from "@onrails/result/async";
11
+ * import { pipe } from "@onrails/result";
12
+ * import { flow } from "@onrails/result/pipe";
13
+ *
14
+ * // value-first pipe
15
+ * pipe(loadUser(id), RA.flatMap(loadOrders), RA.map((os) => os.length));
16
+ *
17
+ * // reusable point-free pipeline — no value yet
18
+ * const orderCount = flow(loadUser, RA.flatMap(loadOrders), RA.map((os) => os.length));
19
+ * ```
20
+ *
21
+ * Every export mirrors one `ResultAsync` method exactly, in both forms:
22
+ * data-first (`RA.map(ra, fn)`) and curried (`RA.map(fn)`). Import as a
23
+ * namespace — the names deliberately match the sync core, and `test/parity.spec.ts`
24
+ * enforces that the mirror stays complete.
25
+ */
26
+ import type { ResultAsync } from "./async.js";
27
+ import type { Result } from "./types.js";
28
+ /** A step may hand back either carrier; the async one absorbs a sync `Result`. */
29
+ type Step<U, F> = ResultAsync<U, F> | Result<U, F>;
30
+ /**
31
+ * Transforms the `Ok` value, passing `Err` through unchanged.
32
+ *
33
+ * @example
34
+ * ```ts
35
+ * pipe(loadUser(id), map((u) => u.name)); // ResultAsync<string, LoadError>
36
+ * ```
37
+ */
38
+ export declare const map: {
39
+ <T, U, E>(ra: ResultAsync<T, E>, fn: (value: T) => U): ResultAsync<U, E>;
40
+ <T, U>(fn: (value: T) => U): <E>(ra: ResultAsync<T, E>) => ResultAsync<U, E>;
41
+ };
42
+ /**
43
+ * Transforms the `Err` value, passing `Ok` through unchanged — useful for
44
+ * unifying heterogeneous failures into one app-level union.
45
+ *
46
+ * @example
47
+ * ```ts
48
+ * pipe(loadUser(id), mapErr((e): AppError => ({ kind: "load", cause: e })));
49
+ * ```
50
+ */
51
+ export declare const mapErr: {
52
+ <T, E, F>(ra: ResultAsync<T, E>, fn: (error: E) => F): ResultAsync<T, F>;
53
+ <E, F>(fn: (error: E) => F): <T>(ra: ResultAsync<T, E>) => ResultAsync<T, F>;
54
+ };
55
+ /**
56
+ * Transforms both tracks in one step. Exactly one of `onOk` / `onErr` runs.
57
+ *
58
+ * @example
59
+ * ```ts
60
+ * pipe(loadUser(id), bimap(toDto, (e): AppError => ({ kind: "load", cause: e })));
61
+ * ```
62
+ */
63
+ export declare const bimap: {
64
+ <T, U, E, F>(ra: ResultAsync<T, E>, onOk: (value: T) => U, onErr: (error: E) => F): ResultAsync<U, F>;
65
+ <T, U, E, F>(onOk: (value: T) => U, onErr: (error: E) => F): (ra: ResultAsync<T, E>) => ResultAsync<U, F>;
66
+ };
67
+ /**
68
+ * Canonical bind — chains a step returning a `ResultAsync` or a sync `Result`,
69
+ * short-circuiting on `Err`. Error types accumulate (`E | F`).
70
+ *
71
+ * @example
72
+ * ```ts
73
+ * pipe(authenticate(req), flatMap((user) => loadProfile(user.id)));
74
+ * ```
75
+ */
76
+ export declare const flatMap: {
77
+ <T, U, E, F>(ra: ResultAsync<T, E>, fn: (value: T) => Step<U, F>): ResultAsync<U, E | F>;
78
+ <T, U, F>(fn: (value: T) => Step<U, F>): <E>(ra: ResultAsync<T, E>) => ResultAsync<U, E | F>;
79
+ };
80
+ /**
81
+ * Error-track bind — runs `fn` only on `Err`, letting a failed workflow
82
+ * recover to `Ok` or remap the failure.
83
+ *
84
+ * @example
85
+ * ```ts
86
+ * pipe(loadUser(id), recover((e) => (e === "notFound" ? okAsync(guest) : errAsync(e))));
87
+ * ```
88
+ */
89
+ export declare const recover: {
90
+ <T, E, F>(ra: ResultAsync<T, E>, fn: (error: E) => Step<T, F>): ResultAsync<T, F>;
91
+ <T, E, F>(fn: (error: E) => Step<T, F>): (ra: ResultAsync<T, E>) => ResultAsync<T, F>;
92
+ };
93
+ /**
94
+ * Observes the `Ok` value for side effects without changing it.
95
+ *
96
+ * @example
97
+ * ```ts
98
+ * pipe(loadUser(id), tap((u) => log.info({ msg: "loaded", id: u.id })));
99
+ * ```
100
+ */
101
+ export declare const tap: {
102
+ <T, E>(ra: ResultAsync<T, E>, fn: (value: T) => void): ResultAsync<T, E>;
103
+ <T>(fn: (value: T) => void): <E>(ra: ResultAsync<T, E>) => ResultAsync<T, E>;
104
+ };
105
+ /**
106
+ * Observes the `Err` value for side effects without changing it.
107
+ *
108
+ * @example
109
+ * ```ts
110
+ * pipe(loadUser(id), tapErr((e) => metrics.inc("user.load.fail", { kind: e.kind })));
111
+ * ```
112
+ */
113
+ export declare const tapErr: {
114
+ <T, E>(ra: ResultAsync<T, E>, fn: (error: E) => void): ResultAsync<T, E>;
115
+ <E>(fn: (error: E) => void): <T>(ra: ResultAsync<T, E>) => ResultAsync<T, E>;
116
+ };
117
+ /**
118
+ * Terminal collapse — folds both tracks into a single awaited value, settling
119
+ * the carrier exactly once. Ends the pipeline, so it returns a plain `Promise`.
120
+ *
121
+ * @example
122
+ * ```ts
123
+ * const status = await pipe(save(row), match(() => 200, (e) => (e.kind === "conflict" ? 409 : 500)));
124
+ * ```
125
+ */
126
+ export declare const match: {
127
+ <T, E, U1, U2 = U1>(ra: ResultAsync<T, E>, onOk: (value: T) => U1, onErr: (error: E) => U2): Promise<U1 | U2>;
128
+ <T, E, U1, U2 = U1>(onOk: (value: T) => U1, onErr: (error: E) => U2): (ra: ResultAsync<T, E>) => Promise<U1 | U2>;
129
+ };
130
+ /**
131
+ * Terminal — resolves to the `Ok` value, or `defaultValue` on `Err`. The
132
+ * fallback may differ from `T`; the result widens to `T | U`.
133
+ *
134
+ * @example
135
+ * ```ts
136
+ * const profile = await pipe(loadProfile(id), unwrapOr(guestProfile));
137
+ * ```
138
+ */
139
+ export declare const unwrapOr: {
140
+ <T, E, U>(ra: ResultAsync<T, E>, defaultValue: U): Promise<T | U>;
141
+ <U>(defaultValue: U): <T, E>(ra: ResultAsync<T, E>) => Promise<T | U>;
142
+ };
143
+ export {};