Skip to content

Error Handling (Deep Dive)

TypeScript errors are invisible at the type level — any function can throw anything at any time, and the compiler does not force you to handle it. Rust encodes fallibility directly in the return type: a function that can fail returns Result<T, E>, where T is the success value and E is the error. If you ignore the Result, the compiler warns you.

TypeScript
// TypeScript — errors are invisible in the signature
function parseId(s: string): number {
const n = parseInt(s, 10);
if (isNaN(n)) throw new Error(`Invalid id: ${s}`);
if (n === 0) throw new Error("id zero not allowed");
return n;
}
try {
console.log(parseId("42")); // 42
console.log(parseId("abc")); // throws
} catch (err) {
console.error(err instanceof Error ? err.message : err);
}
Rust
use std::num::ParseIntError;
#[derive(Debug)]
enum AppError {
Parse(ParseIntError),
NotFound(String),
}
impl std::fmt::Display for AppError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
AppError::Parse(e) => write!(f, "parse error: {e}"),
AppError::NotFound(s) => write!(f, "not found: {s}"),
}
}
}
impl From<ParseIntError> for AppError {
fn from(e: ParseIntError) -> Self { AppError::Parse(e) }
}
fn parse_id(s: &str) -> Result<u32, AppError> {
let n: u32 = s.trim().parse()?; // ? converts ParseIntError via From
if n == 0 {
return Err(AppError::NotFound(String::from("id zero not allowed")));
}
Ok(n)
}
fn main() {
println!("{:?}", parse_id("42"));
println!("{:?}", parse_id("abc"));
println!("{:?}", parse_id("0"));
}

The ? at the end of a Result-returning expression is syntactic sugar for “if this is Err, convert the error and return early; otherwise unwrap the Ok value.” It replaces dozens of explicit match branches.

// Without ?
let n = match s.trim().parse::<u32>() {
Ok(val) => val,
Err(e) => return Err(AppError::from(e)),
};
// With ? — identical behaviour, one character
let n: u32 = s.trim().parse()?;

For ? to work across different error types, the target error type must implement From<SourceError>. The standard library and thiserror handle this automatically.

The thiserror crate eliminates the boilerplate of writing Display and From by hand. A #[derive(Error)] annotation and a #[error("...")] attribute on each variant is all you need.

TypeScript
// TypeScript — custom error class hierarchy
class AppError extends Error {
constructor(message: string) {
super(message);
this.name = "AppError";
}
}
class ParseError extends AppError {
constructor(public input: string) {
super(`Invalid input: ${input}`);
}
}
class NotFoundError extends AppError {
constructor(public id: string) {
super(`Not found: ${id}`);
}
}
Rust
use thiserror::Error;
#[derive(Error, Debug)]
enum AppError {
#[error("not found: {0}")]
NotFound(String),
#[error("parse error: {0}")]
Parse(#[from] std::num::ParseIntError),
}
fn parse_id(s: &str) -> Result<u32, AppError> {
let n: u32 = s.trim().parse()?; // ParseIntError -> AppError::Parse via #[from]
if n == 0 {
return Err(AppError::NotFound(String::from("id zero not allowed")));
}
Ok(n)
}
fn main() {
match parse_id("42") {
Ok(id) => println!("Parsed: {id}"),
Err(e) => println!("Error: {e}"),
}
match parse_id("abc") {
Ok(id) => println!("Parsed: {id}"),
Err(e) => println!("Error: {e}"),
}
match parse_id("0") {
Ok(id) => println!("Parsed: {id}"),
Err(e) => println!("Error: {e}"),
}
}

anyhow — application-level error handling

Section titled “anyhow — application-level error handling”

thiserror is for library code where callers need to inspect the error type. anyhow is for application/binary code where you just want errors to propagate cleanly with good context messages and you do not need to match on specific variants.

use anyhow::{Context, Result};
fn read_config(path: &str) -> Result<String> {
std::fs::read_to_string(path)
.with_context(|| format!("failed to read config at {path}"))
}

anyhow::Result<T> is an alias for Result<T, anyhow::Error>. Any error that implements std::error::Error can be converted into anyhow::Error with ?.

use thiserror::Error;
#[derive(Error, Debug)]
enum AppError {
#[error("not found: {0}")]
NotFound(String),
#[error("parse error: {0}")]
Parse(#[from] std::num::ParseIntError),
}
fn parse_id(s: &str) -> Result<u32, AppError> {
let n: u32 = s.trim().parse()?;
if n == 0 {
return Err(AppError::NotFound(String::from("id zero not allowed")));
}
Ok(n)
}
fn main() {
match parse_id("42") {
Ok(id) => println!("Parsed: {id}"),
Err(e) => println!("Error: {e}"),
}
match parse_id("abc") {
Ok(id) => println!("Parsed: {id}"),
Err(e) => println!("Error: {e}"),
}
match parse_id("0") {
Ok(id) => println!("Parsed: {id}"),
Err(e) => println!("Error: {e}"),
}
}
What does the `?` operator do when applied to a `Result::Err(e)`?
When should you use `thiserror` vs `anyhow`?
What must be true for `?` to convert error type `E1` into `E2` automatically?
What is the key difference between Rust's `Result`-based error handling and TypeScript's try/catch?