Error Handling #
Rust doesn’t have try-catch. This isn’t an oversight — it’s a deliberate design decision. In languages with exceptions, a function can throw an error at any time without declaring it in its signature, leaving callers unsure whether they need to handle errors or not. Rust rejects this uncertainty: every function that can fail must declare it explicitly in its return type, and callers are required to handle the result. The result is code that’s more verbose on one hand, but with a strong guarantee: if a program compiles, no error can slip through unhandled silently. This article covers Rust’s two error-handling paths — Result for recoverable errors and panic! for unrecoverable ones — along with the idiomatic patterns that keep error-handling code concise and expressive.
The Two Categories of Errors in Rust #
Rust divides errors into two fundamentally different categories, and handles them with different mechanisms:
flowchart TD
E[Errors in Rust]
E --> R["Recoverable\nCan be recovered\nProgram can continue"]
E --> U["Unrecoverable\nBug or unexpected condition\nProgram must stop"]
R --> R1["Result<T, E>\nOk or Err value\nThe caller must handle it"]
R --> R2["Option<T>\nSome or None value\nFor 'no value present'"]
U --> U1["panic!\nUnwinds the stack\nPrints a message and stops"]
U --> U2["assert! / assert_eq!\nunreachable! / todo!\nMeaningful panic variants"]The rule of thumb is simple: use Result for errors that reasonably occur in normal use (file not found, invalid input, failed connection), and panic! for conditions that shouldn’t be possible (index out of bounds, violated invariants, logic bugs).
Result<T, E> — Recoverable Errors
#
Result is a built-in enum with two variants:
// Definition in the standard library
enum Result<T, E> {
Ok(T), // operation succeeded, carrying a value of type T
Err(E), // operation failed, carrying an error of type E
}
Every function that can fail returns a Result. The compiler forces callers to handle both:
use std::fs;
use std::num::ParseIntError;
// A function that might fail — declared in the signature
fn baca_file(path: &str) -> Result<String, std::io::Error> {
fs::read_to_string(path)
}
fn parse_angka(s: &str) -> Result<i32, ParseIntError> {
s.trim().parse::<i32>()
}
fn main() {
// Handling with match — the most explicit
match baca_file("config.txt") {
Ok(isi) => println!("File contents: {}", isi),
Err(e) => println!("Failed to read file: {}", e),
}
// Handling with if let — when you only care about Ok
if let Ok(n) = parse_angka("42") {
println!("Number: {}", n);
}
// unwrap_or — a default value on Err
let n = parse_angka("not a number").unwrap_or(0);
println!("With default: {}", n);
// unwrap_or_else — compute the default value lazily
let n2 = parse_angka("xyz").unwrap_or_else(|e| {
eprintln!("Parse failed: {}", e);
-1
});
println!("With fallback: {}", n2);
}
Important Methods on Result
#
fn main() {
let ok: Result<i32, &str> = Ok(42);
let err: Result<i32, &str> = Err("gagal");
// Check status
println!("{} {}", ok.is_ok(), ok.is_err()); // true false
println!("{} {}", err.is_ok(), err.is_err()); // false true
// Transform the Ok value with map
let doubled = ok.map(|n| n * 2);
println!("{:?}", doubled); // Ok(84)
// Transform the error with map_err
let dengan_pesan = err.map_err(|e| format!("Error: {}", e));
println!("{:?}", dengan_pesan); // Err("Error: gagal")
// and_then — chain operations that can fail
let hasil = parse_angka("10")
.and_then(|n| {
if n > 0 { Ok(n * 2) }
else { Err("harus positif".parse::<i32>().unwrap_err()) }
});
// ok() — convert Result to Option (discard error information)
let sebagai_option: Option<i32> = ok.ok();
println!("{:?}", sebagai_option); // Some(42)
// ANTI-PATTERN: unwrap() in production code without checking
// err.unwrap(); // panic: called `Result::unwrap()` on an `Err` value
// Use expect() with an informative message while prototyping:
// err.expect("An operation that should always succeed");
}
fn parse_angka(s: &str) -> Result<i32, std::num::ParseIntError> {
s.parse()
}
The ? Operator — Automatic Error Propagation
#
The ? operator is the most idiomatic way to propagate errors up the call stack. It extracts the Ok value on success, or immediately return Err(...) on failure — one character replaces an entire match block:
use std::fs;
use std::io;
use std::num::ParseIntError;
// Without the ? operator — verbose
fn baca_dan_parse_verbose(path: &str) -> Result<i32, io::Error> {
let isi = match fs::read_to_string(path) {
Ok(s) => s,
Err(e) => return Err(e),
};
// can't directly return ParseIntError because the types differ
// must convert manually
Ok(42) // simplified
}
// With the ? operator — clean and linear
fn baca_dan_parse(path: &str) -> Result<i32, Box<dyn std::error::Error>> {
let isi = fs::read_to_string(path)?; // propagates io::Error
let angka: i32 = isi.trim().parse()?; // propagates ParseIntError
Ok(angka * 2)
}
fn main() {
match baca_dan_parse("angka.txt") {
Ok(n) => println!("Result: {}", n),
Err(e) => eprintln!("Error: {}", e),
}
}
? in main()
#
Since Rust 2018, main() can return a Result, allowing ? to be used directly inside it:
use std::fs;
use std::io;
fn main() -> Result<(), io::Error> {
let isi = fs::read_to_string("config.txt")?;
println!("Configuration:\n{}", isi);
Ok(()) // main succeeded
}
If Err is returned from main, the program prints the error and exits with a non-zero code — exactly like proper CLI error handling.
Custom Error Types #
For larger libraries or applications, defining your own error type enables more descriptive errors that are easier for callers to handle:
use std::fmt;
use std::num::ParseIntError;
// Custom error type with an enum
#[derive(Debug)]
enum AppError {
IoError(std::io::Error),
ParseError(ParseIntError),
Validasi(String),
}
// Implement Display for user-friendly messages
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
AppError::IoError(e) => write!(f, "I/O error: {}", e),
AppError::ParseError(e) => write!(f, "Parsing error: {}", e),
AppError::Validasi(msg) => write!(f, "Validation failed: {}", msg),
}
}
}
// Implement std::error::Error so it can be used as Box<dyn Error>
impl std::error::Error for AppError {}
// The From trait enables automatic conversion via the ? operator
impl From<std::io::Error> for AppError {
fn from(e: std::io::Error) -> Self {
AppError::IoError(e)
}
}
impl From<ParseIntError> for AppError {
fn from(e: ParseIntError) -> Self {
AppError::ParseError(e)
}
}
fn proses_file(path: &str) -> Result<i32, AppError> {
let isi = std::fs::read_to_string(path)?; // io::Error → AppError::IoError automatically via From
let n: i32 = isi.trim().parse()?; // ParseIntError → AppError::ParseError automatically
if n < 0 {
return Err(AppError::Validasi(format!("Value {} must be positive", n)));
}
Ok(n * 2)
}
fn main() {
match proses_file("data.txt") {
Ok(hasil) => println!("Result: {}", hasil),
Err(AppError::IoError(e)) => eprintln!("File error: {}", e),
Err(AppError::ParseError(e)) => eprintln!("Parse error: {}", e),
Err(AppError::Validasi(msg)) => eprintln!("Validation: {}", msg),
}
}
Box<dyn Error> for Dynamic Errors
#
When the caller doesn’t need to distinguish specific error types, Box<dyn std::error::Error> is a quick way to accept all kinds of errors:
use std::error::Error;
// Accept all error types — practical for scripts and binaries
fn baca_konfigurasi(path: &str) -> Result<(String, u16), Box<dyn Error>> {
let isi = std::fs::read_to_string(path)?; // io::Error
let mut baris = isi.lines();
let host = baris.next()
.ok_or("Host line not found")? // &str as an error
.to_string();
let port: u16 = baris.next()
.ok_or("Port line not found")?
.trim()
.parse()?; // ParseIntError
Ok((host, port))
}
fn main() -> Result<(), Box<dyn Error>> {
let (host, port) = baca_konfigurasi("server.conf")?;
println!("Server: {}:{}", host, port);
Ok(())
}
For libraries that others will use, define a custom error type so callers can handle different error cases separately. Box<dyn Error> is better suited for binaries or prototypes where the error type details don’t matter to the caller.Option<T> — Absence of a Value Isn’t an Error
#
Option is used when the absence of a value is a normal condition rather than a sign of a problem — unlike Result, which carries information about what went wrong:
fn cari_pengguna(id: u32) -> Option<String> {
match id {
1 => Some(String::from("Budi")),
2 => Some(String::from("Sari")),
_ => None, // not found — not an error, just absent
}
}
fn ambil_pertama(v: &[i32]) -> Option<&i32> {
v.first() // None if empty, not an error
}
fn main() {
// Various ways to handle Option
let nama = cari_pengguna(1);
// match — the most explicit
match nama {
Some(n) => println!("Found: {}", n),
None => println!("Not found"),
}
// unwrap_or
let nama = cari_pengguna(99).unwrap_or(String::from("Anonim"));
println!("{}", nama);
// map — transform if Some
let panjang = cari_pengguna(2).map(|n| n.len());
println!("{:?}", panjang); // Some(4)
// and_then — chain Option operations
let hasil = cari_pengguna(1)
.and_then(|nama| if nama.len() > 3 { Some(nama) } else { None });
println!("{:?}", hasil);
// ? also works in functions returning Option
fn panjang_nama(id: u32) -> Option<usize> {
let nama = cari_pengguna(id)?; // return None if absent
Some(nama.len())
}
println!("{:?}", panjang_nama(1)); // Some(4)
println!("{:?}", panjang_nama(99)); // None
}
Converting Between Option and Result
#
fn main() {
// Option → Result with ok_or / ok_or_else
let angka: Option<i32> = Some(42);
let sebagai_result: Result<i32, &str> = angka.ok_or("no value");
println!("{:?}", sebagai_result); // Ok(42)
let none: Option<i32> = None;
let err: Result<i32, &str> = none.ok_or("value absent");
println!("{:?}", err); // Err("value absent")
// Result → Option with ok() and err()
let result: Result<i32, &str> = Ok(42);
let opt: Option<i32> = result.ok(); // discard error info
println!("{:?}", opt); // Some(42)
let result_err: Result<i32, &str> = Err("gagal");
let err_opt: Option<&str> = result_err.err(); // take the error value
println!("{:?}", err_opt); // Some("gagal")
}
panic! — Unrecoverable Errors
#
panic! stops the current thread, cleans up the stack (unwinds), and prints an error message along with a backtrace. Use it only for conditions that shouldn’t be possible in correct usage:
fn akses_aman(data: &[i32], indeks: usize) -> i32 {
// ANTI-PATTERN: panicking for an error that could be handled
// if indeks >= data.len() {
// panic!("Index {} out of bounds", indeks);
// }
// data[indeks]
// CORRECT: return an Option or Result
data.get(indeks).copied().unwrap_or_else(|| {
panic!("Bug: index {} should always be valid at this point", indeks)
})
}
// A legitimate use of panic — an invariant that must not be violated
fn buat_koneksi_pool(ukuran: usize) {
assert!(ukuran > 0, "Pool size must be greater than 0, got: {}", ukuran);
assert!(ukuran <= 100, "Pool size max is 100, got: {}", ukuran);
// ... continue initialization
}
fn main() {
// panic! directly
// panic!("This is an unrecoverable error");
// assert! — panics if the condition is false
let x = 5;
assert!(x > 0, "x must be positive");
assert_eq!(x, 5, "x must be 5");
assert_ne!(x, 0, "x must not be zero");
// unreachable! — marks code that should never be reached
let nilai = 2;
match nilai {
1 => println!("one"),
2 => println!("two"),
3 => println!("three"),
_ => unreachable!("nilai can only be 1, 2, or 3"),
}
// todo! and unimplemented! — placeholders during development
// todo!("Implement this function later");
// unimplemented!("Not implemented yet");
}
panic! vs Result — When to Choose
#
Use panic! if:
✓ The condition is a bug that shouldn't be possible
✓ An invariant guaranteed by the API contract is violated
✓ Initialization fails and the program can't continue at all
✓ Prototype/test code (use .unwrap() and .expect())
Use Result if:
✓ The error is a reasonable possibility in normal use
✓ File not found, connection timeout, invalid input
✓ The caller needs to know what went wrong and might be able to handle it
✓ Library code — don't force library users to panic
Idiomatic Error Handling Patterns #
Chaining Operations with ?
#
use std::io;
use std::fs;
#[derive(Debug)]
struct Konfigurasi {
host: String,
port: u16,
debug: bool,
}
fn muat_konfigurasi(path: &str) -> Result<Konfigurasi, Box<dyn std::error::Error>> {
let isi = fs::read_to_string(path)?;
let mut lines = isi.lines();
let host = lines.next()
.ok_or("Field 'host' not found")?
.trim()
.to_string();
let port = lines.next()
.ok_or("Field 'port' not found")?
.trim()
.parse::<u16>()?;
let debug = lines.next()
.ok_or("Field 'debug' not found")?
.trim()
.parse::<bool>()?;
Ok(Konfigurasi { host, port, debug })
}
Collecting Vec<Result> into Result<Vec>
#
fn main() {
let input = vec!["1", "2", "3", "4", "5"];
// Collect everything into a Result — fails at the first error element
let angka: Result<Vec<i32>, _> = input.iter()
.map(|s| s.parse::<i32>())
.collect();
println!("{:?}", angka); // Ok([1, 2, 3, 4, 5])
let input_rusak = vec!["1", "dua", "3"];
let gagal: Result<Vec<i32>, _> = input_rusak.iter()
.map(|s| s.parse::<i32>())
.collect();
println!("{:?}", gagal); // Err(ParseIntError)
// Skip the errors — only take the successes
let hanya_valid: Vec<i32> = input_rusak.iter()
.filter_map(|s| s.parse::<i32>().ok())
.collect();
println!("{:?}", hanya_valid); // [1, 3]
}
The map + and_then Pipeline Pattern
#
fn validasi_usia(input: &str) -> Result<u8, String> {
input.trim()
.parse::<u8>()
.map_err(|_| format!("'{}' is not a valid number", input))
.and_then(|usia| {
if usia >= 18 {
Ok(usia)
} else {
Err(format!("Age {} is below the minimum of 18", usia))
}
})
}
fn main() {
println!("{:?}", validasi_usia("25")); // Ok(25)
println!("{:?}", validasi_usia("15")); // Err("Age 15 is below the minimum of 18")
println!("{:?}", validasi_usia("abc")); // Err("'abc' is not a valid number")
println!("{:?}", validasi_usia("999")); // Err("'999' is not a valid number") — u8 overflow
}
Summary #
- Rust has no exceptions — instead, all recoverable errors are represented as
Result<T, E>values in function return types. The compiler forces callers to handle them.Resultfor reasonable errors,panic!for bugs — file not found →Result; accessing a supposedly valid index that turns out out of bounds →panic!.- The
?operator propagates errors automatically — extractsOkor immediately returnsErrto the caller. Can be used in functions returningResultorOption.?inmain()is possible — returnResult<(), E>frommainto use?directly withoutunwrap.- Custom error types via enums — define
enum AppErrorwith one variant per category, implementDisplay,Error, andFromfor automatic conversion via?.Box<dyn Error>for prototyping — accepts all error types, useful in binaries and scripts but less informative for libraries.- The
Fromtrait enables automatic conversion — ifimpl From<io::Error> for AppErrorexists, thenio::Errorconverts automatically when using?.Optionisn’t a subset ofResult— they have different roles:Optionfor “a value may be absent”,Resultfor “an operation may fail with error info”. Convert between them via.ok(),.ok_or(), and.ok_or_else().collect::<Result<Vec<_>, _>>()— collects an iterator ofResultintoResult<Vec>, failing at the firstErrelement. Or usefilter_mapto continue and skip errors.