| name | railway-do-notation |
| 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. |
Railway ergonomics in @onrails/result
This skill documents the intended ergonomic layers for @onrails/result.
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.
Below the workflow-builder layer — pipe/flow point-free composition, dual-form transforms, the closure ladder — see the result-composition skill.
Decision Tree
Use the smallest layer that makes the code clear:
-
Low-level helpers for small functions and library internals.
- Use
trySync, tryAsync, fromResult, asyncAfter, flatMapResult, .flatMap, .andThen.
- Best for one or two steps, repository helpers, and package internals.
-
Fluent Railway.* for service workflows.
- Use when code is ETL-shaped: validate input, query Drizzle, require nullable rows, derive values, run parallel enrichment, return DTO.
- Best when there are 4+ named steps or mixed sync/async boundaries.
- For steps shared across workflows, extract plain context functions and plug them in via
.fromResult / .fromAsync.
-
Sync tryGen for dense synchronous Result chains only.
- Use sparingly when no workflow builder is warranted and nested sync
flatMapResult is hard to read.
- Never use for
ResultAsync or anything with await.
Layer 1 — Low-Level Helpers
Use low-level helpers when the operation is small and direct.
return asyncAfter(
trySync(() => ArtifactSchema.parse(artifact), toError)(),
(validated) =>
tryAsync(
getDb()
.insert(artifacts)
.values(validated)
.then(() => undefined),
),
);
This is already clear. Do not wrap it in a workflow builder just to be consistent.
Use this layer for:
- repository create/update/delete methods
- simple parse-then-query functions
- library internals
- functions with one sync boundary and one async boundary
Layer 2 — Fluent Railway.*
Use fluent Railway.* when the code is a named workflow. It carries a growing typed context object through each step.
Mental Model
Railway
.fromSync("profileId", ...)
.fromPromise("row", ...)
.require("profile", "row", ...)
.derive("normalized", ...)
.parallel({ recent: ..., metrics: ... })
.select(...)
Each step either adds a named field to context or maps the final context to a result.
Sync vs Async Mode
Railway tracks whether the workflow has crossed an async boundary:
type RailwayMode = "sync" | "async";
type RailwayOutput<T, E, M extends RailwayMode> =
M extends "async" ? ResultAsync<T, E> : Result<T, E>;
Sync-only workflows return Result:
const parsed = Railway
.fromSync("id", () => IdSchema.parse(raw), toError)
.derive("slug", ({ id }) => makeSlug(id))
.select(({ id, slug }) => ({ id, slug }));
Once a workflow uses fromPromise, fromAsync, or parallel, it upgrades to ResultAsync:
const dto = Railway
.fromSync("id", () => IdSchema.parse(raw), toError)
.fromPromise("row", ({ id }) => db.query.users.findFirst(...), toError)
.require("user", "row", ({ id }) => new Error(`User not found: ${id}`))
.select(({ user }) => toUserDto(user));
Example — Drizzle Query + ETL + Parallel Enrichment
function loadProfileSummary(id: string): ResultAsync<ProfileSummary, Error> {
return Railway
.fromSync("profileId", () => ProfileIdSchema.parse(id), toError)
.fromPromise(
"row",
({ profileId }) =>
db.query.profiles.findFirst({
where: eq(profiles.id, profileId),
with: { artifacts: true, jobs: true },
}),
toError,
)
.require("profile", "row", ({ profileId }) =>
new Error(`Profile not found: ${profileId}`),
)
.derive("normalized", ({ profile }) => normalizeProfile(profile))
.fromResult("stats", ({ normalized }) => enrichProfileStats(normalized))
.parallel({
recentArtifacts: ({ normalized }) => loadRecentArtifacts(normalized.id),
jobMetrics: ({ normalized }) => loadJobMetrics(normalized.id),
})
.select(({ normalized, stats, recentArtifacts, jobMetrics }) =>
toProfileSummary({
profile: normalized,
stats,
recentArtifacts,
jobMetrics,
}),
);
}
Use fluent Railway.* when it removes manual bridges like:
fromResult(...)
- nested
flatMapResult(...)
- positional tuple destructuring for parallel work
- manual context-carrying objects after every step
Do not use fluent Railway.* when asyncAfter(...) is already clearer.
Reusable Steps
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:
const loadProfileRow = ({ profileId }: { profileId: ProfileId }) =>
tryAsync(
db.query.profiles.findFirst({
where: eq(profiles.id, profileId),
with: { artifacts: true, jobs: true },
}),
toError,
);
const summary = Railway.fromSync("profileId", () => ProfileIdSchema.parse(id), toError)
.fromAsync("row", loadProfileRow)
.require("profile", "row", ({ profileId }) => new Error(`Profile not found: ${profileId}`))
.derive("normalized", ({ profile }) => normalizeProfile(profile))
.parallel({
recentArtifacts: ({ normalized }) => loadRecentArtifacts(normalized.id),
jobMetrics: ({ normalized }) => loadJobMetrics(normalized.id),
})
.select(({ normalized, recentArtifacts, jobMetrics }) =>
toProfileSummary({ profile: normalized, recentArtifacts, jobMetrics }),
);
Each step function is independently testable — call it with a hand-built context object and assert on the returned Result / ResultAsync.
Layer 3 — Sync tryGen
tryGen is a synchronous do-notation escape hatch. It is not the primary service-workflow API.
Use it only when:
- every step is sync
- the function body has nested
flatMapResult / mapResult
- a full
Railway.* workflow would be overkill
import { ok, tryGen, yieldResult as $ } from "@onrails/result";
const bundleResult = tryGen<{ artifact: Artifact; chunkData: Chunk[] }, Error>(() => {
const artifact = $(parseArtifact(raw));
const chunkTexts = $(chunker.chunk(artifact.content));
const chunkData = $(parseChunks(artifact, chunkTexts));
return ok({ artifact, chunkData });
});
$(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(...):
const authorizeEdit = (req: Request, postId: string) =>
tryGen(() => {
const user = $(authenticate(req));
const post = $(fetchPost(postId));
if (post.authorId !== user.id && !user.isAdmin) {
return err({ kind: "forbidden" as const });
}
return ok(post);
});
…and a Result-returning loop body that must bail on first failure:
const applyMigrations = (db: Db, steps: readonly Migration[]) =>
tryGen(() => {
let schema = $(currentSchema(db));
for (const step of steps) {
schema = $(applyMigration(schema, step));
}
return ok(schema);
});
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.
Never use tryGen with await or ResultAsync.
tryGen(() => {
const id = yieldResult(parseId(raw));
const row = yieldResult(await loadRow(id));
return ok(row);
});
Async code stays on ResultAsync.flatMap, .andThen, asyncAfter, or a Railway.* workflow.
API Design Notes
Fluent Starters
Railway static methods should start workflows directly:
Railway.empty()
Railway.context({ profileId })
Railway.fromSync("profileId", () => ProfileIdSchema.parse(id), toError)
Railway.fromResult("settings", () => loadSettings())
Railway.fromPromise("row", () => db.query.users.findFirst(...), toError)
Railway.fromAsync("artifact", () => ingestArtifact(...))
Avoid requiring:
Railway.create().fromSync(...)
The static starter form is easier to scan and avoids an empty builder call.
Instance Steps
Expected instance methods:
.derive(key, fn)
.fromResult(key, fn)
.fromSync(key, fn)
.fromPromise(key, fn)
.fromAsync(key, fn)
.require(key, source, onMissing)
.parallel(record)
.select(fn)
.done()
Recommended semantics:
derive is pure and should not catch throws.
- Use
fromSync for throwing sync transforms.
require narrows nullable values to non-null fields.
parallel always upgrades to async.
select hides the internal context.
done returns the accumulated context.
When Not To Use Workflow Syntax
Do not use Railway.* for:
- a single parse
- a single DB call
- one validation followed by one insert where
asyncAfter is clearer
- small library combinators
- code where positional tuple output is genuinely clearer than named context
Bad:
return Railway
.fromSync("artifact", () => ArtifactSchema.parse(artifact), toError)
.fromPromise("inserted", ({ artifact }) =>
getDb().insert(artifacts).values(artifact).then(() => undefined),
toError,
)
.select(() => undefined);
Better:
return asyncAfter(
trySync(() => ArtifactSchema.parse(artifact), toError)(),
(validated) =>
tryAsync(
getDb()
.insert(artifacts)
.values(validated)
.then(() => undefined),
),
);
The workflow builder earns its keep when it names several domain steps and removes real nesting.
Refactor Heuristics
Consider fluent Railway.* when at least two are true:
- more than three railway steps
- both sync and async boundaries appear
- nullable DB output must become a required value
- independent async branches can run in parallel
- comments explain dataflow because the code shape does not
- prior values must be carried forward through nested closures
Consider extracting reusable step functions when:
- two or more workflows share steps
- step-level tests would be useful
- the same parse/query/require sequence repeats
Consider tryGen when:
- all steps are sync
- the current code is a nested
flatMapResult tree
- a workflow builder would introduce unnecessary named context
Stay low-level when:
- the function has one or two steps
- the code is library internals
- the operation is already readable with
asyncAfter, .flatMap, or mapResult
Maintainer Workflow For Case Studies
Each case study should come from a real downstream PR.
- Identify the pain point: nested sync chain, mixed sync/async ETL, or reusable workflow steps.
- Write the low-level current version and the proposed
Railway.* version.
- Add line comments explaining what each new step does.
- Confirm the proposed style removes plumbing without hiding important failure behavior.
- Add the before/after pair to this skill or to package docs.
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.