Error Handling & std::expected

C++ gives you at least five different ways to signal that something went wrong — exceptions, error codes, std::optional, std::expected, and assertions — and picking the wrong one for a given situation is one of the most common design mistakes in real codebases.

Ask five experienced C++ developers how to report an error and you'll get five confident, mutually exclusive answers, because C++ genuinely doesn't have one blessed error-handling mechanism the way languages like Go (error return values) or Java (checked exceptions) do. Instead it offers a toolbox: exceptions for propagating failures across many stack frames automatically, error codes for cheap and explicit failure signaling, std::optional for 'there might not be a value, and that's not even an error,' std::expected (C++23) for 'here's a value, or here's specifically why there isn't one,' and assertions for catching programmer mistakes that should never happen in correct code at all. This page compares all five side by side with the same running example — a function that parses a string into an integer — so the differences in call-site code, performance, and intent are concrete rather than abstract.

The Same Problem, Five Ways

Every approach below solves the same task: parse a string like "42" into an int, and do something sensible when the string isn't a valid integer, like "abc" or "". Watching how the signature and the call site change across all five approaches is the fastest way to internalize their real trade-offs.

Exceptions: Propagation Without Plumbing

An exception lets a deeply nested function report failure without every intermediate function on the call stack having to check for it and pass it along manually — the exception unwinds the stack on its own, running destructors along the way, until something catches it.

parse_int's caller doesn't have to check a return code after every call — the try/catch block is the only place error handling appears, no matter how many functions parse_int calls internally that might also throw. The cost is that every caller of parse_int who doesn't want the exception to escape has to wrap it, and throwing itself is comparatively expensive if it happens often.

Exceptions are best reserved for situations that are genuinely exceptional — malformed input from outside the program, failed allocations, broken invariants — not for routine, expected outcomes like 'the user typed something that isn't a number' in a form that validates input constantly. Throwing on every keystroke of a live input validator would be both slow and a misuse of what exceptions are for.

#include <stdexcept>
#include <string>
#include <iostream>

int parse_int(const std::string &text)
{
    if (text.empty())
        throw std::invalid_argument("empty string");
    return std::stoi(text); // stoi itself throws std::invalid_argument on bad input
}

int main()
{
    try
    {
        int value = parse_int("abc");
        std::cout << value << '\n';
    }
    catch (const std::exception &e)
    {
        std::cout << "parse failed: " << e.what() << '\n';
    }
}

Error Codes: Cheap, Explicit, Easy to Ignore

An error code reports failure through an ordinary return value or an out-parameter, with no stack unwinding and no exception-handling machinery involved at all. It's the oldest and cheapest approach, and it's still what most C APIs and performance-critical C++ code use.

The bool return communicates success or failure directly and cheaply, and out_value carries the actual result only when parsing succeeds. Nothing forces the caller to check the return value, though — ignoring if (parse_int(...)) and reading out_value anyway compiles fine and just uses whatever garbage value out_value happened to hold.

[[nodiscard]] on a function's declaration turns 'the caller ignored the return value' into a compiler warning, which closes most of the gap between error codes and exceptions on the 'can this be silently ignored' question — but it still relies on the caller reading their compiler warnings.

#include <string>
#include <iostream>

bool parse_int(const std::string &text, int &out_value)
{
    if (text.empty())
        return false;

    try
    {
        out_value = std::stoi(text);
        return true;
    }
    catch (...)
    {
        return false;
    }
}

int main()
{
    int value = 0;
    if (parse_int("abc", value))
        std::cout << value << '\n';
    else
        std::cout << "parse failed\n";
}

std::optional: Absence That Isn't Really an Error

std::optional<T> (C++17) holds either a T or nothing, and it's the right tool when 'no value' is a normal, meaningful outcome rather than a failure — looking up a key that legitimately isn't in a map, or finding the maximum of an empty range. Its weakness for error handling specifically is that it can't say why there's no value.

result.has_value() forces the caller to check before dereferencing with *result, which is safer than an error code's silent-ignore problem — but std::nullopt collapses 'empty string,' 'non-numeric text,' and 'value out of int range' into the exact same, information-free 'nothing' outcome.

#include <optional>
#include <string>
#include <iostream>

std::optional<int> parse_int(const std::string &text)
{
    if (text.empty())
        return std::nullopt;

    try
    {
        return std::stoi(text);
    }
    catch (...)
    {
        return std::nullopt;
    }
}

int main()
{
    auto result = parse_int("abc");
    if (result.has_value())
        std::cout << *result << '\n';
    else
        std::cout << "no value\n"; // but WHY? empty string? non-numeric? out of range?
}

std::expected: A Value or a Specific Reason It's Missing

std::expected<T, E> (C++23) is what you reach for when you want optional's explicit, no-exception-required checking, plus the ability to say exactly why the operation failed. It holds either a T (the success case) or an E (the error case), and the caller inspects which one they got before touching the value.

result.error() only becomes valid to call once has_value() has returned false, mirroring the discipline *result requires for optional. Unlike optional, the caller here can distinguish exactly which of three distinct failure reasons occurred and react differently to each, all without a single throw statement or catch block at the call site.

Internally parse_int still uses try/catch around std::stoi, because that's the interface stoi exposes — std::expected doesn't eliminate exceptions from the standard library, it gives you a clean way to translate an exception-based interface into an explicit-checking one at the boundary of your own function.

#include <expected>
#include <string>
#include <iostream>

enum class ParseError { Empty, NotANumber, OutOfRange };

std::expected<int, ParseError> parse_int(const std::string &text)
{
    if (text.empty())
        return std::unexpected(ParseError::Empty);

    try
    {
        return std::stoi(text);
    }
    catch (const std::invalid_argument &)
    {
        return std::unexpected(ParseError::NotANumber);
    }
    catch (const std::out_of_range &)
    {
        return std::unexpected(ParseError::OutOfRange);
    }
}

int main()
{
    auto result = parse_int("abc");
    if (result.has_value())
    {
        std::cout << *result << '\n';
    }
    else
    {
        switch (result.error())
        {
            case ParseError::Empty:      std::cout << "empty input\n"; break;
            case ParseError::NotANumber:  std::cout << "not a number\n"; break;
            case ParseError::OutOfRange:  std::cout << "out of range\n"; break;
        }
    }
}

Assertions: For Bugs, Not for Bad Input

None of the four approaches above are the right tool for one specific category of failure: a violated precondition that should be structurally impossible if the calling code is correct. assert(divisor != 0); inside a divide function isn't reporting 'the user divided by zero' — user-facing division-by-zero should be caught and reported through expected or an error code before it ever reaches the divide function. assert exists to catch the divide function being called incorrectly by other code in the same program, a bug rather than a runtime condition to recover from. The full mechanics of assert, and the precondition/postcondition/invariant vocabulary, are covered on this site's dedicated Assertions & Contracts page.

Choosing the Right Tool

A short decision guide, in the order you should actually reach for these five tools: use assertions only for conditions that indicate a bug in the calling code, never for anything that can legitimately happen at runtime from external input. Use std::optional when the absence of a value is a normal, expected outcome that doesn't need an explanation. Use std::expected when failure is a normal, expected outcome but the caller does need to know why, without the overhead or control-flow surprise of an exception. Use error codes in performance-sensitive or embedded code, C-interfacing code, or codebases that have already standardized on them, understanding that discipline (or [[nodiscard]]) is required to make sure they're not silently ignored. Use exceptions for genuinely exceptional situations — resource exhaustion, corrupted invariants, malformed data from outside the program's control — especially when the failure needs to propagate cleanly across many layers of unrelated code that shouldn't have to know about it.

// Rule of thumb, condensed:
// Bug in this program?              -> assert
// No value, and that's normal?      -> std::optional<T>
// No value, caller needs to know why (no exceptions)? -> std::expected<T, E>
// Hot path, or C-style interface?   -> error code / bool + out-param
// Rare failure, deep call stack?    -> exception

Mixing Approaches in Real Code

Production code routinely uses several of these at once, at different layers. A network library might use exceptions for truly catastrophic failures like socket creation failing, std::expected for ordinary request-level failures like a 404 response, std::optional for a header lookup that legitimately may not be present, error codes at its lowest C-interop layer talking to the OS, and assert internally to check that its own invariants (like 'the connection pool is never negative') hold. The skill isn't picking one mechanism and using it everywhere — it's recognizing which of these five situations you're actually in at each boundary in your code.

Why is std::optional the wrong tool for reporting a parse failure that could happen for three different reasons (empty string, non-numeric text, out-of-range value)?

std::optional can only represent 'a value' or 'no value' — it has no way to attach a reason to the absent case, so all three distinct failure causes collapse into the same uninformative std::nullopt. std::expected<T, E> solves this by carrying an actual error value (like an enum with three cases) alongside the failure, letting the caller distinguish and react to each one.

What's the key structural difference between how an error code and an exception get from the point of failure back to the code that handles it?

An error code is passed back explicitly through the return value or an out-parameter at each function boundary, so every function on the call chain has to check it and decide whether to propagate it further. An exception unwinds the call stack automatically, running destructors along the way, until it reaches a matching catch block — intermediate functions that don't care about the error don't need to mention it at all.

Why is assert the wrong tool for validating input a user typed into a form, even though the input might genuinely be invalid?

Assertions are meant to catch bugs in the program itself — violated preconditions that should be structurally impossible when the calling code is correct — and they're typically compiled out entirely in release builds via NDEBUG. Invalid user input is an expected, recoverable runtime condition, not a bug, and it needs to be checked and reported through a mechanism that's still active in release builds, like std::expected or an error code.

In the std::expected version of parse_int, why does the function still use try/catch internally around std::stoi even though the whole point of expected is to avoid exceptions at the call site?

std::stoi is part of the existing standard library interface and reports failure by throwing, regardless of what error-handling style the calling code prefers. Wrapping that call in try/catch and translating each caught exception into std::unexpected(...) lets parse_int present a clean, exception-free interface to its own callers while still using stoi's exception-based interface internally.

Give one situation where an error code (bool or int return value) is genuinely preferable to both exceptions and std::expected.

In a very hot code path called millions of times per second, or when interfacing directly with a C API or embedded environment where exception handling machinery is unavailable or too costly, a plain error code avoids both the runtime cost of throwing and the (smaller but nonzero) overhead of constructing an expected object, at the cost of relying on the caller's discipline (or [[nodiscard]]) to actually check it.

Write a function std::expected<double, std::string> safe_sqrt(double x) that returns the square root of x, or an error message if x is negative, and demonstrate both outcomes in main.

safe_sqrt returns std::unexpected(...) with a descriptive message when x is negative, and the plain result of std::sqrt(x) otherwise. main checks has_value() before calling value() or error() on each result, printing the computed root for 16.0 and the error message for -4.0, with no exception ever thrown.

Refactor this error-code-based function into one that returns std::optional<int>, and explain why optional (rather than expected) is the appropriate choice here.

The refactor is std::optional<int> find_first_even(const std::vector<int> &nums) { for (int n : nums) if (n % 2 == 0) return n; return std::nullopt; }. optional is appropriate rather than expected because 'this vector contains no even numbers' isn't a failure or an error condition to explain — it's a completely normal, unremarkable outcome that doesn't need an accompanying reason, which is exactly what optional's plain 'value or nothing' models.

Write a function that opens a value from a std::map<std::string, int> by key, using std::expected<int, std::string> to distinguish a missing key from the case where the key exists but its value is negative (treated as invalid), and show both failure paths in main.

get_valid_score distinguishes two different failure reasons — a missing key versus a present-but-invalid value — using distinct unexpected(...) messages, something a bool return code or std::optional couldn't express as clearly. The loop prints alice's valid score, bob's negative-score error, and carol's missing-key error, each with a message specific to what actually went wrong.

bool find_first_even(const std::vector<int> &nums, int &out)
{
    for (int n : nums)
    {
        if (n % 2 == 0)
        {
            out = n;
            return true;
        }
    }
    return false;
}