Railway-oriented programming models success and failure as two tracks through a workflow. Instead of throwing exceptions everywhere, each step returns a value that is either successful or failed, and the pipeline continues on the appropriate track. When asynchronous operations are involved, the same idea still works, but you need combinators that understand both the Result shape and the async boundary.
At the center of the pattern is a result type with two branches: success and failure. In TypeScript or JavaScript, one simple version looks like this:
type Result<T, E> =
| { ok: true; value: T } | { ok: false; error: E }; function ok<T>(value: T): Result<T, never> { return { ok: true, value }; } function err<E>(error: E): Result<never, E> { return { ok: false, error }; } ``` Each function returns a value that forces callers to handle both paths explicitly. ## Composing Synchronous Steps Before async work, the pattern is straightforward: if a step succeeds, feed its value to the next step. If it fails, stop and return the failure unchanged. ```typescript function bind<T, U, E>( input: Result<T, E>, next: (value: T) => Result<U, E> ): Result<U, E> { return input.ok ? next(input.value) : input; } ``` This is the railway switch: stay on the success track or keep rolling on the failure track. ## Async Changes the Shape As soon as a step becomes asynchronous, you are no longer composing just `Result<T, E>`. You are composing `Promise<Result<T, E>>`. That means your combinators must await the promise, then inspect the result. ```typescript async function bindAsync<T, U, E>( input: Promise<Result<T, E>>, next: (value: T) => Promise<Result<U, E>> ): Promise<Result<U, E>> { const resolved = await input; return resolved.ok ? next(resolved.value) : resolved; } ``` This lets you sequence database calls, HTTP requests, or other async steps without losing the railway structure. ## End-to-End Async Workflow Example Here is a small pipeline that validates input, loads a user asynchronously, and transforms the output. ```typescript type Result<T, E> = | { ok: true; value: T } | { ok: false; error: E }; const ok = <T>(value: T): Result<T, never> => ({ ok: true, value }); const err = <E>(error: E): Result<never, E> => ({ ok: false, error }); function validateUserId(raw: string): Result<number, string> { const id = Number(raw); return Number.isInteger(id) && id > 0 ? ok(id) : err("invalid user id"); } async function fetchUser(id: number): Promise<Result<{ id: number; name: string }, string>> { await new Promise((r) => setTimeout(r, 10)); return id === 42 ? ok({ id, name: "Ava" }) : err("user not found"); } async function run(raw: string): Promise<Result<string, string>> { const validated = validateUserId(raw); if (!validated.ok) return validated; const user = await fetchUser(validated.value); if (!user.ok) return user; return ok(user.value.name.toUpperCase()); } run("42").then(console.log); run("0").then(console.log); ``` This code stays explicit about where failures occur without throwing for normal business-rule errors. ## Why This Pattern Helps Railway-oriented async code is useful when: - failures are expected domain outcomes, not exceptional crashes. - you want to avoid deeply nested `try` and `catch`. - you want error flow to be visible in function types. - you need predictable composition of validation and I/O steps. It works especially well for request pipelines, form processing, payment flows, and API orchestration. ## Exceptions Still Have a Place Railway-oriented programming should not replace all exceptions. Unexpected failures such as corrupted configuration, broken infrastructure, or invariant violations may still deserve exception handling. A good rule is: - use `Result` for expected business failures. - use exceptions for unexpected programmer or infrastructure failures. Mixing the two without discipline leads to confusion. ## Common Pitfalls - Wrapping every possible failure in `Result` and hiding real exceptional errors. - Forgetting that async composition changes the type to `Promise<Result<...>>`. - Writing manual `if` chains everywhere instead of reusable bind helpers. - Losing error detail by collapsing every failure into one generic message. - Treating the pattern as mandatory even where simple `try` and `catch` would be clearer. ## Summary - Railway-oriented programming models success and failure as explicit result tracks. - Async workflows require composition over `Promise<Result<...>>`, not just `Result<...>`. - The pattern works well for expected business-rule failures in I/O-heavy pipelines. - Keep domain failures in `Result` and reserve exceptions for truly unexpected failures. - Use helper combinators to keep async railway code readable and maintainable.