conceptModern C++~4 min readUpdated 2026-07-02#cpp#errors#exceptions#expected#raii

Error handling: exceptions vs expected

Exceptions are non-local failure transfer: a function says "I could not complete my contract" and control jumps to a matching handler while destructors clean the unwound scopes. expected-style values are local failure data: a function returns either a value or an error, and callers decide immediately how to continue. Neither is the universal C++ answer. The right choice depends on whether the failure is exceptional, whether recovery is local, whether constructors need to fail, and whether your codebase permits exception machinery at all.

The reset: error handling is part of the API. Do not choose by taste alone. Choose by the shape of the failure and by the runtime your program is allowed to carry.

How it really works

With exceptions, a throw transfers control up the call stack to a compatible catch. Objects whose lifetimes end during unwinding have their destructors run, which is why RAII and exception safety are inseparable. The common guarantees are nothrow, strong, basic, and no guarantee. A destructor should not throw during unwinding, and noexcept means an escaping exception calls std::terminate.

With std::expected<T, E>, standardized in C++23, the return object contains either a T or an E. The caller can test it, inspect the value, inspect the error, or compose it with monadic operations when the implementation provides them. The failure path is visible in the function signature, but it can also become noisy if every layer only forwards errors mechanically.

Shape Exceptions fit expected fits
Constructor cannot establish invariant yes factory can return expected
Parser sees invalid user input usually no yes
Deep subsystem cannot complete required task often yes maybe
Syscall or kernel-style API returns errno-like status usually no yes
Hot path where failure is common usually no yes
Project builds with -fno-exceptions no yes
Library boundary with unknown policy risky explicit

There is a third category: bugs and violated preconditions. Those are usually assertions, sanitizers, tests, or process termination, not recoverable errors. Returning expected from a function that was called with impossible arguments can train callers to recover from corrupted invariants.

Executable artifact: throwing parse and expected-style parse

The runnable demo lives in examples/modern-cpp/error-handling-exceptions-vs-expected/. It compiles as C++20, so it uses a tiny local Expected<T, E> built on std::variant instead of requiring C++23 std::expected.

cd examples/modern-cpp/error-handling-exceptions-vs-expected
./run.sh

The throwing path uses std::stoi and converts parse failures into exceptions:

static int parse_or_throw(std::string_view text)
{
    if (text.empty())
        throw std::invalid_argument("empty");

    std::string owned { text };
    std::size_t consumed = 0;
    int value = std::stoi(owned, &consumed, 10);

    if (consumed != owned.size())
        throw std::invalid_argument("trailing characters");

    return value;
}

The explicit path uses std::from_chars, which is non-allocating and non-throwing:

static ParseResult parse_expected(std::string_view text)
{
    if (text.empty())
        return ParseError::Empty;

    int value = 0;
    auto const* first = text.data();
    auto const* last = text.data() + text.size();
    auto [position, error] = std::from_chars(first, last, value);

    if (error == std::errc::result_out_of_range)
        return ParseError::Overflow;
    if (error == std::errc::invalid_argument || position != last)
        return ParseError::Invalid;

    return value;
}

The script emits demo.O2.s. Compare the normal path and the failure path. The explicit parse is ordinary branches and returned state. The throwing parse has a separate control path and depends on the exception runtime and unwind tables used by your ABI and compiler flags.

Failure modes & trade-offs

  • Exceptions without RAII leak. If cleanup is manual, unwinding skips the cleanup code you forgot to wrap in a destructor.
  • Catching too broadly hides invariants. catch (...) at a low layer can turn a programming bug into a fake recoverable condition.
  • Throwing for normal input errors makes control flow surprising. Invalid CLI input, malformed packets, and missing cache entries are usually expected failures.
  • expected can create boilerplate tunnels. A stack of functions that only checks and forwards errors may obscure the success path.
  • Error types can be too weak. bool, int, or a string-only error often fails to preserve what the caller needs for recovery.
  • Mixing policies is expensive. A no-exceptions library boundary, a throwing third-party dependency, and a kernel-style errno API need adapters, not wishful thinking.
  • noexcept is a contract. Marking a function noexcept for speed and then letting an exception escape turns recovery into termination.

In practice

Use exceptions when a function cannot fulfill its contract, recovery is naturally at a higher layer, constructors need to fail cleanly, and the codebase is already exception-safe. Use expected or a project equivalent when failure is an ordinary result, the caller is expected to branch locally, or the runtime forbids exceptions. Use assertions for violated internal assumptions.

For low-level code, be especially strict at boundaries. Syscalls, drivers, allocators, parsers, and protocol code usually benefit from explicit error values. Hosted application code can often use exceptions effectively, but only if the project consistently designs for exception safety.

Connects to: RAII: the idea that changes everything · Move semantics and value categories · Modern C++ highlights worth adopting · Which subset of C++ to use · Undefined behavior: the contract · Sanitizers: ASan, UBSan, and TSan · The syscall: crossing into the kernel

Sources

  • cppreference - Exceptions - language model for throw/catch, unwinding, and exception-safety guarantees. https://en.cppreference.com/w/cpp/language/exceptions
  • cppreference - std::expected - C++23 value-or-error vocabulary type and observer operations. https://en.cppreference.com/w/cpp/utility/expected
  • cppreference - std::from_chars - non-allocating, non-throwing numeric parse API used by the demo. https://en.cppreference.com/cpp/utility/from_chars
  • C++ Core Guidelines - error-handling and RAII rules that make exceptions usable instead of leak-prone. https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines
  • Google C++ Style Guide - Exceptions - pragmatic no-exceptions policy and rationale from a large existing codebase. https://google.github.io/styleguide/cppguide.html
  • ISO C++ FAQ - Exceptions - longer design discussion of when exceptions are appropriate in C++. https://isocpp.org/wiki/faq/exceptions
  • LLVM Coding Standards - example of a major systems project that avoids exceptions and RTTI for size and policy reasons. https://llvm.org/docs/CodingStandards.html