Think of a value as a little train rolling through your program. Most real jobs aren't one action but a chain of them: validate the input, parse it into something useful, save it, then notify someone. Each of those steps can succeed — or derail. Railway-oriented programming (a name coined by Scott Wlaschin) is a delightfully simple way to lay down the tracks so a derailment never causes a pile-up.
The problem
Try writing that workflow the usual way and watch it get tangled. Either you nest if checks until the real work is buried six levels deep, or you sprinkle exceptions around and hope every caller remembers to catch them. Both approaches mix the happy path and the sad path together, so it's hard to see what actually happens when something goes wrong — and easy to forget a case entirely.
The classic smell is a pyramid of if validation passed, then if parse worked, then if save succeeded... — each step indented further than the last, with a matching else for the error buried somewhere below. The logic is fine; it's the shape that makes it unreadable and fragile.
How it works
Give every step two possible outcomes instead of one. In F# that's the Result type: a step returns either Ok value (it worked — stay on the success track) or Error e (it failed — switch to the failure track). Now imagine two parallel rails running the length of your workflow: the value rides the success track from step to step, but the instant one step hands back an Error, it hops onto the failure track and coasts past everything else, carrying the error straight to the finish line.
- Ok / ErrorEach step returns success (Ok) or failure (Error); the first failure switches tracks and skips the rest.
The piece that connects two steps is Result.bind. It's a tiny rule: if the value is still Ok, run the next step; if it's already an Error, don't run anything — just let it slide through unchanged. That single rule is the railway switch that makes the whole pattern work.
// Each step takes a value and returns a Result — success or failure.
let validate input =
if input <> "" then Ok input
else Error "input was empty"
let parse (s: string) =
match System.Int32.TryParse s with
| true, n -> Ok n
| _ -> Error "not a number"
Now chain the steps together with Result.bind. Read it top to bottom like a list of stations the train visits in order:
let save n = if n > 0 then Ok n else Error "must be positive"
let notify n = Ok (sprintf "saved and notified: %d" n)
let workflow input =
validate input
|> Result.bind parse
|> Result.bind save
|> Result.bind notify
If validate returns Ok, the value rolls into parse; if parse returns Ok, on to save, and so on. But if any step returns an Error, every Result.bind after it simply hands the same error along without running its step. The error you see at the end is the first one that happened.
- parseReturns Error here — the value leaves the success track and never reaches save.
- ErrorThe first failure rides straight to the end, carrying its reason and skipping every remaining step.
This builds directly on the maybe-value: there, a value was either something or nothing. Result adds a label to the "nothing" case so a failure can explain itself — Error "input was empty" instead of a silent blank. Same two-track idea, now with a reason attached.
Arriving at the station
However the train arrives — success or failure — you handle both outcomes in one place at the end with a single match:
match workflow "42" with
| Ok message -> printfn "%s" message
| Error why -> printfn "failed: %s" why
This is where the pattern pays off. There's no try/catch wrapped around every call and no nested if ladder — just two clean branches that pattern matching forces you to cover. Forget the Error case and the compiler reminds you.
Because a step can run again after a failure upstream is fixed (or after a retry), it pays to make each step safe to repeat — see idempotency. A save that double-charges or double-creates turns a harmless retry on the failure track into a real-world mess.
That's the whole idea: model each step as Ok-or-Error, wire them with Result.bind, and let the first failure reroute around the rest. Your workflow reads as a straight line of stations, the error path takes care of itself, and one final match brings both tracks home. Tidy tracks, no pile-ups.