Options & Results #
Other languages have two classic ways of handling failures and missing values: sentinel values like null or -1, and exceptions that can be thrown at any time from any function without being written in the signature. Both have the same problem — the compiler can’t force the programmer to deal with it. NullPointerException in Java and nil pointer dereference in Go are proof that this approach is simply not enough. Rust chooses a different path: the absence of values and the possibility of failure are part of the type system, not exceptions. Option<T> represents a value that may or may not exist. Result<T, E> represents an operation that may succeed or fail. Both must be handled explicitly — otherwise the code will not compile.
Why Option and Result, Not Null and Exception #
Before looking at how to use both, it’s important to understand why Rust designed them as regular types, rather than as language-specific mechanisms.
When a function returns Option<String>, its signature explicitly communicates: “this function may not return a value.” Callers cannot ignore this possibility — the compiler forces them to handle the None case. Different from languages that return String but can return null silently.
When a function returns Result<Data, IoError>, its signature communicates two things at once: the success value type (Data) and the possible error type (IoError). There are no hidden errors that can arise from functions that don’t include them in the signature.
// Other languages: functions that can return null or throw an exception
// String searchUser(int id) ← can be null, can be throw, not clear from signature
// Rust: signatures accurately reflect behavior
fn cari_pengguna(id: u32) -> Option<String> { ... } // probably doesn't exist
fn baca_file(path: &str) -> Result<String, io::Error> { ... } // may fail
flowchart TD
A[Fungsi dipanggil] --> B{Operasi berhasil?}
B -- Ya, ada nilai --> C["Option::Some(T)"]
B -- Tidak ada nilai --> D[Option::None]
B -- Ya, sukses --> E["Result::Ok(T)"]
B -- Gagal dengan error --> F["Result::Err(E)"]
C --> G[Compiler paksa tangani kedua kasus]
D --> G
E --> H[Compiler paksa tangani kedua kasus]
F --> H
style C fill:#e8f5e9
style D fill:#ffebee
style E fill:#e8f5e9
style F fill:#ffebeeOption<T> — Possible Missing Value #
Option<T> is an enum with two variants: Some(T) when the value is present, and None when it is not present. Use Option when the absence of a value is a normal condition that is not an error — for example searching for an element in a collection, retrieving the first element of a slice, or an optional field in a struct.
fn main() {
// Create Option
let ada: Option<i32> = Some(42);
let tidak_ada: Option<i32> = None;
// Collection returns Option when the element may not exist
let angka = vec![1, 2, 3, 4, 5];
let pertama: Option<&i32> = angka.first(); // Some(&1)
let dari_indeks: Option<&i32> = angka.get(10); // None — index out of bounds
let dicari: Option<&i32> = angka.iter().find(|&&x| x > 3); // Some(&4)
// HashMap returns Option when key does not exist
use std::collections::HashMap;
let mut skor: HashMap<&str, i32> = HashMap::new();
skor.insert("Alice", 100);
let skor_alice: Option<&i32> = skor.get("Alice"); // Some(&100)
let skor_bob: Option<&i32> = skor.get("Bob"); // None
}
Pattern Matching in Options #
match is the most explicit and flexible way to handle Option. Use it when you need different logic for Some and None, or when you need bindings to values in Some.
fn deskripsi_skor(skor: Option<i32>) -> String {
match skor {
Some(s) if s >= 90 => format!("Sangat baik: {}", s),
Some(s) if s >= 70 => format!("Baik: {}", s),
Some(s) => format!("Perlu ditingkatkan: {}", s),
None => String::from("Belum ada skor"),
}
}
fn main() {
println!("{}", deskripsi_skor(Some(95))); // "Excellent: 95"
println!("{}", deskripsi_skor(Some(75))); // "Fine: 75"
println!("{}", deskripsi_skor(None)); // "No score yet"
// if let — more concise when only needing to handle Some
let nilai = Some(42);
if let Some(v) = nilai {
println!("Nilai: {}", v);
}
// while let — iterate until None
let mut stack = vec![1, 2, 3];
while let Some(top) = stack.pop() {
println!("Popped: {}", top);
}
}
Method in Option #
Option has many methods that allow transformation and handling without explicit match. This makes the code more concise and composable.
fn main() {
let ada: Option<i32> = Some(10);
let tidak_ada: Option<i32> = None;
// unwrap — take value, panic if None
// ANTI-PATTERN: unwrap in production code without being sure it is not None
let nilai = ada.unwrap(); // 10, but panic if it's not there.unwrap()
// unwrap_or — default value if None
let a = ada.unwrap_or(0); // 10
let b = tidak_ada.unwrap_or(0); // 0
// unwrap_or_else — default of closure (lazy evaluation)
let c = tidak_ada.unwrap_or_else(|| {
println!("Menghitung default...");
42
});
// unwrap_or_default — default of the Default trait
let d: i32 = tidak_ada.unwrap_or_default(); // 0 (default i32)
// map — transforms values in Some, None to remain None
let doubled = ada.map(|v| v * 2); // Some(20)
let doubled_none = tidak_ada.map(|v| v * 2); // None
// map_or — map with default value if None
let e = ada.map_or(0, |v| v * 2); // 20
let f = tidak_ada.map_or(0, |v| v * 2); // 0
// and_then — chaining operations that each return an Option
let hasil = ada
.and_then(|v| if v > 5 { Some(v * 2) } else { None })
.and_then(|v| if v < 100 { Some(v) } else { None });
println!("{:?}", hasil); // Some(20)
// filter — returns None if the value does not satisfy the condition
let genap = ada.filter(|v| v % 2 == 0); // Some(10)
let ganjil = ada.filter(|v| v % 2 != 0); // None
// or and or_else — fallback to another Option if None
let g = tidak_ada.or(Some(99)); // Some(99)
let h = ada.or(Some(99)); // Some(10) — there is no change
// is_some and is_none — checking without consuming
println!("{}", ada.is_some()); // true
println!("{}", tidak_ada.is_none()); // true
// as_ref — borrow content without consuming Option
let s: Option<String> = Some(String::from("halo"));
let panjang = s.as_ref().map(|v| v.len()); // s are still valid after this
println!("{:?}", s); // Some("hello") — does not move
}
flowchart LR
A[Option<T>] --> B["map(f)"]
A --> C["and_then(f)"]
A --> D["filter(pred)"]
A --> E["or(opt)"]
B --> B1[Option<U> — value transformation]
C --> C1[Option<U> — chaining Option-returning fn]
D --> D1[Option<T> — None if condition is not met]
E --> E1[Option<T> — fallback if None]
style A fill:#e3f2fd
style B1 fill:#e8f5e9
style C1 fill:#e8f5e9
style D1 fill:#e8f5e9
style E1 fill:#e8f5e9Result<T, E> — Operation that could fail #
Result<T, E> is an enum with two variants: Ok(T) when the operation succeeds, and Err(E) when it fails with an error of type E. Use Result when failure is something that needs to be communicated and handled — I/O operations, parsing, network requests, input validation.
use std::num::ParseIntError;
use std::io;
// Functions that can fail return a Result
fn parse_angka_positif(s: &str) -> Result<u32, String> {
let angka: i64 = s.trim().parse().map_err(|_| format!("'{}' bukan angka valid", s))?;
if angka < 0 {
return Err(format!("Angka harus positif, dapat: {}", angka));
}
Ok(angka as u32)
}
fn main() {
// Create Result manually
let sukses: Result<i32, String> = Ok(42);
let gagal: Result<i32, String> = Err(String::from("sesuatu gagal"));
// Standard library operation that returns Result
let parsed: Result<i32, ParseIntError> = "42".parse();
let file_content: Result<String, io::Error> = std::fs::read_to_string("file.txt");
// Pattern matching
match parse_angka_positif("123") {
Ok(n) => println!("Berhasil: {}", n),
Err(e) => println!("Gagal: {}", e),
}
match parse_angka_positif("abc") {
Ok(n) => println!("Berhasil: {}", n),
Err(e) => println!("Gagal: {}", e), // "Failed: 'abc' is not a valid number"
}
}
Method on Result #
Result has a method that is very similar to Option, plus a special method for handling errors.
fn main() {
let ok: Result<i32, String> = Ok(10);
let err: Result<i32, String> = Err(String::from("error"));
// unwrap and expect — panic when Err
// ANTI-PATTERN: unwrap without context in production code
let nilai = ok.unwrap(); // 10
// TRUE: expect returns an informative error message
let nilai = ok.expect("Seharusnya tidak gagal di tahap ini");
// unwrap_or, unwrap_or_else, unwrap_or_default
let a = err.unwrap_or(0);
let b = err.unwrap_or_else(|e| {
eprintln!("Error ditangani: {}", e);
-1
});
// map — transformation Ok, Err is still Err
let doubled = ok.map(|v| v * 2); // Ok(20)
let doubled_err = err.map(|v| v * 2); // Err("error")
// map_err — transformation Err, Ok remains Ok
let konversi = err.map_err(|e| format!("Wrapped: {}", e));
// Err("Wrapped: error")
// and_then — chaining of Result-returning operations
let hasil = ok
.and_then(|v| if v > 5 { Ok(v * 2) } else { Err(String::from("terlalu kecil")) })
.and_then(|v| Ok(v + 1));
println!("{:?}", hasil); // Ok(21)
// or and or_else — fallback Result if Err
let fallback = err.or(Ok(99)); // Ok(99)
let fallback2 = err.or_else(|e| {
println!("Mencoba recovery dari: {}", e);
Ok(0)
});
// is_ok and is_err
println!("{}", ok.is_ok()); // true
println!("{}", err.is_err()); // true
// ok() — convert Result to Option (Err to None)
let sebagai_option: Option<i32> = ok.ok(); // Some(10)
let err_option: Option<i32> = err.ok(); // None
// err() — takes Err value as Option
let error_value: Option<String> = err.err(); // Some("error")
}
Operator ? — Elegant Error Propagation #
The ? operator is a feature that makes error handling in Rust feel ergonomic. When installed after the expression Result, it does two things: if it is Ok, it extracts the value within it. If it’s Err, it immediately returns Err from the running function. This eliminates the need for repeated match for each operation that could fail.
use std::io;
use std::fs;
use std::num::ParseIntError;
// ANTI-PATTERN: match repeats for every operation — verbose and disrupts the flow
fn baca_angka_dari_file_verbose(path: &str) -> Result<i32, String> {
let konten = match fs::read_to_string(path) {
Ok(s) => s,
Err(e) => return Err(e.to_string()),
};
let angka = match konten.trim().parse::<i32>() {
Ok(n) => n,
Err(e) => return Err(e.to_string()),
};
Ok(angka * 2)
}
// CORRECT: operator ? keeps the main flow readable
fn baca_angka_dari_file(path: &str) -> Result<i32, String> {
let konten = fs::read_to_string(path).map_err(|e| e.to_string())?;
let angka = konten.trim().parse::<i32>().map_err(|e| e.to_string())?;
Ok(angka * 2)
}
? also works on Option — if it’s None, the function immediately returns None:
fn nama_depan(nama_lengkap: &str) -> Option<&str> {
let bagian: Vec<&str> = nama_lengkap.split_whitespace().collect();
let pertama = bagian.first()?; // None if the string is empty
Some(pertama)
}
fn inisial(nama_lengkap: &str) -> Option<char> {
let depan = nama_depan(nama_lengkap)?; // propagation None
depan.chars().next() // first character
}
The?operator can only be used in functions that returnResultorOption. Using it on a regularmain()will cause a compile error. The solution: change the signaturemaintofn main() -> Result<(), Box<dyn Error>>, or handle the error explicitly inmain.
use std::error::Error;
use std::fs;
// main who can use ?
fn main() -> Result<(), Box<dyn Error>> {
let konten = fs::read_to_string("config.txt")?;
let nilai: i32 = konten.trim().parse()?;
println!("Nilai dari config: {}", nilai);
Ok(())
}
Chaining — Composition Without Nested Match #
One of the strengths of Option and Result is its functional composability. Instead of nested match which makes the code feel like a pyramid, you can chain operations linearly.
use std::collections::HashMap;
fn cari_email_pengguna(db: &HashMap<u32, HashMap<&str, &str>>, id: u32) -> Option<String> {
// ANTI-PATTERN: nested match — hard to read and maintain
match db.get(&id) {
Some(profil) => {
match profil.get("email") {
Some(email) => {
if email.contains('@') {
Some(email.to_uppercase())
} else {
None
}
}
None => None,
}
}
None => None,
}
}
// TRUE: linear, easy-to-read chaining
fn cari_email_pengguna(db: &HashMap<u32, HashMap<&str, &str>>, id: u32) -> Option<String> {
db.get(&id)
.and_then(|profil| profil.get("email"))
.filter(|email| email.contains('@'))
.map(|email| email.to_uppercase())
}
fn main() {
let mut db = HashMap::new();
let mut profil = HashMap::new();
profil.insert("email", "[email protected]");
profil.insert("nama", "Budi");
db.insert(1u32, profil);
println!("{:?}", cari_email_pengguna(&db, 1)); // Some("[email protected]")
println!("{:?}", cari_email_pengguna(&db, 99)); // None — id does not exist
}
The same pattern applies to Result:
use std::num::ParseIntError;
fn hitung_dari_input(a: &str, b: &str) -> Result<i32, String> {
// Chaining Result — if one fails, the entire chain fails
let x: i32 = a.trim()
.parse()
.map_err(|_: ParseIntError| format!("'{}' bukan angka valid", a))?;
let y: i32 = b.trim()
.parse()
.map_err(|_: ParseIntError| format!("'{}' bukan angka valid", b))?;
if y == 0 {
return Err(String::from("Pembagi tidak boleh nol"));
}
Ok(x / y)
}
fn main() {
println!("{:?}", hitung_dari_input("10", "2")); // Ok(5)
println!("{:?}", hitung_dari_input("10", "0")); // Err("Divisor cannot be zero")
println!("{:?}", hitung_dari_input("abc", "2")); // Err("'abc' is not a valid number")
}
Conversion Between Option and Result #
In real code, you often need to convert between Option and Result — for example when a library function returns Option but you’re working in a context that expects Result.
fn main() {
// Option → Result with ok_or and ok_or_else
let ada: Option<i32> = Some(42);
let tidak_ada: Option<i32> = None;
let r1: Result<i32, &str> = ada.ok_or("nilai tidak ada"); // Ok(42)
let r2: Result<i32, &str> = tidak_ada.ok_or("nilai tidak ada"); // Err("no value")
// ok_or_else — lazy, errors are generated only when necessary
let r3: Result<i32, String> = tidak_ada.ok_or_else(|| {
format!("Nilai tidak ditemukan pada waktu {}", chrono_waktu())
});
// Result → Option with ok() and err()
let ok: Result<i32, &str> = Ok(42);
let err: Result<i32, &str> = Err("gagal");
let opt1: Option<i32> = ok.ok(); // Some(42)
let opt2: Option<i32> = err.ok(); // None — error thrown
// Real scenario: search in HashMap, then process the results
use std::collections::HashMap;
let konfigurasi: HashMap<&str, &str> = [
("port", "8080"),
("timeout", "30"),
].into_iter().collect();
fn ambil_port(config: &HashMap<&str, &str>) -> Result<u16, String> {
config
.get("port")
.ok_or_else(|| String::from("Konfigurasi 'port' tidak ditemukan"))?
.parse::<u16>()
.map_err(|e| format!("Port tidak valid: {}", e))
}
match ambil_port(&konfigurasi) {
Ok(port) => println!("Server berjalan di port {}", port),
Err(e) => eprintln!("Error konfigurasi: {}", e),
}
}
fn chrono_waktu() -> String { String::from("sekarang") }
flowchart TD
A[Option<T>] -- "ok_or(e)" --> B[Result<T, E>]
A -- "ok_or_else(|| e)" --> B
B -- "ok()" --> C[Option<T>]
B -- "err()" --> D[Option<E>]
C -- "ok_or(...)" --> B
style A fill:#e8f5e9
style B fill:#e3f2fd
style C fill:#e8f5e9
style D fill:#fff3e0Custom Error Type #
For larger applications, you need to define your own error types that represent all possible failures in your application domain.
use std::fmt;
use std::num::ParseIntError;
use std::io;
// Custom error definition with enum
#[derive(Debug)]
enum AppError {
Io(io::Error),
Parse(ParseIntError),
Validasi(String),
TidakDitemukan { resource: String, id: u32 },
}
// Display implementation for user friendly messages
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
AppError::Io(e) => write!(f, "Error I/O: {}", e),
AppError::Parse(e) => write!(f, "Error parsing: {}", e),
AppError::Validasi(msg) => write!(f, "Error validasi: {}", msg),
AppError::TidakDitemukan { resource, id } => {
write!(f, "{} dengan id {} tidak ditemukan", resource, id)
}
}
}
}
// Implementation of From for automatic conversion with operator ?
impl From<io::Error> for AppError {
fn from(e: io::Error) -> Self {
AppError::Io(e)
}
}
impl From<ParseIntError> for AppError {
fn from(e: ParseIntError) -> Self {
AppError::Parse(e)
}
}
// With the From implementation, the ? can convert errors automatically
fn proses_konfigurasi(path: &str) -> Result<u32, AppError> {
let konten = std::fs::read_to_string(path)?; // io::Error → AppError::Io auto
let nilai: u32 = konten.trim().parse()?; // ParseIntError → AppError::Auto parse
if nilai == 0 {
return Err(AppError::Validasi(String::from("Nilai tidak boleh nol")));
}
Ok(nilai)
}
fn main() {
match proses_konfigurasi("config.txt") {
Ok(nilai) => println!("Konfigurasi berhasil: {}", nilai),
Err(AppError::Io(e)) => eprintln!("Gagal baca file: {}", e),
Err(AppError::Parse(e)) => eprintln!("Format tidak valid: {}", e),
Err(AppError::Validasi(msg)) => eprintln!("Validasi gagal: {}", msg),
Err(e) => eprintln!("Error lain: {}", e),
}
}
Using thiserror for Less Boilerplate #
Crate thiserror simplifies creating custom error types with derive macros:
# Cargo.toml
[dependencies]
thiserror = "1"
use thiserror::Error;
#[derive(Debug, Error)]
enum AppError {
#[error("Error I/O: {0}")]
Io(#[from] std::io::Error),
#[error("Error parsing: {0}")]
Parse(#[from] std::num::ParseIntError),
#[error("Error validasi: {0}")]
Validasi(String),
#[error("{resource} dengan id {id} tidak ditemukan")]
TidakDitemukan { resource: String, id: u32 },
}
// The From implementation is generated automatically by #[from]
// Display generated from attribute #[error("...")]
// The process_configuration code above can be used directly without changes
Using anyhow for Rapid Prototyping #
Crate anyhow is useful when you don’t care about specific error types — for example in binaries (not libraries) or when prototyping:
[dependencies]
anyhow = "1"
use anyhow::{Context, Result, bail, ensure};
fn baca_dan_proses(path: &str) -> Result<i32> {
// Context adds a context message to the existing error
let konten = std::fs::read_to_string(path)
.with_context(|| format!("Gagal membaca file '{}'", path))?;
let nilai: i32 = konten.trim().parse()
.context("Isi file bukan angka yang valid")?;
// bail! — shorthand for return Err(anyhow!(...))
if nilai < 0 {
bail!("Nilai harus positif, dapat: {}", nilai);
}
// ensure! — assert which returns Err if the condition is false
ensure!(nilai < 1000, "Nilai {} melebihi batas maksimum 1000", nilai);
Ok(nilai * 2)
}
fn main() -> Result<()> {
let hasil = baca_dan_proses("input.txt")?;
println!("Hasil: {}", hasil);
Ok(())
}
Usethiserrorfor libraries — an explicit error type that allows library users to perform pattern matching. Useanyhowfor binary or application code — it’s more ergonomic and doesn’t require detailed error enum definitions.
Collecting Results from Iterators #
When processing collections and individual items can fail, Rust provides an idiomatic way to collect the results.
fn main() {
let input = vec!["1", "2", "3", "4", "5"];
let salah = vec!["1", "dua", "3", "empat", "5"];
// collect() to Result<Vec<T>, E> — fails if one item is Err
let semua_ok: Result<Vec<i32>, _> = input.iter()
.map(|s| s.parse::<i32>())
.collect();
println!("{:?}", semua_ok); // Ok([1, 2, 3, 4, 5])
let ada_error: Result<Vec<i32>, _> = salah.iter()
.map(|s| s.parse::<i32>())
.collect();
println!("{:?}", ada_error); // Err(ParseIntError { ... })
// Separate Ok and Err — processes that are valid, ignore those that are not
let (berhasil, gagal): (Vec<_>, Vec<_>) = salah.iter()
.map(|s| s.parse::<i32>())
.partition(Result::is_ok);
let nilai: Vec<i32> = berhasil.into_iter().map(|r| r.unwrap()).collect();
let error: Vec<_> = gagal.into_iter().map(|r| r.unwrap_err()).collect();
println!("Berhasil: {:?}", nilai); // [1, 3, 5]
println!("Gagal: {:?}", error); // [ParseIntError, ParseIntError]
// filter_map — silently ignore None/Err
let hanya_valid: Vec<i32> = salah.iter()
.filter_map(|s| s.parse::<i32>().ok())
.collect();
println!("Hanya valid: {:?}", hanya_valid); // [1, 3, 5]
}
When to Use Option vs Result #
Choosing between Option and Result isn’t just a matter of preference — there are semantic differences to understand.
Gunakan Option jika:
✓ Ketiadaan nilai adalah kondisi normal, bukan error
✓ Operasi pencarian yang mungkin tidak menemukan hasil
✓ Field opsional dalam struct
✓ Iterator yang bisa habis
✓ Nilai yang belum diinisialisasi
Gunakan Result jika:
✗ Kegagalan terjadi karena kondisi eksternal (I/O, network, parsing)
✗ Caller perlu tahu MENGAPA gagal, bukan hanya bahwa gagal
✗ Kegagalan adalah pengecualian dari alur normal
✗ Error perlu dilaporkan atau di-log
✗ Operasi yang membutuhkan resource dan bisa ditolak sistem
Examples that are often confusing:
// Searching HashMap — use Option
// There is nothing "wrong" if the key is not there
fn cari_pengguna(db: &HashMap<u32, String>, id: u32) -> Option<&String> {
db.get(&id)
}
// Read users from database — use Result
// Can fail due to database connection, query error, etc.
fn ambil_pengguna(id: u32) -> Result<Pengguna, DbError> {
// database connections can fail, queries can fail
db.query("SELECT * FROM users WHERE id = ?", [id])
}
// Parse optional values from configuration
// Option if the field is optional, Result if the format must be valid
fn port_konfigurasi(config: &HashMap<&str, &str>) -> Result<Option<u16>, String> {
match config.get("port") {
None => Ok(None), // no port = use default
Some(s) => s.parse::<u16>()
.map(Some)
.map_err(|_| format!("Port '{}' tidak valid", s)),
}
}
Summary #
Option<T>for absence,Result<T, E>for failure —Optionwhen absence of value is a normal condition;Resultwhen a failure occurs due to an external condition that needs to be reported.- Avoid
unwrap()in production codes — useunwrap_or,unwrap_or_else, or?for safe handling. Saveunwrap()for prototyping or testing.?operator for error propagation — much cleaner than repeatedmatch. Make sure the function returns a compatibleResultorOption, and implementFromfor automatic error type conversion.mapandand_thenfor chaining —mapchanges the value inside without changingSome/OkvsNone/Err;and_thenfor operations that themselves returnOptionorResult.ok_orandok_or_elsefor Option → Result conversion — useful when working in contexts that expectResultbut the library function returnsOption.- Custom error type with
thiserror— for libraries, define an error enum with explicit variants to allow users to perform pattern matching. Use#[from]for automatic conversion.anyhowfor binary and application code — ergonomic, does not require detailed error type definitions, and supports rich error context via.context().collect::<Result<Vec<_>, _>>()— idiomatic way to process collections where individual items can fail. Fail fast when an error occurs, or usefilter_mapto silently ignore errors.