references/errors.md
Error handling
Three mechanisms, three jobs. Picking per situation rather than per codebase is what keeps signatures honest.
The taxonomy
| The failure is | Mechanism | Example |
|---|---|---|
| A bug in the caller | assert, contract, std::unreachable | Index past the end of your own loop. Null passed to a function documented as requiring non-null. |
| Expected, and the caller decides | std::expected<T, E> | Parsing user input. A lookup that can miss. A request that can 404. |
| Rare, and most frames cannot help | exception | Out of memory. A config file the process cannot start without. |
The test for the middle row versus the bottom: would a reasonable caller write a handler for this? If yes, put it in the signature. If every caller would just let it propagate, an exception is doing that for free.
std::expected<T, E> (C++23)
The default for recoverable failure. It puts the failure in the type, so the caller cannot ignore it without saying so.
auto parse_port(std::string_view text) -> std::expected<Port, ConfigError>;
auto port = parse_port(raw);
if (!port) { return std::unexpected(port.error()); }
use(*port);
The monadic operations chain without the early-return ladder. and_then for a step that can also fail, transform for one that cannot, or_else to recover, transform_error to translate at a boundary:
auto config = read_file(path)
.and_then(parse_toml) // each step can fail
.and_then(validate)
.transform(apply_defaults) // this one cannot
.transform_error(to_startup_error); // one error type at the boundary
Design the error type. An enum class plus, where it helps, a payload. Not a string: a string cannot be switched on, and the caller ends up matching substrings.
enum class ConfigError { missing_key, bad_type, out_of_range };
One error enum per module, translated at the module boundary. A single project-wide error enum grows to eighty values and tells the caller nothing.
std::optional<T> is expected with no error information. Use it when there is exactly one way to fail and the name says what it is: find_user returning nothing means not found. The moment there are two reasons, switch to expected.
Exceptions
The cost model is asymmetric. Zero overhead when nothing throws, on every mainstream implementation: the happy path has no branches and no checks, only unwind tables in a cold section of the binary. Throwing costs microseconds: allocation, RTTI lookup, table-driven unwinding. That asymmetry is the whole guidance. Exceptions are excellent for the rare and terrible for the routine.
Practicalities:
- Throw by value, catch by
const&. Catching by value slices. - Derive from
std::runtime_erroror a domain base of your own, so callers
can catch a category. Include enough context in what() to act on.
- Never
catch (...)and continue. It swallows the bugs too. Catch it at the
top level to log and terminate cleanly, and nowhere else.
- Destructors do not throw. They are implicitly
noexcept; throwing from one
during unwinding calls std::terminate.
Exception safety
Every function offers one of three guarantees. Know which one you are writing.
| Guarantee | Meaning |
|---|---|
| Nothrow | Cannot fail. Required for destructors, swap, and move in containers. |
| Strong | Succeeds, or has no effect. Commit-or-rollback. |
| Basic | No leaks and no broken invariants, but state may have changed. |
RAII gives you basic for free, which is why it is the foundation and not a style. The strong guarantee usually comes from the copy-and-swap idiom: do the work on a copy, then swap in with a nothrow operation.
noexcept
Mark it where it is true and where somebody depends on it:
- Move constructor and move assignment.
std::vectorreallocation calls
move_if_noexcept, so a move that might throw is turned into a copy to keep the strong guarantee. This is the highest-value noexcept in the language.
swap.- Small leaf functions where it is obviously true.
Do not spray it. noexcept on a function that can throw calls std::terminate, and it is part of the signature, so removing it later is a breaking change.
-fno-exceptions
Embedded targets, some game engines, and a few trading systems build without them. Then expected is the only mechanism, and it is worth knowing that standard library functions which would throw instead call std::terminate: vector::at, std::stoi, allocation failure. Use operator[] with your own bounds check, std::from_chars instead of stoi, and a checked allocator.
Programmer error
Not a failure to report: a bug to surface. assert in debug, compiled out by NDEBUG in release. That asymmetry is a genuine risk for anything that could be attacker-controlled, so use a hardened standard library (see build.md) rather than relying on asserts for bounds.
C++26 adds contracts, which are the right home for this once compilers land:
Money withdraw(Money amount)
pre(amount > Money{0})
post(balance >= Money{0});
std::unreachable() (C++23) marks a branch that cannot happen. It is undefined behaviour if reached, so use it only where the impossibility is proven, and prefer an assert where it merely should not happen.
std::stacktrace (C++23) captures the call stack. Attaching one to your top-level error type turns "it failed somewhere" into a bug report you can act on.
Anti-patterns
| Pattern | Why it fails |
|---|---|
bool f(T& out) | The caller can use out after false. Nothing stops them. Return expected. |
| Error codes nobody checks | Silent. Wrap in [[nodiscard]] or use expected. |
throw for control flow | Microseconds per iteration, and it hides the normal path from the reader. |
| One project-wide error enum | Grows to eighty values, half of which cannot occur at any given call. |
| Logging and rethrowing | The same failure appears five times in the log at five layers. Log where you handle it. |
catch (...) around a whole subsystem | Turns bugs into mysteries. |
Returning -1 | The caller must know the convention. The type should say it. |