ExamplesBy LevelBy TopicLearning Paths
1013 Intermediate

1013-panic-vs-result — Panic vs Result

Functional Programming

Tutorial

The Problem

Every language with explicit error handling faces the same design question: when should an error abort the program versus return a recoverable value? In Go, panic and error serve different roles. In Rust, panic! signals a programming bug (an invariant violation), while Result<T, E> represents an expected failure that callers should handle. Conflating the two leads to either over-cautious unwrap-heavy code or libraries that silently swallow errors.

The rule of thumb: use panic! for logic errors that indicate the program is in an unrecoverable state; use Result for operations that legitimately fail in production (file not found, network timeout, bad input from an untrusted source).

🎯 Learning Outcomes

  • • Distinguish programming bugs (panic) from recoverable failures (Result)
  • • Use expect instead of unwrap to give panics meaningful messages
  • • Know when debug_assert! versus assert! is appropriate
  • • Design library APIs that return Result and let callers decide how to handle failures
  • • Understand that panic! unwinds the stack and cannot be caught in normal code paths
  • Code Example

    #![allow(clippy::all)]
    // 1013: Panic vs Result
    // When to panic vs return Result: unwrap, expect, assertions
    
    // Approach 1: panic! / unwrap / expect — for bugs and invariants
    fn divide_or_panic(a: i64, b: i64) -> i64 {
        if b == 0 {
            panic!("division by zero: programming error");
        }
        a / b
    }
    
    fn first_element(slice: &[i64]) -> i64 {
        // unwrap: panics with generic message
        // expect: panics with custom message — preferred
        slice.first().copied().expect("slice must not be empty")
    }
    
    // Approach 2: Result — for expected/recoverable failures
    fn divide(a: i64, b: i64) -> Result<i64, String> {
        if b == 0 {
            Err("division by zero".into())
        } else {
            Ok(a / b)
        }
    }
    
    fn parse_positive(s: &str) -> Result<i64, String> {
        let n: i64 = s.parse().map_err(|_| format!("not a number: {}", s))?;
        if n <= 0 {
            Err(format!("not positive: {}", n))
        } else {
            Ok(n)
        }
    }
    
    // Approach 3: debug_assert for development-only checks
    fn process_data(data: &[i64]) -> i64 {
        debug_assert!(!data.is_empty(), "data must not be empty");
        assert!(data.len() <= 1000, "data too large"); // always checked
        data.iter().sum()
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_divide_success() {
            assert_eq!(divide_or_panic(10, 2), 5);
        }
    
        #[test]
        #[should_panic(expected = "division by zero")]
        fn test_divide_panic() {
            divide_or_panic(10, 0);
        }
    
        #[test]
        fn test_first_element() {
            assert_eq!(first_element(&[1, 2, 3]), 1);
        }
    
        #[test]
        #[should_panic(expected = "must not be empty")]
        fn test_first_element_panic() {
            first_element(&[]);
        }
    
        #[test]
        fn test_result_divide() {
            assert_eq!(divide(10, 2), Ok(5));
            assert_eq!(divide(10, 0), Err("division by zero".into()));
        }
    
        #[test]
        fn test_parse_positive() {
            assert_eq!(parse_positive("42"), Ok(42));
            assert!(parse_positive("-5").unwrap_err().contains("not positive"));
            assert!(parse_positive("abc").unwrap_err().contains("not a number"));
        }
    
        #[test]
        fn test_process_data() {
            assert_eq!(process_data(&[1, 2, 3]), 6);
        }
    
        #[test]
        fn test_unwrap_vs_expect() {
            // unwrap: generic panic message
            let val: Option<i64> = Some(42);
            assert_eq!(val.unwrap(), 42);
    
            // expect: custom panic message — better for debugging
            assert_eq!(val.expect("should have a value"), 42);
        }
    
        #[test]
        fn test_guidelines() {
            // Use panic/unwrap/expect when:
            // - Logic error / invariant violation (bug in your code)
            // - Prototype/example code
            // - Tests
    
            // Use Result when:
            // - Input validation
            // - File/network operations
            // - Parsing user data
            // - Any expected failure the caller should handle
            assert!(true); // documenting the distinction
        }
    }

    Key Differences

  • Catchability: OCaml exceptions are always catchable with try ... with; Rust panics can only be caught with std::panic::catch_unwind and are not recommended for control flow.
  • Library convention: Rust library crates are expected to return Result for all user-facing failures; panicking in a library is considered poor practice unless it signals a bug.
  • **expect messages**: Rust's expect("reason") is a convention for explaining why a value should never be None/Err; OCaml's Option.value_exn ~message: fills the same role.
  • Debug vs release: Rust's debug_assert! disappears in release builds; OCaml has no direct equivalent in the standard library.
  • OCaml Approach

    OCaml uses exceptions for recoverable errors and failwith/assert for bugs:

    exception Division_by_zero_user of string
    
    let divide a b =
      if b = 0 then Error "division by zero"
      else Ok (a / b)
    
    let divide_or_raise a b =
      if b = 0 then failwith "programming error: divide by zero"
      else a / b
    

    OCaml exceptions are more integrated into the type system than Rust panics — they can be caught with try ... with — but idiomatic modern OCaml prefers Result.

    Full Source

    #![allow(clippy::all)]
    // 1013: Panic vs Result
    // When to panic vs return Result: unwrap, expect, assertions
    
    // Approach 1: panic! / unwrap / expect — for bugs and invariants
    fn divide_or_panic(a: i64, b: i64) -> i64 {
        if b == 0 {
            panic!("division by zero: programming error");
        }
        a / b
    }
    
    fn first_element(slice: &[i64]) -> i64 {
        // unwrap: panics with generic message
        // expect: panics with custom message — preferred
        slice.first().copied().expect("slice must not be empty")
    }
    
    // Approach 2: Result — for expected/recoverable failures
    fn divide(a: i64, b: i64) -> Result<i64, String> {
        if b == 0 {
            Err("division by zero".into())
        } else {
            Ok(a / b)
        }
    }
    
    fn parse_positive(s: &str) -> Result<i64, String> {
        let n: i64 = s.parse().map_err(|_| format!("not a number: {}", s))?;
        if n <= 0 {
            Err(format!("not positive: {}", n))
        } else {
            Ok(n)
        }
    }
    
    // Approach 3: debug_assert for development-only checks
    fn process_data(data: &[i64]) -> i64 {
        debug_assert!(!data.is_empty(), "data must not be empty");
        assert!(data.len() <= 1000, "data too large"); // always checked
        data.iter().sum()
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_divide_success() {
            assert_eq!(divide_or_panic(10, 2), 5);
        }
    
        #[test]
        #[should_panic(expected = "division by zero")]
        fn test_divide_panic() {
            divide_or_panic(10, 0);
        }
    
        #[test]
        fn test_first_element() {
            assert_eq!(first_element(&[1, 2, 3]), 1);
        }
    
        #[test]
        #[should_panic(expected = "must not be empty")]
        fn test_first_element_panic() {
            first_element(&[]);
        }
    
        #[test]
        fn test_result_divide() {
            assert_eq!(divide(10, 2), Ok(5));
            assert_eq!(divide(10, 0), Err("division by zero".into()));
        }
    
        #[test]
        fn test_parse_positive() {
            assert_eq!(parse_positive("42"), Ok(42));
            assert!(parse_positive("-5").unwrap_err().contains("not positive"));
            assert!(parse_positive("abc").unwrap_err().contains("not a number"));
        }
    
        #[test]
        fn test_process_data() {
            assert_eq!(process_data(&[1, 2, 3]), 6);
        }
    
        #[test]
        fn test_unwrap_vs_expect() {
            // unwrap: generic panic message
            let val: Option<i64> = Some(42);
            assert_eq!(val.unwrap(), 42);
    
            // expect: custom panic message — better for debugging
            assert_eq!(val.expect("should have a value"), 42);
        }
    
        #[test]
        fn test_guidelines() {
            // Use panic/unwrap/expect when:
            // - Logic error / invariant violation (bug in your code)
            // - Prototype/example code
            // - Tests
    
            // Use Result when:
            // - Input validation
            // - File/network operations
            // - Parsing user data
            // - Any expected failure the caller should handle
            assert!(true); // documenting the distinction
        }
    }
    ✓ Tests Rust test suite
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_divide_success() {
            assert_eq!(divide_or_panic(10, 2), 5);
        }
    
        #[test]
        #[should_panic(expected = "division by zero")]
        fn test_divide_panic() {
            divide_or_panic(10, 0);
        }
    
        #[test]
        fn test_first_element() {
            assert_eq!(first_element(&[1, 2, 3]), 1);
        }
    
        #[test]
        #[should_panic(expected = "must not be empty")]
        fn test_first_element_panic() {
            first_element(&[]);
        }
    
        #[test]
        fn test_result_divide() {
            assert_eq!(divide(10, 2), Ok(5));
            assert_eq!(divide(10, 0), Err("division by zero".into()));
        }
    
        #[test]
        fn test_parse_positive() {
            assert_eq!(parse_positive("42"), Ok(42));
            assert!(parse_positive("-5").unwrap_err().contains("not positive"));
            assert!(parse_positive("abc").unwrap_err().contains("not a number"));
        }
    
        #[test]
        fn test_process_data() {
            assert_eq!(process_data(&[1, 2, 3]), 6);
        }
    
        #[test]
        fn test_unwrap_vs_expect() {
            // unwrap: generic panic message
            let val: Option<i64> = Some(42);
            assert_eq!(val.unwrap(), 42);
    
            // expect: custom panic message — better for debugging
            assert_eq!(val.expect("should have a value"), 42);
        }
    
        #[test]
        fn test_guidelines() {
            // Use panic/unwrap/expect when:
            // - Logic error / invariant violation (bug in your code)
            // - Prototype/example code
            // - Tests
    
            // Use Result when:
            // - Input validation
            // - File/network operations
            // - Parsing user data
            // - Any expected failure the caller should handle
            assert!(true); // documenting the distinction
        }
    }

    Deep Comparison

    Panic vs Result — Comparison

    Core Insight

    Both languages have two error channels: one for bugs (panic/exception) and one for expected failures (Result). The key is knowing which to use when.

    OCaml Approach

  • failwith, invalid_arg, assert false — for bugs
  • Result type — for expected failures
  • • Exceptions are lightweight (no stack trace by default)
  • • Culture: exceptions used more liberally than Rust panics
  • Rust Approach

  • panic!, unwrap(), expect(), unreachable!() — for bugs
  • Result<T, E> — for expected failures
  • • Panics unwind the stack (or abort, configurable)
  • • Culture: strong preference for Result; panic = something went very wrong
  • Comparison Table

    AspectOCamlRust
    Bug/invariantfailwith / assert falsepanic! / unreachable!
    Quick unwrapOption.get (unsafe)unwrap() / expect()
    Expected failureResult / OptionResult / Option
    Debug-only checkN/Adebug_assert!
    Custom messageinvalid_arg "msg"expect("msg")
    Cultural normExceptions commonResult strongly preferred

    Exercises

  • Refactor divide_or_panic into a safe divide function and a thin divide_unchecked that panics — document the invariant clearly with a comment.
  • Write a function that reads a port number from a string, using Result for parsing errors and panic! if the port is outside 1–65535 (treat that as a configuration bug).
  • Use std::panic::catch_unwind to call first_element on an empty slice and verify that the panic is recovered without crashing the test.
  • Open Source Repos