1013-panic-vs-result — Panic vs Result
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
expect instead of unwrap to give panics meaningful messagesdebug_assert! versus assert! is appropriateResult and let callers decide how to handle failurespanic! unwinds the stack and cannot be caught in normal code pathsCode 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
try ... with; Rust panics can only be caught with std::panic::catch_unwind and are not recommended for control flow.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_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
}
}#[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 bugsResult type — for expected failuresRust Approach
panic!, unwrap(), expect(), unreachable!() — for bugsResult<T, E> — for expected failuresComparison Table
| Aspect | OCaml | Rust |
|---|---|---|
| Bug/invariant | failwith / assert false | panic! / unreachable! |
| Quick unwrap | Option.get (unsafe) | unwrap() / expect() |
| Expected failure | Result / Option | Result / Option |
| Debug-only check | N/A | debug_assert! |
| Custom message | invalid_arg "msg" | expect("msg") |
| Cultural norm | Exceptions common | Result strongly preferred |
Exercises
divide_or_panic into a safe divide function and a thin divide_unchecked that panics — document the invariant clearly with a comment.Result for parsing errors and panic! if the port is outside 1–65535 (treat that as a configuration bug).std::panic::catch_unwind to call first_element on an empty slice and verify that the panic is recovered without crashing the test.