@onrails/result 0.1.4 → 0.3.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 (72) hide show
  1. package/DESIGN.md +11 -19
  2. package/README.md +14 -62
  3. package/RECIPES.md +12 -25
  4. package/dist/async-BR3AX0ck.d.cts +326 -0
  5. package/dist/async-SZ7BEl0g.d.ts +326 -0
  6. package/dist/compat/neverthrow.cjs +266 -94
  7. package/dist/compat/neverthrow.cjs.map +1 -1
  8. package/dist/compat/neverthrow.d.cts +4 -4
  9. package/dist/compat/neverthrow.d.ts +4 -4
  10. package/dist/compat/neverthrow.js +263 -91
  11. package/dist/compat/neverthrow.js.map +1 -1
  12. package/dist/extra.cjs +2 -25
  13. package/dist/extra.cjs.map +1 -1
  14. package/dist/extra.d.cts +7 -19
  15. package/dist/extra.d.ts +7 -19
  16. package/dist/extra.js +3 -25
  17. package/dist/extra.js.map +1 -1
  18. package/dist/fluent.cjs +55 -29
  19. package/dist/fluent.cjs.map +1 -1
  20. package/dist/fluent.d.cts +38 -11
  21. package/dist/fluent.d.ts +38 -11
  22. package/dist/fluent.js +55 -29
  23. package/dist/fluent.js.map +1 -1
  24. package/dist/index.cjs +290 -131
  25. package/dist/index.cjs.map +1 -1
  26. package/dist/index.d.cts +294 -69
  27. package/dist/index.d.ts +294 -69
  28. package/dist/index.js +287 -127
  29. package/dist/index.js.map +1 -1
  30. package/dist/pipe.cjs.map +1 -1
  31. package/dist/pipe.d.cts +7 -5
  32. package/dist/pipe.d.ts +7 -5
  33. package/dist/pipe.js.map +1 -1
  34. package/dist/railway.cjs +293 -188
  35. package/dist/railway.cjs.map +1 -1
  36. package/dist/railway.d.cts +26 -180
  37. package/dist/railway.d.ts +26 -180
  38. package/dist/railway.js +294 -178
  39. package/dist/railway.js.map +1 -1
  40. package/dist/try-gen.cjs +1 -1
  41. package/dist/try-gen.cjs.map +1 -1
  42. package/dist/try-gen.d.cts +29 -3
  43. package/dist/try-gen.d.ts +29 -3
  44. package/dist/try-gen.js +1 -1
  45. package/dist/try-gen.js.map +1 -1
  46. package/dist/types-BQ9vv0nD.d.cts +55 -0
  47. package/dist/types-BQ9vv0nD.d.ts +55 -0
  48. package/package.json +5 -18
  49. package/skills/railway-do-notation/SKILL.md +397 -0
  50. package/skills/result-composition/SKILL.md +323 -0
  51. package/dist/$.cjs +0 -40
  52. package/dist/$.cjs.map +0 -1
  53. package/dist/$.js +0 -36
  54. package/dist/$.js.map +0 -1
  55. package/dist/_.d.cts +0 -2
  56. package/dist/_.d.ts +0 -2
  57. package/dist/async-CH8Cs8KE.d.cts +0 -161
  58. package/dist/async-DKISqIBc.d.ts +0 -161
  59. package/dist/interop.cjs +0 -253
  60. package/dist/interop.cjs.map +0 -1
  61. package/dist/interop.d.cts +0 -35
  62. package/dist/interop.d.ts +0 -35
  63. package/dist/interop.js +0 -249
  64. package/dist/interop.js.map +0 -1
  65. package/dist/types-C2Dp1d5J.d.cts +0 -21
  66. package/dist/types-C2Dp1d5J.d.ts +0 -21
  67. package/dist/validation.cjs +0 -53
  68. package/dist/validation.cjs.map +0 -1
  69. package/dist/validation.d.cts +0 -72
  70. package/dist/validation.d.ts +0 -72
  71. package/dist/validation.js +0 -48
  72. package/dist/validation.js.map +0 -1
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The core railway type: a tagged union carrying either a success value
3
+ * (`Ok<T>`) or a failure value (`Err<E>`). It is a plain discriminated union
4
+ * on `_tag` — no classes — so it is immutable and tree-shake friendly. Narrow
5
+ * with {@link isOk} / {@link isErr} to read `.value` / `.error`.
6
+ *
7
+ * @typeParam T - the `Ok` value type
8
+ * @typeParam E - the `Err` error type
9
+ *
10
+ * @example
11
+ * ```ts
12
+ * function parse(raw: string): Result<number, "nan"> {
13
+ * const n = Number(raw);
14
+ * return Number.isNaN(n) ? err("nan") : ok(n);
15
+ * }
16
+ * ```
17
+ */
18
+ type Result<T, E> = {
19
+ readonly _tag: "Ok";
20
+ readonly value: T;
21
+ } | {
22
+ readonly _tag: "Err";
23
+ readonly error: E;
24
+ };
25
+ /**
26
+ * The success branch of a {@link Result} — `{ _tag: "Ok"; value: T }`.
27
+ * The `E` parameter keeps the type assignable to `Result<T, E>`.
28
+ */
29
+ type Ok<T, E = never> = Extract<Result<T, E>, {
30
+ _tag: "Ok";
31
+ }>;
32
+ /**
33
+ * The failure branch of a {@link Result} — `{ _tag: "Err"; error: E }`.
34
+ */
35
+ type Err<T, E> = Extract<Result<T, E>, {
36
+ _tag: "Err";
37
+ }>;
38
+ /**
39
+ * Error subclass representing an **unexpected** defect — a thrown exception or
40
+ * promise rejection that was not part of the modelled `Err` union. Used as the
41
+ * default mapping when {@link fromAsync} is called without an `onDefect`
42
+ * handler; the original cause is preserved on `.cause`.
43
+ *
44
+ * @example
45
+ * ```ts
46
+ * const defect = new UnexpectedError("Unexpected async defect", caught);
47
+ * defect.cause; // the original thrown value
48
+ * ```
49
+ */
50
+ declare class UnexpectedError extends Error {
51
+ readonly cause?: unknown | undefined;
52
+ constructor(message: string, cause?: unknown | undefined);
53
+ }
54
+
55
+ export { type Err as E, type Ok as O, type Result as R, UnexpectedError as U };
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The core railway type: a tagged union carrying either a success value
3
+ * (`Ok<T>`) or a failure value (`Err<E>`). It is a plain discriminated union
4
+ * on `_tag` — no classes — so it is immutable and tree-shake friendly. Narrow
5
+ * with {@link isOk} / {@link isErr} to read `.value` / `.error`.
6
+ *
7
+ * @typeParam T - the `Ok` value type
8
+ * @typeParam E - the `Err` error type
9
+ *
10
+ * @example
11
+ * ```ts
12
+ * function parse(raw: string): Result<number, "nan"> {
13
+ * const n = Number(raw);
14
+ * return Number.isNaN(n) ? err("nan") : ok(n);
15
+ * }
16
+ * ```
17
+ */
18
+ type Result<T, E> = {
19
+ readonly _tag: "Ok";
20
+ readonly value: T;
21
+ } | {
22
+ readonly _tag: "Err";
23
+ readonly error: E;
24
+ };
25
+ /**
26
+ * The success branch of a {@link Result} — `{ _tag: "Ok"; value: T }`.
27
+ * The `E` parameter keeps the type assignable to `Result<T, E>`.
28
+ */
29
+ type Ok<T, E = never> = Extract<Result<T, E>, {
30
+ _tag: "Ok";
31
+ }>;
32
+ /**
33
+ * The failure branch of a {@link Result} — `{ _tag: "Err"; error: E }`.
34
+ */
35
+ type Err<T, E> = Extract<Result<T, E>, {
36
+ _tag: "Err";
37
+ }>;
38
+ /**
39
+ * Error subclass representing an **unexpected** defect — a thrown exception or
40
+ * promise rejection that was not part of the modelled `Err` union. Used as the
41
+ * default mapping when {@link fromAsync} is called without an `onDefect`
42
+ * handler; the original cause is preserved on `.cause`.
43
+ *
44
+ * @example
45
+ * ```ts
46
+ * const defect = new UnexpectedError("Unexpected async defect", caught);
47
+ * defect.cause; // the original thrown value
48
+ * ```
49
+ */
50
+ declare class UnexpectedError extends Error {
51
+ readonly cause?: unknown | undefined;
52
+ constructor(message: string, cause?: unknown | undefined);
53
+ }
54
+
55
+ export { type Err as E, type Ok as O, type Result as R, UnexpectedError as U };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onrails/result",
3
- "version": "0.1.4",
3
+ "version": "0.3.0",
4
4
  "description": "Tagged Result / ResultAsync for railway-oriented TypeScript — pure tagged unions, neverthrow-shaped compat shim, FL-friendly",
5
5
  "license": "MIT",
6
6
  "author": "Alan R. Soares",
@@ -24,7 +24,8 @@
24
24
  "rop",
25
25
  "typescript",
26
26
  "bun",
27
- "functional"
27
+ "functional",
28
+ "tanstack-intent"
28
29
  ],
29
30
  "engines": {
30
31
  "node": ">=18",
@@ -44,7 +45,8 @@
44
45
  "DESIGN.md",
45
46
  "README.md",
46
47
  "RECIPES.md",
47
- "LICENSE"
48
+ "LICENSE",
49
+ "skills"
48
50
  ],
49
51
  "exports": {
50
52
  ".": {
@@ -52,11 +54,6 @@
52
54
  "import": "./dist/index.js",
53
55
  "require": "./dist/index.cjs"
54
56
  },
55
- "./$": {
56
- "types": "./dist/_.d.ts",
57
- "import": "./dist/$.js",
58
- "require": "./dist/$.cjs"
59
- },
60
57
  "./fluent": {
61
58
  "types": "./dist/fluent.d.ts",
62
59
  "import": "./dist/fluent.js",
@@ -67,11 +64,6 @@
67
64
  "import": "./dist/extra.js",
68
65
  "require": "./dist/extra.cjs"
69
66
  },
70
- "./interop": {
71
- "types": "./dist/interop.d.ts",
72
- "import": "./dist/interop.js",
73
- "require": "./dist/interop.cjs"
74
- },
75
67
  "./pipe": {
76
68
  "types": "./dist/pipe.d.ts",
77
69
  "import": "./dist/pipe.js",
@@ -87,11 +79,6 @@
87
79
  "import": "./dist/try-gen.js",
88
80
  "require": "./dist/try-gen.cjs"
89
81
  },
90
- "./validation": {
91
- "types": "./dist/validation.d.ts",
92
- "import": "./dist/validation.js",
93
- "require": "./dist/validation.cjs"
94
- },
95
82
  "./compat/neverthrow": {
96
83
  "types": "./dist/compat/neverthrow.d.ts",
97
84
  "import": "./dist/compat/neverthrow.js",
@@ -0,0 +1,397 @@
1
+ ---
2
+ name: railway-do-notation
3
+ description: "Ergonomic railway composition for `@onrails/result`: when to use low-level Result/ResultAsync helpers, fluent `Railway.*` workflows, or sync `tryGen`. Use when writing or refactoring Result-heavy TypeScript, Drizzle/Zod ETL workflows, nested `flatMapResult` chains, mixed sync/async railway code, or when a user asks about 'Railway', 'railway', 'do-notation', 'safe workflow', or 'expressive Result code'. Do NOT use workflow builders for tiny repository methods where `asyncAfter` or `.flatMap` is clearer."
4
+ ---
5
+
6
+ # Railway ergonomics in `@onrails/result`
7
+
8
+ This skill documents the intended ergonomic layers for `@onrails/result`.
9
+
10
+ The goal is expressive safe code without exposing plumbing at every composition site. Low-level `Result` and `ResultAsync` remain the foundation. Higher-level workflow syntax exists only where it makes complex service code easier to read.
11
+
12
+ > Below the workflow-builder layer — pipe/flow point-free composition, dual-form transforms, the closure ladder — see the [`result-composition`](../result-composition/SKILL.md) skill.
13
+
14
+ ## Decision Tree
15
+
16
+ Use the smallest layer that makes the code clear:
17
+
18
+ 1. **Low-level helpers** for small functions and library internals.
19
+ - Use `trySync`, `tryAsync`, `fromResult`, `asyncAfter`, `flatMapResult`, `.flatMap`, `.andThen`.
20
+ - Best for one or two steps, repository helpers, and package internals.
21
+
22
+ 2. **Fluent `Railway.*`** for service workflows.
23
+ - Use when code is ETL-shaped: validate input, query Drizzle, require nullable rows, derive values, run parallel enrichment, return DTO.
24
+ - Best when there are 4+ named steps or mixed sync/async boundaries.
25
+ - For steps shared across workflows, extract plain context functions and plug them in via `.fromResult` / `.fromAsync`.
26
+
27
+ 3. **Sync `tryGen`** for dense synchronous `Result` chains only.
28
+ - Use sparingly when no workflow builder is warranted and nested sync `flatMapResult` is hard to read.
29
+ - Never use for `ResultAsync` or anything with `await`.
30
+
31
+ ## Layer 1 — Low-Level Helpers
32
+
33
+ Use low-level helpers when the operation is small and direct.
34
+
35
+ ```ts
36
+ return asyncAfter(
37
+ // Sync validation boundary. If Zod throws, map the thrown value to Error.
38
+ trySync(() => ArtifactSchema.parse(artifact), toError)(),
39
+
40
+ // Async IO boundary. Promise rejection becomes Err(Error).
41
+ (validated) =>
42
+ tryAsync(
43
+ getDb()
44
+ .insert(artifacts)
45
+ .values(validated)
46
+ .then(() => undefined),
47
+ ),
48
+ );
49
+ ```
50
+
51
+ This is already clear. Do not wrap it in a workflow builder just to be consistent.
52
+
53
+ Use this layer for:
54
+
55
+ - repository create/update/delete methods
56
+ - simple parse-then-query functions
57
+ - library internals
58
+ - functions with one sync boundary and one async boundary
59
+
60
+ ## Layer 2 — Fluent `Railway.*`
61
+
62
+ Use fluent `Railway.*` when the code is a named workflow. It carries a growing typed context object through each step.
63
+
64
+ ### Mental Model
65
+
66
+ ```ts
67
+ Railway
68
+ .fromSync("profileId", ...)
69
+ // context: { profileId }
70
+ .fromPromise("row", ...)
71
+ // context: { profileId, row }
72
+ .require("profile", "row", ...)
73
+ // context: { profileId, row, profile }
74
+ .derive("normalized", ...)
75
+ // context: { profileId, row, profile, normalized }
76
+ .parallel({ recent: ..., metrics: ... })
77
+ // context: { profileId, row, profile, normalized, recent, metrics }
78
+ .select(...)
79
+ ```
80
+
81
+ Each step either adds a named field to context or maps the final context to a result.
82
+
83
+ ### Sync vs Async Mode
84
+
85
+ `Railway` tracks whether the workflow has crossed an async boundary:
86
+
87
+ ```ts
88
+ type RailwayMode = "sync" | "async";
89
+
90
+ type RailwayOutput<T, E, M extends RailwayMode> =
91
+ M extends "async" ? ResultAsync<T, E> : Result<T, E>;
92
+ ```
93
+
94
+ Sync-only workflows return `Result`:
95
+
96
+ ```ts
97
+ const parsed = Railway
98
+ .fromSync("id", () => IdSchema.parse(raw), toError)
99
+ .derive("slug", ({ id }) => makeSlug(id))
100
+ .select(({ id, slug }) => ({ id, slug }));
101
+
102
+ // Result<{ id: Id; slug: string }, Error>
103
+ ```
104
+
105
+ Once a workflow uses `fromPromise`, `fromAsync`, or `parallel`, it upgrades to `ResultAsync`:
106
+
107
+ ```ts
108
+ const dto = Railway
109
+ .fromSync("id", () => IdSchema.parse(raw), toError)
110
+ .fromPromise("row", ({ id }) => db.query.users.findFirst(...), toError)
111
+ .require("user", "row", ({ id }) => new Error(`User not found: ${id}`))
112
+ .select(({ user }) => toUserDto(user));
113
+
114
+ // ResultAsync<UserDto, Error>
115
+ ```
116
+
117
+ ### Example — Drizzle Query + ETL + Parallel Enrichment
118
+
119
+ ```ts
120
+ function loadProfileSummary(id: string): ResultAsync<ProfileSummary, Error> {
121
+ return Railway
122
+ // Start sync. Zod parse failures become Err(Error).
123
+ .fromSync("profileId", () => ProfileIdSchema.parse(id), toError)
124
+
125
+ // First async boundary. The workflow upgrades from Result to ResultAsync.
126
+ .fromPromise(
127
+ "row",
128
+ ({ profileId }) =>
129
+ db.query.profiles.findFirst({
130
+ where: eq(profiles.id, profileId),
131
+ with: { artifacts: true, jobs: true },
132
+ }),
133
+ toError,
134
+ )
135
+
136
+ // Convert nullable Drizzle output into a required profile.
137
+ // If row is null/undefined, short-circuit with this error.
138
+ .require("profile", "row", ({ profileId }) =>
139
+ new Error(`Profile not found: ${profileId}`),
140
+ )
141
+
142
+ // Pure sync derivation. Do not catch throws here.
143
+ // Use fromSync if the transform can throw.
144
+ .derive("normalized", ({ profile }) => normalizeProfile(profile))
145
+
146
+ // Sync Result-returning ETL step.
147
+ // Err from enrichProfileStats short-circuits the whole workflow.
148
+ .fromResult("stats", ({ normalized }) => enrichProfileStats(normalized))
149
+
150
+ // Independent async branches run from the same context.
151
+ // Their named successes merge back into context.
152
+ .parallel({
153
+ recentArtifacts: ({ normalized }) => loadRecentArtifacts(normalized.id),
154
+ jobMetrics: ({ normalized }) => loadJobMetrics(normalized.id),
155
+ })
156
+
157
+ // Final projection. Output stays ResultAsync because async appeared above.
158
+ .select(({ normalized, stats, recentArtifacts, jobMetrics }) =>
159
+ toProfileSummary({
160
+ profile: normalized,
161
+ stats,
162
+ recentArtifacts,
163
+ jobMetrics,
164
+ }),
165
+ );
166
+ }
167
+ ```
168
+
169
+ Use fluent `Railway.*` when it removes manual bridges like:
170
+
171
+ - `fromResult(...)`
172
+ - nested `flatMapResult(...)`
173
+ - positional tuple destructuring for parallel work
174
+ - manual context-carrying objects after every step
175
+
176
+ Do not use fluent `Railway.*` when `asyncAfter(...)` is already clearer.
177
+
178
+ ### Reusable Steps
179
+
180
+ When the same parse/query/require/derive steps appear in multiple workflows, or tests should exercise individual steps, extract plain functions of the accumulated context and plug them in by name:
181
+
182
+ ```ts
183
+ // Reusable steps are plain context functions returning Result / ResultAsync.
184
+ const loadProfileRow = ({ profileId }: { profileId: ProfileId }) =>
185
+ tryAsync(
186
+ db.query.profiles.findFirst({
187
+ where: eq(profiles.id, profileId),
188
+ with: { artifacts: true, jobs: true },
189
+ }),
190
+ toError,
191
+ );
192
+
193
+ const summary = Railway.fromSync("profileId", () => ProfileIdSchema.parse(id), toError)
194
+ .fromAsync("row", loadProfileRow)
195
+ .require("profile", "row", ({ profileId }) => new Error(`Profile not found: ${profileId}`))
196
+ .derive("normalized", ({ profile }) => normalizeProfile(profile))
197
+ .parallel({
198
+ recentArtifacts: ({ normalized }) => loadRecentArtifacts(normalized.id),
199
+ jobMetrics: ({ normalized }) => loadJobMetrics(normalized.id),
200
+ })
201
+ .select(({ normalized, recentArtifacts, jobMetrics }) =>
202
+ toProfileSummary({ profile: normalized, recentArtifacts, jobMetrics }),
203
+ );
204
+ ```
205
+
206
+ Each step function is independently testable — call it with a hand-built context object and assert on the returned `Result` / `ResultAsync`.
207
+
208
+ ## Layer 3 — Sync `tryGen`
209
+
210
+ `tryGen` is a synchronous do-notation escape hatch. It is not the primary service-workflow API.
211
+
212
+ Use it only when:
213
+
214
+ - every step is sync
215
+ - the function body has nested `flatMapResult` / `mapResult`
216
+ - a full `Railway.*` workflow would be overkill
217
+
218
+ ```ts
219
+ import { ok, tryGen, yieldResult as $ } from "@onrails/result";
220
+
221
+ const bundleResult = tryGen<{ artifact: Artifact; chunkData: Chunk[] }, Error>(() => {
222
+ const artifact = $(parseArtifact(raw));
223
+ const chunkTexts = $(chunker.chunk(artifact.content));
224
+ const chunkData = $(parseChunks(artifact, chunkTexts));
225
+ return ok({ artifact, chunkData });
226
+ });
227
+ ```
228
+
229
+ `$(result)` unwraps the Ok value or short-circuits the whole block with the first Err. Its real edge over a `flatMap` chain is a **guard between two unwraps** — an early `return err(...)`:
230
+
231
+ ```ts
232
+ const authorizeEdit = (req: Request, postId: string) =>
233
+ tryGen(() => {
234
+ const user = $(authenticate(req));
235
+ const post = $(fetchPost(postId));
236
+ if (post.authorId !== user.id && !user.isAdmin) {
237
+ return err({ kind: "forbidden" as const }); // early exit, no nesting
238
+ }
239
+ return ok(post);
240
+ });
241
+ ```
242
+
243
+ …and a `Result`-returning loop body that must bail on first failure:
244
+
245
+ ```ts
246
+ const applyMigrations = (db: Db, steps: readonly Migration[]) =>
247
+ tryGen(() => {
248
+ let schema = $(currentSchema(db));
249
+ for (const step of steps) {
250
+ schema = $(applyMigration(schema, step)); // first Err aborts loop + block
251
+ }
252
+ return ok(schema);
253
+ });
254
+ ```
255
+
256
+ Use `yieldResult as $` for do-notation snippets in this repo. Both names are exported from the package index and `@onrails/result/try-gen`. The alias is intentionally local to `tryGen` blocks and mirrors Rust's `?` ergonomics without changing the rest of the railway API.
257
+
258
+ Never use `tryGen` with `await` or `ResultAsync`.
259
+
260
+ ```ts
261
+ // Wrong: tryGen is sync-only.
262
+ tryGen(() => {
263
+ const id = yieldResult(parseId(raw));
264
+ const row = yieldResult(await loadRow(id)); // invalid shape
265
+ return ok(row);
266
+ });
267
+ ```
268
+
269
+ Async code stays on `ResultAsync.flatMap`, `.andThen`, `asyncAfter`, or a `Railway.*` workflow.
270
+
271
+ ## API Design Notes
272
+
273
+ ### Fluent Starters
274
+
275
+ `Railway` static methods should start workflows directly:
276
+
277
+ ```ts
278
+ Railway.empty()
279
+ Railway.context({ profileId })
280
+ Railway.fromSync("profileId", () => ProfileIdSchema.parse(id), toError)
281
+ Railway.fromResult("settings", () => loadSettings())
282
+ Railway.fromPromise("row", () => db.query.users.findFirst(...), toError)
283
+ Railway.fromAsync("artifact", () => ingestArtifact(...))
284
+ ```
285
+
286
+ Avoid requiring:
287
+
288
+ ```ts
289
+ Railway.create().fromSync(...)
290
+ ```
291
+
292
+ The static starter form is easier to scan and avoids an empty builder call.
293
+
294
+ ### Instance Steps
295
+
296
+ Expected instance methods:
297
+
298
+ ```ts
299
+ .derive(key, fn) // pure sync transform
300
+ .fromResult(key, fn) // sync Result-returning step
301
+ .fromSync(key, fn) // throwing sync boundary
302
+ .fromPromise(key, fn) // Promise boundary, upgrades to async
303
+ .fromAsync(key, fn) // ResultAsync boundary, upgrades to async
304
+ .require(key, source, onMissing)
305
+ .parallel(record)
306
+ .select(fn)
307
+ .done()
308
+ ```
309
+
310
+ Recommended semantics:
311
+
312
+ - `derive` is pure and should not catch throws.
313
+ - Use `fromSync` for throwing sync transforms.
314
+ - `require` narrows nullable values to non-null fields.
315
+ - `parallel` always upgrades to async.
316
+ - `select` hides the internal context.
317
+ - `done` returns the accumulated context.
318
+
319
+ ## When Not To Use Workflow Syntax
320
+
321
+ Do not use `Railway.*` for:
322
+
323
+ - a single parse
324
+ - a single DB call
325
+ - one validation followed by one insert where `asyncAfter` is clearer
326
+ - small library combinators
327
+ - code where positional tuple output is genuinely clearer than named context
328
+
329
+ Bad:
330
+
331
+ ```ts
332
+ return Railway
333
+ .fromSync("artifact", () => ArtifactSchema.parse(artifact), toError)
334
+ .fromPromise("inserted", ({ artifact }) =>
335
+ getDb().insert(artifacts).values(artifact).then(() => undefined),
336
+ toError,
337
+ )
338
+ .select(() => undefined);
339
+ ```
340
+
341
+ Better:
342
+
343
+ ```ts
344
+ return asyncAfter(
345
+ trySync(() => ArtifactSchema.parse(artifact), toError)(),
346
+ (validated) =>
347
+ tryAsync(
348
+ getDb()
349
+ .insert(artifacts)
350
+ .values(validated)
351
+ .then(() => undefined),
352
+ ),
353
+ );
354
+ ```
355
+
356
+ The workflow builder earns its keep when it names several domain steps and removes real nesting.
357
+
358
+ ## Refactor Heuristics
359
+
360
+ Consider fluent `Railway.*` when at least two are true:
361
+
362
+ - more than three railway steps
363
+ - both sync and async boundaries appear
364
+ - nullable DB output must become a required value
365
+ - independent async branches can run in parallel
366
+ - comments explain dataflow because the code shape does not
367
+ - prior values must be carried forward through nested closures
368
+
369
+ Consider extracting reusable step functions when:
370
+
371
+ - two or more workflows share steps
372
+ - step-level tests would be useful
373
+ - the same parse/query/require sequence repeats
374
+
375
+ Consider `tryGen` when:
376
+
377
+ - all steps are sync
378
+ - the current code is a nested `flatMapResult` tree
379
+ - a workflow builder would introduce unnecessary named context
380
+
381
+ Stay low-level when:
382
+
383
+ - the function has one or two steps
384
+ - the code is library internals
385
+ - the operation is already readable with `asyncAfter`, `.flatMap`, or `mapResult`
386
+
387
+ ## Maintainer Workflow For Case Studies
388
+
389
+ Each case study should come from a real downstream PR.
390
+
391
+ 1. Identify the pain point: nested sync chain, mixed sync/async ETL, or reusable workflow steps.
392
+ 2. Write the low-level current version and the proposed `Railway.*` version.
393
+ 3. Add line comments explaining what each new step does.
394
+ 4. Confirm the proposed style removes plumbing without hiding important failure behavior.
395
+ 5. Add the before/after pair to this skill or to package docs.
396
+
397
+ Keep examples grounded in real code. Synthetic examples are fine for reference sections, but case studies should prove the API pays for itself in production-shaped code.