Result, ?, and Error Enums
Goal: you can build the error architecture every CodeCora service uses — one error
enum per crate, Display for humans, From for ? interop, and a stable mapping to
outward-facing codes.
Result<T, E> — errors as values
#![allow(unused)]
fn main() {
enum Result<T, E> { Ok(T), Err(E) }
}
No exceptions. Failure is a value in the return type, so a function that can fail
announces it in its signature, and the compiler refuses to let you ignore the Err
side. That is why Rust services fail gracefully: the handling path is typed, not
forgotten.
The ? operator
? means: if this is Err, return the error from the current function; if Ok,
unwrap the value. It composes across layers — with one condition: the error types
must connect via From.
#![allow(unused)]
fn main() {
fn read_config(path: &str) -> Result<String, ParseError> {
let text = std::fs::read_to_string(path)?; // io::Error -> ParseError via From
Ok(text)
}
}
The house pattern: one error enum per crate
This is the exact shape of AppError in our Axum services (Sinau, nginjen) —
learned on a tiny parser so nothing distracts:
#![allow(unused)]
fn main() {
#[derive(Debug, PartialEq, Eq)]
enum ParseError {
Empty,
MissingField(&'static str),
BadLevel(String),
}
impl fmt::Display for ParseError { // human-facing message
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ParseError::Empty => write!(f, "input is empty"),
ParseError::MissingField(name) => write!(f, "missing field: {name}"),
ParseError::BadLevel(tok) => write!(f, "unknown level `{tok}` (want INFO|WARN|ERROR)"),
}
}
}
impl std::error::Error for ParseError {} // interop with the ecosystem
impl From<std::io::Error> for ParseError { // makes `?` work across the IO boundary
fn from(e: std::io::Error) -> Self {
ParseError::MissingField(match e.kind() {
std::io::ErrorKind::NotFound => "file",
_ => "readable input",
})
}
}
}
Three traits, three jobs:
| Trait | Job | Who consumes it |
|---|---|---|
Debug | developer view ({:?}) | logs, unwrap, test failures |
Display | human message ({}) | CLI output, error pages |
From<E> | conversion for ? | every caller crossing a layer |
Status codes are a rendering of errors
The mistake to un-learn: sprinkling HTTP codes through business logic. The error type stays transport-agnostic; the boundary renders it:
#![allow(unused)]
fn main() {
impl ParseError {
fn to_status(&self) -> (&'static str, u16) {
match self {
ParseError::Empty => ("422 Unprocessable Entity", 422),
ParseError::MissingField(_) => ("400 Bad Request", 400),
ParseError::BadLevel(_) => ("400 Bad Request", 400),
}
}
}
}
In an Axum service this method becomes impl IntoResponse for AppError and returns
StatusCode — same architecture, bigger stage (M5).
Fail well: the lab’s CLI behavior
$ m2-errlib mixed.log
ok [INFO] all good
skip 400 Bad Request: unknown level `TRACE` (want INFO|WARN|ERROR)
parsed 1 line(s), skipped 1
Note what the skip line does: names the category (400), quotes the offending token, states the accepted values. Errors are UX. A panic is never UX.
Exercises
- Add a
LineTooLong(usize)variant (cap: 256 chars) withDisplay+ a413status, enforce it inparse_line, extend the tests. - Write
fn parse_all(input: &str) -> Result<Vec<Entry>, ParseError>that fails on the FIRST bad line (all-or-nothing), with tests. Contrast with the skip-and-continue CLI. Which policy fits a batch importer? Which fits an HTTP endpoint? - Implement
From<ParseError> for Stringand use it in amap_errchain. Then defend or attackStringas an error type (hint: what canBadLevel(String)do thatStringcannot?).
Done when: cargo test -p m2-errlib is green (6+ tests) and you can explain, in
order, what happens when ? hits a mismatched error type.