ExamplesBy LevelBy TopicLearning Paths
072 Intermediate

072 — Railway-Oriented Programming

Functional Programming

Tutorial

The Problem

Railway-oriented programming (ROP), coined by Scott Wlaschin in his 2014 talk and blog series, is a visual metaphor for monadic error handling. Picture a two-track railway: the "happy track" carries Ok values forward; the "error track" carries Err values that bypass all remaining steps. Each processing function is a switch: if the input arrives on the happy track and the function succeeds, the output stays on the happy track; if the function fails, the output switches to the error track and stays there for all subsequent steps.

This pattern, implemented via and_then chaining, produces code that reads top-to-bottom as a linear sequence of steps — exactly like procedural code with exceptions, but without hidden control flow. The ? operator in Rust is syntactic sugar for and_then applied to the current function's result type. Used in F#, Rust, Scala, Haskell, and any language where Result/Either is the primary error-handling mechanism, this pattern scales from simple validation pipelines to multi-step business logic with complex failure modes.

🎯 Learning Outcomes

  • • Chain validation functions with and_then to form a railway
  • • Understand that each and_then step runs only on the happy track
  • • Use validate_item, validate_quantity, validate_price as railway switches
  • • Implement railway-style pipelines using both explicit and_then and ?
  • • Recognize that the railway metaphor explains Result monad behavior
  • Code Example

    #![allow(clippy::all)]
    // 072: Railway-Oriented Programming
    // Chain Results — stay on happy path or switch to error track
    
    #[derive(Debug, Clone)]
    struct Order {
        item: String,
        quantity: i32,
        price: f64,
    }
    
    // Individual validation steps
    fn validate_quantity(order: Order) -> Result<Order, String> {
        if order.quantity <= 0 {
            Err("Quantity must be positive".into())
        } else {
            Ok(order)
        }
    }
    
    fn validate_price(order: Order) -> Result<Order, String> {
        if order.price <= 0.0 {
            Err("Price must be positive".into())
        } else {
            Ok(order)
        }
    }
    
    fn validate_item(order: Order) -> Result<Order, String> {
        if order.item.is_empty() {
            Err("Item name required".into())
        } else {
            Ok(order)
        }
    }
    
    // Approach 1: and_then chain
    fn validate_order_chain(order: Order) -> Result<Order, String> {
        validate_item(order)
            .and_then(validate_quantity)
            .and_then(validate_price)
    }
    
    // Approach 2: Using ? operator
    fn validate_order_question(order: Order) -> Result<Order, String> {
        let o = validate_item(order)?;
        let o = validate_quantity(o)?;
        validate_price(o)
    }
    
    // Approach 3: Full pipeline
    fn apply_discount(pct: f64, mut order: Order) -> Result<Order, String> {
        if !(0.0..=100.0).contains(&pct) {
            Err("Invalid discount".into())
        } else {
            order.price *= 1.0 - pct / 100.0;
            Ok(order)
        }
    }
    
    fn calculate_total(order: &Order) -> f64 {
        order.quantity as f64 * order.price
    }
    
    fn process_order(order: Order, discount: f64) -> Result<f64, String> {
        let o = validate_order_chain(order)?;
        let o = apply_discount(discount, o)?;
        Ok(calculate_total(&o))
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_valid_order() {
            let o = Order {
                item: "Widget".into(),
                quantity: 5,
                price: 10.0,
            };
            assert!(validate_order_chain(o).is_ok());
        }
    
        #[test]
        fn test_invalid_quantity() {
            let o = Order {
                item: "Widget".into(),
                quantity: -1,
                price: 10.0,
            };
            assert_eq!(
                validate_order_chain(o).unwrap_err(),
                "Quantity must be positive"
            );
        }
    
        #[test]
        fn test_invalid_item() {
            let o = Order {
                item: "".into(),
                quantity: 5,
                price: 10.0,
            };
            assert_eq!(validate_order_chain(o).unwrap_err(), "Item name required");
        }
    
        #[test]
        fn test_process_order() {
            let o = Order {
                item: "Widget".into(),
                quantity: 5,
                price: 10.0,
            };
            assert_eq!(process_order(o, 10.0), Ok(45.0));
        }
    
        #[test]
        fn test_bad_discount() {
            let o = Order {
                item: "Widget".into(),
                quantity: 5,
                price: 10.0,
            };
            assert!(process_order(o, 200.0).is_err());
        }
    }

    Key Differences

  • Visual metaphor: The railway metaphor makes and_then chains intuitive — you are either on the happy track or the error track. ? makes the track switch implicit.
  • Pass-through pattern: Each validator takes and returns the full order (pass-through). This allows validators to be composed in any order without changing signatures.
  • Error accumulation vs railway: The railway pattern stops at the first error. For all-errors-at-once, use the Validation pattern (example 072-error-accumulation). Knowing when to use which is key.
  • **>>= in OCaml**: Defining let (>>=) = Result.bind locally in OCaml makes railway chains very readable: a >>= f >>= g >>= h. Rust's method syntax a.and_then(f).and_then(g) is equivalent.
  • OCaml Approach

    OCaml's railway uses Result.bind or custom operators:

    let validate_order order =
      let ( >>= ) = Result.bind in
      validate_item order
      >>= validate_quantity
      >>= validate_price
    

    With OCaml 4.08+ binding operators, you can also write let* o = validate_item order in let* o = validate_quantity o in validate_price o. Each step receives the validated value from the previous step. The symmetry with Rust's .and_then chain is exact.

    Full Source

    #![allow(clippy::all)]
    // 072: Railway-Oriented Programming
    // Chain Results — stay on happy path or switch to error track
    
    #[derive(Debug, Clone)]
    struct Order {
        item: String,
        quantity: i32,
        price: f64,
    }
    
    // Individual validation steps
    fn validate_quantity(order: Order) -> Result<Order, String> {
        if order.quantity <= 0 {
            Err("Quantity must be positive".into())
        } else {
            Ok(order)
        }
    }
    
    fn validate_price(order: Order) -> Result<Order, String> {
        if order.price <= 0.0 {
            Err("Price must be positive".into())
        } else {
            Ok(order)
        }
    }
    
    fn validate_item(order: Order) -> Result<Order, String> {
        if order.item.is_empty() {
            Err("Item name required".into())
        } else {
            Ok(order)
        }
    }
    
    // Approach 1: and_then chain
    fn validate_order_chain(order: Order) -> Result<Order, String> {
        validate_item(order)
            .and_then(validate_quantity)
            .and_then(validate_price)
    }
    
    // Approach 2: Using ? operator
    fn validate_order_question(order: Order) -> Result<Order, String> {
        let o = validate_item(order)?;
        let o = validate_quantity(o)?;
        validate_price(o)
    }
    
    // Approach 3: Full pipeline
    fn apply_discount(pct: f64, mut order: Order) -> Result<Order, String> {
        if !(0.0..=100.0).contains(&pct) {
            Err("Invalid discount".into())
        } else {
            order.price *= 1.0 - pct / 100.0;
            Ok(order)
        }
    }
    
    fn calculate_total(order: &Order) -> f64 {
        order.quantity as f64 * order.price
    }
    
    fn process_order(order: Order, discount: f64) -> Result<f64, String> {
        let o = validate_order_chain(order)?;
        let o = apply_discount(discount, o)?;
        Ok(calculate_total(&o))
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_valid_order() {
            let o = Order {
                item: "Widget".into(),
                quantity: 5,
                price: 10.0,
            };
            assert!(validate_order_chain(o).is_ok());
        }
    
        #[test]
        fn test_invalid_quantity() {
            let o = Order {
                item: "Widget".into(),
                quantity: -1,
                price: 10.0,
            };
            assert_eq!(
                validate_order_chain(o).unwrap_err(),
                "Quantity must be positive"
            );
        }
    
        #[test]
        fn test_invalid_item() {
            let o = Order {
                item: "".into(),
                quantity: 5,
                price: 10.0,
            };
            assert_eq!(validate_order_chain(o).unwrap_err(), "Item name required");
        }
    
        #[test]
        fn test_process_order() {
            let o = Order {
                item: "Widget".into(),
                quantity: 5,
                price: 10.0,
            };
            assert_eq!(process_order(o, 10.0), Ok(45.0));
        }
    
        #[test]
        fn test_bad_discount() {
            let o = Order {
                item: "Widget".into(),
                quantity: 5,
                price: 10.0,
            };
            assert!(process_order(o, 200.0).is_err());
        }
    }
    ✓ Tests Rust test suite
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_valid_order() {
            let o = Order {
                item: "Widget".into(),
                quantity: 5,
                price: 10.0,
            };
            assert!(validate_order_chain(o).is_ok());
        }
    
        #[test]
        fn test_invalid_quantity() {
            let o = Order {
                item: "Widget".into(),
                quantity: -1,
                price: 10.0,
            };
            assert_eq!(
                validate_order_chain(o).unwrap_err(),
                "Quantity must be positive"
            );
        }
    
        #[test]
        fn test_invalid_item() {
            let o = Order {
                item: "".into(),
                quantity: 5,
                price: 10.0,
            };
            assert_eq!(validate_order_chain(o).unwrap_err(), "Item name required");
        }
    
        #[test]
        fn test_process_order() {
            let o = Order {
                item: "Widget".into(),
                quantity: 5,
                price: 10.0,
            };
            assert_eq!(process_order(o, 10.0), Ok(45.0));
        }
    
        #[test]
        fn test_bad_discount() {
            let o = Order {
                item: "Widget".into(),
                quantity: 5,
                price: 10.0,
            };
            assert!(process_order(o, 200.0).is_err());
        }
    }

    Deep Comparison

    Core Insight

    Railway-oriented programming models computation as two tracks: success and failure. Each function either continues on the success track or diverts to the error track. Result's bind/and_then is the switch.

    OCaml Approach

  • Result.bind chains fallible operations
  • let* binding operators for monadic syntax
  • • Error track accumulates through the pipeline
  • Rust Approach

  • ? operator is railway switching — returns Err early
  • .and_then() for explicit chaining
  • .map_err() to convert between error types on the error track
  • Comparison Table

    FeatureOCamlRust
    Switch to errorError eErr(e) / ? returns
    Stay on successOk xOk(x)
    ChainResult.bind.and_then() / ?
    Transform errorResult.map_error.map_err()

    Exercises

  • Enriched errors: Change the error type to struct ValidationError { field: String, message: String }. Update the validators to include the field name. This enables structured error reporting.
  • Parallel validation: Combine the railway approach (sequential) with applicative validation (parallel): validate all fields simultaneously and accumulate errors, then construct the Order only if all pass.
  • Undo railway: Write a rollback function for a sequence of operations: if step 3 fails, undo steps 1 and 2. Model this with a stack of undo actions.
  • Open Source Repos