Error Codes & std::expected
The value-based alternative to exceptions: a function returns either a result or an error, encoded in the return type rather than the control flow.
Why it matters
Exceptions are unsuitable on hot paths, across ABI/C boundaries, or where the toolchain disables them (-fno-exceptions in many game/embedded/kernel codebases). The classic answer was errno/std::error_code out-parameters; C++23’s std::expected<T, E> (header <expected>) makes the error part of the value, so it cannot be silently ignored and needs no try. It is a type-safe T-or-E that composes via monadic .and_then/.transform.
How it works
expected<T,E> holds exactly one of a T (success) or an E (failure), like a purpose-built variant. Failures are wrapped in std::unexpected<E>.
| Approach | Cost on success | Ignorable? | Crosses ABI |
|---|---|---|---|
| Exceptions | zero | hard to ignore | no (unsafe) |
error_code out-param | branch + write | yes (silently) | yes |
std::expected<T,E> | branch | nodiscard-guarded | yes |
- Query with
e.has_value()/if (e); read success via*e/e.value()and failure viae.error(). e.value()on an error throwsbad_expected_access<E>;e.value_or(d)ande.error()never throw.- Chaining:
.and_then(f)runsfonly on success (andfitself returns anexpected),.transform(f)maps the value,.or_else(f)handles the error — short-circuiting on the first failure with noifpyramid. - Unlike
optional, the failure carries why it failed (anE), not just “empty”.
Example
std::expected<int, std::string> parse(std::string_view s) {
int v{};
auto [p, ec] = std::from_chars(s.data(), s.data() + s.size(), v);
if (ec != std::errc{}) return std::unexpected("not a number");
return v; // success path
}
auto r = parse("21")
.transform([](int n){ return n * 2; }) // 42, runs only if ok
.value_or(-1); // -1 if any step failedparse("oops") short-circuits: transform is skipped and value_or yields -1 — no exception, no manual error check between steps.
Pitfalls
- Pre-C++23 / no
<expected>: reach fortl::expected, AbseilStatusOr, or Boost.Outcome — the API is near-identical. std::expectedis marked nodiscard, but a local you assign and forget still drops the error. Discipline (or.value()) is needed; it is harder to ignore thanerror_code, not impossible.expected<void, E>is the form for “succeeds or fails with no payload” — don’t reach forexpected<bool, E>, which conflates the result with success.- Don’t mix paradigms randomly: a codebase that throws and returns
expectedfor the same failure class forces callers to handle both. Pick one per layer; convert at the boundary.