Skip to content
C++

Which Error Handling Strategy?

Decision guide for choosing between exceptions, std::expected, std::optional, error codes, and outcome/result types in C++.

Quick Decision

cpp
Is failure expected / part of normal control flow?
├── NO (truly exceptional)  → exceptions
└── YES (e.g., parsing, lookup, network)
    ├── Need error details (code, message)?
    │   └── std::expected<T, E>  (C++23) or result type
    └── Just "found or not"?
        └── std::optional<T>

Is this performance-critical / embedded / no-exception build?
└── YES → error codes or std::expected (no-throw)

Is this a C API or calling C code?
└── error codes or errno

Is the error always fatal?
└── assert() / std::terminate() / [[noreturn]]

Exceptions — Truly Exceptional Events

cpp
// Best when: failure is rare, propagation across many layers is needed
double divide(double a, double b) {
    if (b == 0.0)
        throw std::domain_error("division by zero");
    return a / b;
}

try {
    auto result = divide(10.0, 0.0);
} catch (const std::domain_error& e) {
    std::println("error: {}", e.what());
} catch (const std::exception& e) {
    std::println("unexpected: {}", e.what());
}

// Pros: clean call sites, propagate automatically, RAII integrates
// Cons: not usable in -fno-exceptions, adds binary size, non-deterministic latency

std::optional — Present or Not

cpp
#include <optional>

// Best when: value may not exist, no error details needed
std::optional<int> find_user_id(const std::string& name) {
    auto it = db.find(name);
    if (it == db.end()) return std::nullopt;
    return it->second.id;
}

// Call site
auto id = find_user_id("Alice");
if (id) {
    use(*id);
} else {
    std::println("user not found");
}

// Monadic API (C++23)
auto result = find_user_id("Alice")
    .transform([](int id) { return load_profile(id); })
    .value_or(default_profile);

// Pros: explicit, zero overhead for trivial types, no exceptions
// Cons: no error details — caller doesn't know WHY it failed

std::expected — Value or Error (C++23)

cpp
#include <expected>

enum class ParseError { InvalidFormat, Overflow, Empty };

std::expected<int, ParseError> parse_int(std::string_view sv) {
    if (sv.empty()) return std::unexpected(ParseError::Empty);
    int result;
    auto [ptr, ec] = std::from_chars(sv.data(), sv.data() + sv.size(), result);
    if (ec == std::errc::invalid_argument) return std::unexpected(ParseError::InvalidFormat);
    if (ec == std::errc::result_out_of_range) return std::unexpected(ParseError::Overflow);
    return result;
}

// Call site
auto val = parse_int("42");
if (val) {
    std::println("parsed: {}", *val);
} else {
    switch (val.error()) {
        case ParseError::Empty:         std::println("empty input"); break;
        case ParseError::InvalidFormat: std::println("bad format"); break;
        case ParseError::Overflow:      std::println("overflow"); break;
    }
}

// Monadic chaining (C++23)
auto result = parse_int(input)
    .and_then([](int n) -> std::expected<double, ParseError> {
        if (n < 0) return std::unexpected(ParseError::InvalidFormat);
        return std::sqrt((double)n);
    })
    .value_or(-1.0);

// Pros: typed errors, no heap alloc, no exceptions, explicit error path
// Cons: C++23 only (use tl::expected for C++17)

Error Codes — C Interop and Performance

cpp
// std::error_code — system error integration
#include <system_error>

std::error_code open_file(const std::string& path) {
    FILE* f = fopen(path.c_str(), "r");
    if (!f)
        return std::error_code{errno, std::system_category()};
    // ...
    return {};  // success
}

auto ec = open_file("missing.txt");
if (ec)
    std::println("error: {}", ec.message());

// Custom error category
enum class DbError { NotFound = 1, Timeout, Constraint };

struct DbErrorCategory : std::error_category {
    const char* name() const noexcept override { return "database"; }
    std::string message(int ev) const override {
        switch (static_cast<DbError>(ev)) {
            case DbError::NotFound:   return "record not found";
            case DbError::Timeout:    return "query timed out";
            case DbError::Constraint: return "constraint violation";
        }
        return "unknown";
    }
};

const DbErrorCategory db_category_instance{};
std::error_code make_error_code(DbError e) {
    return {static_cast<int>(e), db_category_instance};
}

tl::expected for C++17

cpp
// Before C++23: use tl::expected (header-only, MIT)
#include <tl/expected.hpp>

tl::expected<int, std::string> parse(std::string_view s) {
    // ...
    return tl::unexpected("invalid input");
}

Comparison Table

Exceptionsstd::optionalstd::expectedError codes
Error detailswhat() stringNoneTyped enum/stringCategory + value
Call site noiseLowMediumMediumHigh
PerformanceNon-deterministicZeroZeroZero
-fno-exceptionsNoYesYesYes
PropagationAutomaticManualManual (.and_then)Manual
C++17 readyYesYesNo (use tl::expected)Yes
Best forTruly exceptional"found or not"Expected failuresC interop, HFT

Practical Rules

cpp
// Constructor failure → exception (can't return a value)
Matrix::Matrix(size_t rows, size_t cols) {
    if (rows == 0 || cols == 0)
        throw std::invalid_argument("zero dimension");
}

// Lookup → optional
std::optional<User> find_user(int id);

// Parse / convert → expected
std::expected<Config, ParseError> parse_config(std::string_view json);

// OS/IO → error_code
std::error_code write_file(const Path& p, std::span<const std::byte> data);

// Truly fatal → assert/terminate (programming error, not runtime)
void process(std::span<int> v) {
    assert(!v.empty());  // caller's responsibility
}