Result y Option: manejo de errores al estilo Rust
1. Resumen general
Rust no tiene excepciones como Java o C#. En su lugar, Rust utiliza dos tipos de datos Option<T> y Result<T, E> para representar “puede no haber valor” y “puede fallar”. Este artículo te ayuda a entender cómo usar estos dos tipos correctamente, cómo propagar errores de forma limpia con el operador ?, y cómo definir errores personalizados con thiserror. Después de leerlo, podrás escribir código Rust que maneje errores de forma clara, sin necesidad de try/catch.
2. ¿Por qué no usar excepciones?
En Java, un método puede lanzar RuntimeException en cualquier momento sin que la firma lo indique. Tienes que leer la Javadoc o el código para saber qué puede lanzar.
Rust te obliga a declarar la posibilidad de fallo directamente en el tipo de retorno:
fn divide(a: i32, b: i32) -> Result<i32, String> {
if b == 0 {
return Err("división por cero".to_string());
}
Ok(a / b)
}
Al ver la firma Result<i32, String> sabes inmediatamente que esta función puede fallar. El compilador te obliga a manejar ambas ramas Ok y Err, no puedes ignorar silenciosamente el error como si olvidaras un catch en Java.
Nota: Rust aún tiene
panic!para errores irrecuperables (bugs de programación, invariantes violados).Resultes para errores que puedes predecir y manejar.
3. Option: valores que pueden estar ausentes
Option<T> reemplaza a null. Tiene dos variantes: Some(T) y None.
fn find_customer(id: u32) -> Option<String> {
let customers = vec![(1, "Alice"), (2, "Bob")];
customers.iter()
.find(|(cid, _)| *cid == id)
.map(|(_, name)| name.to_string())
}
fn main() {
match find_customer(2) {
Some(name) => println!("Encontrado: {name}"),
None => println!("No existe este cliente"),
}
}
find devuelve Option<&(u32, &str)>, map transforma el valor dentro de Some si lo hay, lo ignora si es None. El resultado:
Encontrado: Bob
3.1. Métodos comúnmente usados
let maybe_age: Option<u32> = Some(25);
let age = maybe_age.unwrap_or(18); // 25, o 18 si None
let doubled = maybe_age.map(|a| a * 2); // Some(50)
let result: Result<u32, &str> = maybe_age.ok_or("edad insuficiente"); // Ok(25)
unwrap_or(default): obtiene el valor o devuelve el default, nunca entra en pánico.map(f): transforma el valor dentro si lo hay, mantieneNonesi no.ok_or(err): convierteOption<T>aResult<T, E>— útil cuando necesitas usar el operador?después.
Nota: Evita llamar a
.unwrap()directamente enOption/Resulten código de producción. Entra en pánico cuando encuentraNone/Err, equivalente a lanzarNullPointerExceptionsin control. Úsalo solo en tests o cuando estés 100% seguro de que el valor existe.
4. Result<T, E>: éxito o fracaso
Result<T, E> tiene dos variantes: Ok(T) para el éxito, Err(E) para el fracaso.
use std::num::ParseIntError;
fn parse_quantity(input: &str) -> Result<u32, ParseIntError> {
input.trim().parse::<u32>()
}
fn main() {
match parse_quantity(" 42 ") {
Ok(qty) => println!("Cantidad: {qty}"),
Err(e) => println!("Error al parsear: {e}"),
}
}
parse::<u32>() devuelve Result<u32, ParseIntError>. match te obliga a manejar ambas ramas — el compilador se rehúsa a compilar si falta alguna.
Cantidad: 42
4.1. El operador ? para propagar errores
Escribir match en todas partes es muy verboso. El operador ? ayuda a propagar el error inmediatamente si encuentras Err, similar a un throw automático:
use std::num::ParseIntError;
struct Order {
quantity: u32,
price: u32,
}
fn parse_order(qty_str: &str, price_str: &str) -> Result<Order, ParseIntError> {
let quantity = qty_str.trim().parse::<u32>()?;
let price = price_str.trim().parse::<u32>()?;
Ok(Order { quantity, price })
}
fn main() {
match parse_order("3", "15000") {
Ok(order) => println!("Total: {}", order.quantity * order.price),
Err(e) => println!("Datos inválidos: {e}"),
}
}
La línea qty_str.trim().parse::<u32>()? significa: si parse devuelve Err, la función parse_order devuelve ese Err al llamador de inmediato, sin necesidad de escribir match manualmente. Si es Ok, el valor adentro se “desempaqueta” y se asigna a quantity.
Total: 45000
Nota: El operador
?solo funciona cuando el tipo de error de la función actual y la función llamada son compatibles (o existe una implementación deFrompara convertir). Si no, el compilador reporta un error de type mismatch en tiempo de compilación.
5. Combinando Option y Result
Estos dos tipos a menudo se entrelazan. Por ejemplo, buscar un producto en el almacén y luego verificar si el stock es suficiente:
struct Product {
name: String,
stock: u32,
}
fn find_product(name: &str, products: &[Product]) -> Option<&Product> {
products.iter().find(|p| p.name == name)
}
fn check_stock(name: &str, requested: u32, products: &[Product]) -> Result<(), String> {
let product = find_product(name, products)
.ok_or_else(|| format!("Producto no encontrado: {name}"))?;
if product.stock < requested {
return Err(format!("Solo quedan {} productos", product.stock));
}
Ok(())
}
ok_or_else convierte Option<&Product> a Result<&Product, String> con un mensaje de error, y luego ? propaga si es None. Esta forma de escribir evita match anidados profundamente.
6. Definición de errores personalizados con thiserror
Usar String como tipo de error es conveniente para ejemplos pequeños, pero proyectos reales necesitan enums de error bien estructurados. El crate thiserror hace esto de forma limpia:
<!-- Cargo.toml equivalente, usando la sección dependencies -->
# Cargo.toml
[dependencies]
thiserror = "1.0"
use thiserror::Error;
#[derive(Error, Debug)]
enum OrderError {
#[error("producto no existe: {0}")]
ProductNotFound(String),
#[error("stock insuficiente, quedan {available} productos")]
InsufficientStock { available: u32 },
}
fn validate_order(stock: Option<u32>, requested: u32) -> Result<(), OrderError> {
let available = stock.ok_or_else(|| OrderError::ProductNotFound("Laptop".into()))?;
if available < requested {
return Err(OrderError::InsufficientStock { available });
}
Ok(())
}
#[derive(Error, Debug)] genera automáticamente la implementación del trait std::error::Error. #[error("...")] define el mensaje que se muestra para cada variante, soportando placeholders como {0} (posicional) o {available} (nombre de campo). El resultado cuando imprimes el error:
Error: InsufficientStock { available: 2 }
Si usas {} en lugar de {:?} al imprimir, obtienes un mensaje amigable: stock insuficiente, quedan 2 productos.
7. anyhow para aplicaciones, thiserror para librerías
Cuando escribes un binario/aplicación (no una librería para que otros la usen), típicamente no necesitas distinguir detalles de tipos de error — solo propagar e imprimir. El crate anyhow es apropiado para esto:
[dependencies]
anyhow = "1.0"
use anyhow::{Context, Result};
fn load_config(path: &str) -> Result<String> {
std::fs::read_to_string(path)
.with_context(|| format!("no se pudo leer el archivo de configuración: {path}"))
}
fn main() -> Result<()> {
let config = load_config("app.toml")?;
println!("Config: {config}");
Ok(())
}
anyhow::Result<T> es un alias de Result<T, anyhow::Error>, puede contener cualquier error que implemente el trait Error. with_context añade información de contexto al error original, facilitando el debug sin necesidad de definir un enum propio.
- Librería (crate que otros usarán): usa
thiserror, devuelve un enum de error específico para que el caller hagamatchy maneje cada caso. - Aplicación (binary final): usa
anyhow, solo propaga y registra, nadie necesita hacermatchdetallado.
8. Test del comportamiento de errores
Rust tiene un framework de testing integrado, no necesitas librerías externas para tests básicos.
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn devuelve_ok_cuando_hay_stock() {
let result = validate_order(Some(10), 5);
assert!(result.is_ok());
}
#[test]
fn devuelve_err_cuando_stock_insuficiente() {
let result = validate_order(Some(2), 5);
assert!(matches!(
result,
Err(OrderError::InsufficientStock { available: 2 })
));
}
#[test]
fn devuelve_err_cuando_producto_no_encontrado() {
let result = validate_order(None, 5);
assert!(matches!(result, Err(OrderError::ProductNotFound(_))));
}
}
#[test] marca una función como test, se ejecuta con cargo test. matches! es una macro que verifica si un valor coincide con un patrón dado — más conveniente que assert_eq! cuando comparas enums con campos, porque no necesitas implementar PartialEq para todo el tipo de error.
running 3 tests
test tests::devuelve_ok_cuando_hay_stock ... ok
test tests::devuelve_err_cuando_stock_insuficiente ... ok
test tests::devuelve_err_cuando_producto_no_encontrado ... ok
9. Errores comunes
- Abusar de
.unwrap(): el código funciona bien en desarrollo, pero entra en pánico inesperadamente en producción con inputs extraños. Usa?,match, ounwrap_or_elsecon manejo explícito. - Usar
Stringcomo tipo de error en una librería pública: los llamadores no pueden hacermatchpor tipo de error, solo comparar strings — frágil si cambias el mensaje. Usa un enum conthiserroren lugar deString. - Mezclar
anyhow::Erroren la API pública de una librería: oculta información del tipo de error específico, fuerza al llamador a hacerdowncastmanual.anyhowsolo debe usarse en la capa de aplicación final. - Olvidar
#[derive(Debug)]: el operador?y muchas macros de test requieren que el tipo de error implementeDebug. Sin esto, el compilador reporta un error de trait bound no satisfecho.
Nota: Si vienes de Java e intentas “emular excepciones checked”, no trates de imitar try/catch con macros. Acostúmbrate al concepto: un error es un valor que se devuelve y se pasa como cualquier otro dato.
10. Conclusión
Option<T> y Result<T, E> convierten el manejo de “ausencia” y “fracaso” en parte del type system, verificado por el compilador en tiempo de compilación en lugar de runtime. El operador ? ayuda a mantener el código conciso y explícito. Para proyectos pequeños, String o anyhow son suficientes; para librerías compartidas, invierte en enums de error claros con thiserror para que los llamadores puedan manejar cada caso de forma segura.