>_devkit
modern-cpp
skills/modern-cpp/

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 isMechanismExample
A bug in the callerassert, contract, std::unreachableIndex past the end of your own loop. Null passed to a function documented as requiring non-null.
Expected, and the caller decidesstd::expected<T, E>Parsing user input. A lookup that can miss. A request that can 404.
Rare, and most frames cannot helpexceptionOut 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_error or 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.

GuaranteeMeaning
NothrowCannot fail. Required for destructors, swap, and move in containers.
StrongSucceeds, or has no effect. Commit-or-rollback.
BasicNo 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::vector reallocation 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

PatternWhy it fails
bool f(T& out)The caller can use out after false. Nothing stops them. Return expected.
Error codes nobody checksSilent. Wrap in [[nodiscard]] or use expected.
throw for control flowMicroseconds per iteration, and it hides the normal path from the reader.
One project-wide error enumGrows to eighty values, half of which cannot occur at any given call.
Logging and rethrowingThe same failure appears five times in the log at five layers. Log where you handle it.
catch (...) around a whole subsystemTurns bugs into mysteries.
Returning -1The caller must know the convention. The type should say it.