Flat Error Codes Are Not Enough
Table of Contents
The case for flat error codes
I’m writing this in pushback against “Stop Forwarding Errors, Start Designing
Them”
and other similar
ideas
.
Those suggest that you only need one concrete Error type per library, with two
fields:
An error message, meant for the user.
Along the call graph, this string should accumulate high-level business context to avoid reporting a cryptic low-level error like
"No such file or directory". This is already a common practice in Go and Rust.A flat
ErrorCode/ErrorKindenum, meant for robust programmatic error handling and recovery.There should be just one such enum per library. It should be mininal. It shouldn’t expose every specific low-level error that your library might encounter. Instead, only expose the level of detail that’s relevant to the calling libraries’ error-handling logic. The detailed reporting for the user is already covered by the error message.
The main benefit is that you satisfy both the human and the machine, while keeping your codebase clean and minimal.
The case for nested error data
Flat error codes might work in your own application code, where you rarely need complex error recovery. In the Rust community, many application devs already return a string with no specific “error code” at all . (Although, personally, I’m against that ).
But I don’t see how flat error codes can work in high-level, IO-heavy libraries that do need to provide enough detail for recovery.
Consider the following example from my work codebase:
use sea_orm::*;
use sea_orm::sqlx::*;
fn human_message(db_err: &DbErr) -> Option<&'static str> {
match db_err {
DbErr::Query(RuntimeErr::SqlxError(Error::Database(database_error))) => {
let constraint_name = database_error.constraint()?;
match database_error.kind() {
ErrorKind::UniqueViolation => humanize_unique_violation(constraint_name),
ErrorKind::ForeignKeyViolation => humanize_fk_violation(database_error.message()),
ErrorKind::CheckViolation => humanize_check_constraint_violation(constraint_name),
_ => None,
}
}
// More match arms here...
}
}
Basically, there’s a database driver library
sqlx
. Then, there’s
sea_orm
, built on top of it. It exposes a
deep hierarchy of error types:
- High-level ORM methods return
sea_orm::DbErr. - When caused by a database interaction (IO), it provides an
sqlx::Errorwith all the details. - When caused by an error returned from the DBMS itself, it provides an
sqlx::DatabaseError. - Finally,
sqlx::DatabaseErrorstores the raw DBMS error privately. It knows how to parse and categorize it. I use that in my application to provide human-readable error messages wherever I rely on database constraints for validation.
If sea_orm didn’t nest and expose anything from sqlx, it would have to
duplicate all of that functionality in its own error types, or drop it. Either
outcome would be very unfortunate.
And even if sea_orm exposed or copy-pasted all “error codes” from sqlx, that
still wouldn’t be enough for my use case. It’s not enough to know that I
violated a CHECK constraint. I need other structured data, like the name of the
constraint. Otherwise, I would have to parse that back from the error message.
Which is obviously inferior.
Related reading
I tackle a very similar “flat vs nested enums” tradeoff in “Designing Error Types in Rust Applications” .
See also my other posts about error handling .
Comments