Error Handling in Rust: Building Industrial Software That Never Crashes
Why Rust Has No Exceptions
Many languages use try/catch exceptions. Rust deliberately avoids them because exceptions create invisible control flow -- dangerous in industrial software where a missed error can shut down a production line or damage equipment.
Instead, Rust uses two enum types from the standard library:
Option<T>-- for values that might not existResult<T, E>-- for operations that can succeed or fail
Both force the programmer to handle every case explicitly at compile time.
Option: Values That Might Not Exist
Option<T> has two variants: Some(value) or None. There is no null in Rust.
struct SensorArray {
sensors: Vec<f64>, // temperature readings
}
impl SensorArray {
// Returns None if no sensors exist
fn highest_reading(&self) -> Option<f64> {
if self.sensors.is_empty() {
None
} else {
let max = self.sensors.iter().cloned().fold(f64::MIN, f64::max);
Some(max)
}
}
}
fn main() {
let array = SensorArray { sensors: vec![72.1, 85.4, 63.9] };
// You must handle both cases
match array.highest_reading() {
Some(temp) => println!("Highest: {temp}°C"),
None => println!("No sensors connected"),
}
// Or use if-let for when you only care about Some
if let Some(temp) = array.highest_reading() {
println!("Peak temperature: {temp}°C");
}
}
Result<T, E>: Operations That Can Fail
Result<T, E> has Ok(value) for success and Err(error) for failure. Use it
for any operation that can go wrong: file I/O, network calls, sensor reads.
use std::fs;
fn read_machine_config(path: &str) -> Result<String, std::io::Error> {
fs::read_to_string(path)
}
fn main() {
match read_machine_config("/etc/machine/config.toml") {
Ok(contents) => println!("Config loaded: {} bytes", contents.len()),
Err(e) => println!("Failed to load config: {e}"),
}
}
The ? Operator: Elegant Error Propagation
The ? operator returns early with the error if the result is Err,
or unwraps the Ok value. It eliminates deeply nested match blocks.
use std::fs;
use std::io;
struct MachineConfig {
name: String,
max_rpm: u32,
}
fn load_config(path: &str) -> Result<MachineConfig, io::Error> {
let contents = fs::read_to_string(path)?; // returns Err early if it fails
let lines: Vec<&str> = contents.lines().collect();
let name = lines.first()
.ok_or(io::Error::new(io::ErrorKind::InvalidData, "missing name"))?;
let rpm: u32 = lines.get(1)
.ok_or(io::Error::new(io::ErrorKind::InvalidData, "missing rpm"))?
.trim()
.parse()
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid rpm"))?;
Ok(MachineConfig {
name: name.to_string(),
max_rpm: rpm,
})
}
Each ? replaces a multi-line match, keeping the code flat and readable.
Custom Error Types with enum
For industrial systems, define your own error types to capture domain-specific failures.
enum MachineError {
SensorTimeout { sensor_id: u32 },
OverTemperature(f64),
CalibrationFailed(String),
CommunicationLost,
}
impl std::fmt::Display for MachineError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MachineError::SensorTimeout { sensor_id } =>
write!(f, "Sensor #{sensor_id} timed out"),
MachineError::OverTemperature(temp) =>
write!(f, "Temperature {temp}°C exceeds limit"),
MachineError::CalibrationFailed(msg) =>
write!(f, "Calibration failed: {msg}"),
MachineError::CommunicationLost =>
write!(f, "Communication with PLC lost"),
}
}
}
fn check_sensor(id: u32, temp: f64) -> Result<f64, MachineError> {
if temp > 150.0 {
Err(MachineError::OverTemperature(temp))
} else {
Ok(temp)
}
}
Converting Between Error Types: The From Trait
When a function can produce different error types, implement From to allow
automatic conversion with the ? operator.
use std::io;
use std::num::ParseIntError;
enum ConfigError {
Io(io::Error),
Parse(ParseIntError),
Missing(String),
}
// Automatic conversion from io::Error
impl From<io::Error> for ConfigError {
fn from(e: io::Error) -> Self {
ConfigError::Io(e)
}
}
// Automatic conversion from ParseIntError
impl From<ParseIntError> for ConfigError {
fn from(e: ParseIntError) -> Self {
ConfigError::Parse(e)
}
}
fn load_sensor_count(path: &str) -> Result<u32, ConfigError> {
let text = std::fs::read_to_string(path)?; // io::Error -> ConfigError
let count: u32 = text.trim().parse()?; // ParseIntError -> ConfigError
if count == 0 {
return Err(ConfigError::Missing("sensor count is zero".into()));
}
Ok(count)
}
When to Use unwrap, expect, and When Not To
unwrap() and expect() panic on None or Err. In industrial code, panics
can crash a control system. Use them only in specific situations.
fn main() {
// SAFE: use expect in tests or when failure is truly impossible
let port: u16 = "8080".parse().expect("hardcoded value must parse");
// SAFE: prototypes and quick scripts
let config = std::fs::read_to_string("config.toml").unwrap();
// DANGEROUS in production -- prefer proper handling
// let reading = get_sensor_value().unwrap(); // could crash at 3 AM!
}
// Production-grade approach: always return Result
fn get_sensor_value(id: u32) -> Result<f64, String> {
// ... real implementation ...
Ok(42.0)
}
fn run_monitoring() -> Result<(), String> {
let value = get_sensor_value(1)?;
if value > 100.0 {
println!("Alert: high reading on sensor 1");
}
Ok(())
}
Guidelines:
- Use
expect("reason")for values you are certain exist, with a clear message. - Use
unwrap()only in tests or throwaway code. - In production industrial code, always propagate errors with
?or handle them withmatch.
Summary
- Rust has no exceptions; it uses
OptionandResultinstead, making errors visible. - Option represents values that may or may not exist (replaces null).
- Result<T, E> represents operations that can succeed or fail.
- The ? operator propagates errors cleanly without nested match blocks.
- Custom error enums model domain-specific failures for industrial systems.
- From trait implementations enable automatic error type conversion with
?. - Reserve unwrap/expect for tests and provably safe cases; production code should handle errors explicitly.