Rust Errors Without Dependencies

December 27th 2025

Rust error handling is a complex topic mostly due to the composability it gives you and no "blessed way" to accomplish this from the community. Popular libraries include, anyhow, thiserror, and eyre are often recommended and for good reason, they're great libraries! However, personally, I wanted to use what the standard library offered and write my errors without dependencies. This is not THE idiomatic way to write rust but rather the way that I write errors.

This is mostly for two reasons

Security

I want less code. I want to limit the amount of 3rd party code I pull in. This is mostly due to supply chain disasters over on NPM scaring me and the amount of code dependencies bringing in see rust dependencies scare me. I own the code that I bring into my repo, I belive the standard library is sufficent for my needs without having to pull in more crates.

Adaptability

Using the standard creates a universal way to handle errors that most rustaceans will be familiar with. Almost everyone uses the standard library, which itself it extremely well vetted and battle tested.

Why are errors hard in Rust

Coming from the try catch paradigm the typical way I write critical API errors is to throw one and bubble it up in middlware. Rust does this differently, the first shift that really helped me with rust errors was this video about safety critical embedded code by LaurieWired. In embedded code exceptions are typically banned. This is mostly due to timing guarantees needing to be upheld and mitigating risk of undefined behavior. By using return codes in place of exceptions we map every error with a specific code and must define what should happen when that error is reached, this is the composability that rust tries tap into. This however increases congnitive load on the programmer forcing the programmer to deal with the error at the exact moment an action completes.

let's compare some rust code

fn main() { let my_number: i32 = "3^".parse().unwrap(); println!("{my_number:?}"); }

Here the unwrap will cause a panic since "3^" cannot be transformed into a number with the following error message

thread 'main' (503752) panicked at src/main.rs:2:39: called `Result::unwrap()` on an `Err` value: ParseIntError { kind: InvalidDigit } note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Helpfully the rust compiler shows a runtime error on line 2 about an int error. Here we get some helpful data about what kind of error occurred but not details on what the input was and what character it failed on. Notably the unwrap will cause the program to panic and does not print the number. For many cases this is insufficient and can lead to service failure. Let's see what we can do to prevent this.


Writer's aside

Some individuals propose to completely reject any unwrap or expect in a rust codebase. While it is a solution I believe it misses the point of unwrap and expect. If an error is guaranteed to never occur then there isn't much need to properly handle it. If the above code was changed to

fn main() { let my_number: i32 = "3".parse().unwrap(); println!("{my_number:?}"); }

I would argue that an unwrap is okay since we do not take user input is always "3". There is no possible way to error here. Of course this assumption needs to be iron clad. In the recent Cloudlfare outage Cloudlflare's proxy service went down directly due to an unwrap when reading a config file. Me and many other developers jumped the shark, calling out Cloudflare on their best practices. In Cloudflare's defense they treated this file as trusted input and never expected it to be malformed. Due to circumstances the file became invalid causing the programs assumption's to break. Much like the unsafe code word, unwrap is meant to opt out of rust's protections. In some cases this is completely okay! However, the onus is on the developer to ensure correctness, which is no different than C and CPP.


How to write an error in Rust

Rust error's are often written in many different ways, typically most rust devs create an error enum that looks like this:

use std::{error::Error, fmt::{Display, Formatter}}; #[derive(Debug)] pub enum DemoError { ParseErr(std::num::ParseIntError), } impl From for DemoError { fn from(error: std::num::ParseIntError) -> Self { DemoError::ParseErr(error) } } impl Error for DemoError {} impl Display for DemoError { fn fmt(&self, f: &mut Formatter) -> Result<(), std::fmt::Error> { match self { DemoError::ParseErr(error) => write!( f, "error parsing with {}", error.to_string() ), } } } fn my_function() -> Result<(), DemoError> { let first_input = "3^"; let my_number: i32 = first_input.parse()?; println!("{my_number}"); Ok(()) } fn main(){ match my_function() { Ok(_) => println!("okay"), Err(error) => { eprintln!("{error}"); } } }

An error from this code will look like the following message.

error parsing with invalid digit found in string

While this message gives a decent description it leaves a couple things to be desired. There are two major issues: one, there's no context on what the input was and two, there's no location data. Let's see what we can do to improve.

use std::{ error::Error, fmt::{Display, Formatter}, panic::Location, }; #[derive(Debug)] pub struct DemoError { kind: DemoErrorKind, location: &'static Location<'static>, } #[derive(Debug)] pub enum DemoErrorKind { ParseErr(std::num::ParseIntError), } impl From for DemoError { #[track_caller] fn from(error: std::num::ParseIntError) -> Self { DemoError { kind: DemoErrorKind::ParseErr(error), location: Location::caller(), } } } impl Error for DemoError {} impl Display for DemoError { fn fmt(&self, f: &mut Formatter) -> Result<(), std::fmt::Error> { match &self.kind { DemoErrorKind::ParseErr(error) => write!( f, "my function had a parse error {} at location {}", error.to_string(), self.location.to_string() ), } } } fn my_function() -> Result<(), DemoError> { let first_input = "3^"; let my_number: i32 = first_input.parse()?; println!("{my_number}"); Ok(()) } fn main() { match my_function() { Ok(_) => println!("okay"), Err(error) => { eprintln!("{error}"); } } }

The above code will produce the below error message

my function had a parse error invalid digit found in string at location src/main.rs:37:26

This error message significantly improves the error by divulging the location of the data, however there's still one more missing issue: context. Libraries like anyhow can provide context with the .context("msg") feature. A similar ability can be accomplished in std with .map_err() and implementing new for the error. This has the side benefit of allowing mulitple types of errors for a single larger error.

use std::{ error::Error, fmt::{Display, Formatter}, panic::Location, }; #[derive(Debug)] pub struct DemoError { kind: DemoErrorKind, location: &'static Location<'static>, } #[derive(Debug)] pub enum DemoErrorKind { FirstNumberErr(String), NextNumberErr(String), } impl DemoError { #[track_caller] fn new(kind: DemoErrorKind) -> Self { DemoError { kind: kind, location: Location::caller(), } } } impl Error for DemoError {} impl Display for DemoError { fn fmt(&self, f: &mut Formatter) -> Result<(), std::fmt::Error> { match &self.kind { DemoErrorKind::FirstNumberErr(failed_input) => write!( f, "my function failed parse and had a first number input '{}' at location {}", failed_input self.location.to_string() ), DemoErrorKind::NextNumberErr(failed_input) => write!( f, "my function failed parse and had a next number input '{}' at location {}", failed_input self.location.to_string() ), } } } fn my_function() -> Result<(), DemoError> { let first_input = "3"; let my_number: i32 = first_input .parse() .map_err(|_| DemoError::new(DemoErrorKind::FirstNumberErr( first_input.into() )) )?; println!("{my_number}"); let next_input = "3^"; let my_number_two: i32 = next_input .parse() .map_err(|_| DemoError::new(DemoErrorKind::NextNumberErr( next_input.into() )) )?; println!("{my_number_two}"); Ok(()) } fn main() { match my_function() { Ok(_) => println!("okay"), Err(error) => { eprintln!("{error}"); } } }

In this error we provide the context of the failed input as well as a way to distinguish which number failed the parsing. If this were a library a consumer could perform different actions based on the error. The following code produces the below error message:

my function failed parse and had a next number input '3^' at location src/main.rs:58:22

This error makes it easy to determine exactly what happened, why, what the input was that caused it, and where the line of code responsible is. Not every error is going to need this amount of verbosity. Writing errors this way can cause code to be overly verbose and littered with .map_err calls. It's up to you as the programmer to determine if you need to use map_err or simply a ? with an impl new.

Why is this better than NodeJS

Try catch in NodeJS can be quite difficult. Catching an error at the wrong time can lead to malformed data by incorrectly taking the happy path or the programmer simply not dealing with the error at all. Prior to express 5 errors that are thrown in an endpoint would crash the entire server.

import express from 'express' const app = express() const port = 3000 app.get('/', async (req, res) => { throw new Error("failed!") res.send('Hello World!') }) app.listen(port, () => { console.log(`Example app listening on port ${port}`) })

Prior to express 5 any errors thrown in express endpoints in an async context will crash the server completely. Understanding where and what function throw errors or could potentially throw errors on malformed input can be ticky. Personally I and others at my ${company_name} have run into production errors where malformed input causes a crash deep down in an async endpoint. In rust the programmer is forced to handle this error immediately whereas NodeJS can hide this for you.

Issues with rust error handling

Admittedly this isn't perfect in rust. Amazon's prime video team is responsible for running the prime video TV app on a wide range of devices. Like any other rust project the team pulls in many third party crates. Due to the way tv apps work crashing is a worst case senario, meaning every error has to be mapped out and delt with. To uphold this guarantee the programmers need to ensure that libraries do not use unwrap, expect, panic, direct indexing, allocation, and a handful of other concepts. In cases where panic-ing is not an option a better approach sould be proving behavior using math.

Far better than the rest

Rust deals with errors far better than other languages by making it more apparent when and how an error occurs. However it's up to the culture to enforce best practices here. Rust is still new and gaining adoption, even rust in the linux kernel is still experimenting with its error system. Every project has a different context and a different way of handling errors. As the ecosystem improves and hones in on a particular style this will improve. Even concepts like #[track_caller] are relatively new and were directly formed to improve the problems of everyday rustaceans. My only complaints are that wiritng errors can often feel cumbersome due to their versbosity. My plea to the rust community and the rust foundation are to created documents to improve error understanding for those looking to get into the language. If you have a better way or extra details for error handling feel free to reach out at sgherzivincent@gmail.com.

Hiring for a rust role?

look into my resume

Other good sources for rust errors

Error handling Isn't All About Errors by Jane Lusby

Error Handling in Rust by Andrew Gallant