Explainstuff.mebeta
All concepts
Functional Programmingintermediate7 min

The Success-or-Failure Railway

Wire your steps together like train tracks, and the first failure quietly reroutes around everything that's left.

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.

Watch out

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.

Two tracks: success and failure
success track
input
validate
save
Ok
Error
Stay on the success track through every step; the first failure switches to the failure track and skips ahead.

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.

The first failure skips the rest
failure shunts across
input
validate
parse
save
Ok
Error
parse fails, so the value hops to the failure track and coasts past save — straight to Error.
Note

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.

Tip

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.

Key takeaways

  • Real workflows are a chain of steps that can each fail — validate, parse, save, notify — and doing that with nested if-checks or scattered exceptions gets tangled fast.
  • Model each step's outcome as a `Result`: either `Ok value` on the success track, or `Error e` on the failure track.
  • `Result.bind` wires two steps together: it runs the next step only when the value is still `Ok`, and lets any `Error` glide straight past.
  • The first failure switches the value onto the failure track and skips every remaining step, carrying the error untouched to the end.
  • One `match` on `Ok`/`Error` at the very end handles both outcomes in a single place — no try/catch sprawl.

Keep going