Functional Programmingintermediate10 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: check the order, save it, email the customer a receipt. 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. With plain if checks, every step has to sit inside the one before it:

let placeOrder order =
    if order.Email <> "" then
        if save order then
            if sendEmail order then "order placed"
            else "couldn't send the receipt"
        else "couldn't save the order"
    else "email is blank"

The real work drifts further right with every step, and each error message lands far away from the check that caused it — the message for the first check is on the last line. Add a fourth step and the pyramid grows another level.

The other common route is exceptions: each step throws when something goes wrong, and you hope every caller remembers to catch. Nothing in the code tells you which steps can fail, and a forgotten try only shows up when a real customer hits it.

Watch out

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 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, and it's simpler than it sounds: think of it as a box with one of two labels on it. Ok means it worked, and here's the value. Error means it didn't, and here's why. Whoever receives the box has to check the label before using what's inside, so a failure can't be mistaken for a success.

type Order = { Email: string; Number: int }   // Number stays 0 until the order is saved

let validate order =
    if order.Email = "" then Error "email is blank"   // switch to the failure track
    else Ok order                                     // stay on the success 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.

Step through an order with a blank email below. Once validate has failed, predict which of the remaining steps still run. Then flip to a valid order and watch it take the same stations on the other track.

Check yourself

Your checkout runs validate, then chargeCard, then ship, and each returns a Result. The card is declined, so chargeCard returns Error "card declined". What does ship do?

The switch between steps

Something has to sit between two steps and decide whether the next one runs. It's a tiny function — let's call it andThen:

let andThen next result =
    match result with
    | Ok value -> next value   // still on the success track: run the next step
    | Error e  -> Error e      // already failed: skip it, pass the error along

In plain words: if the value so far is Ok, take it out of the box and run the next step on it; if it's already an Error, don't run anything — hand the same error straight on. That one rule is the railway switch that makes the whole pattern work. You don't even have to write it yourself: F# ships the same switch as Result.bind, so |> Result.bind save does exactly what |> andThen save does.

Here are the other two steps (db and mailer stand in for your real database and email clients). save turns a database outage into an Error instead of letting an exception escape, and sendEmail passes the order along once the receipt is out:

let save order =
    if db.IsUp then Ok (db.Insert order)   // the saved order, now with its number
    else Error "db is down"

let sendEmail order =
    mailer.Send(order.Email, "Thanks for your order!")
    Ok order

Now chain them with pipes, putting andThen in front of every step after the first:

let placeOrder order =
    validate order
    |> andThen save
    |> andThen sendEmail

It reads as a straight list of stations, with no if in sight. If validate returns Ok, the order rolls into save; if save returns Ok, on to sendEmail. If any step returns an Error, every andThen after it simply hands that error along without running its step. The error you see at the end is the first one that happened.

Pick an order below and step through the code. Watch which line of andThen's match runs at each step, and predict what happens to the steps after a failure.

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 "email is blank" 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 placeOrder order with
| Ok saved  -> showMessage (sprintf "Order #%d placed" saved.Number)
| Error why -> showMessage 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 warns you.

Check yourself

An order with a blank email arrives on a day the database is down. What does placeOrder return?

Watch out

The railway stops at the first failure. That's exactly right for save-then-email, but it can be annoying on a form: a customer with a blank email and a missing address only hears about the email, fixes it, and then gets told about the address. When you want to report every problem at once, check all the fields inside validate and return one Error that lists them all.

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: give each step an Ok-or-Error outcome, connect them with a tiny switch, 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, save, notify — and wiring them with nested if-checks or scattered exceptions gets tangled fast.
  • Give every step two possible outcomes with F#'s `Result`: `Ok value` keeps the value on the success track, `Error reason` switches it to the failure track.
  • A tiny switch between steps (called `andThen` here) runs the next step only while the value is still `Ok`, and lets any `Error` roll straight past.
  • The first failure skips every remaining step and arrives at the end untouched, so the error you see is the one that happened first.
  • One `match` on `Ok`/`Error` at the very end handles both outcomes in a single place — no try/catch sprawl.

Keep going