Skip to content
C++
Library
since C++23
Expert

std::expected and Exception-Free Error Handling

std::expected<T,E> for recoverable errors: monadic operations and_then/transform/transform_error, when to use expected vs exceptions vs optional, and designing error types.

std::expected<T, E> (C++23) is a vocabulary type for functions that can either succeed with a value of type T or fail with an error of type E. It puts the error in the type system β€” callers cannot accidentally ignore it β€” while avoiding the overhead and flow-control distortion of exceptions.


The problem it solves

Three ways to signal a recoverable error, each with a different tradeoff:

cpp
// 1. Exceptions β€” unclear from the signature; callers may forget to catch
Image find_cat(ImageView img);   // throws NoCatFound?

// 2. std::optional β€” no information about WHY it failed
std::optional<Image> find_cat(ImageView img);

// 3. std::expected β€” type-safe, self-documenting, reason included
std::expected<Image, ErrorCode> find_cat(ImageView img);

With expected, the caller sees from the signature that the function can fail and what kind of error to expect. The compiler enforces that they handle or propagate the result.


Basic usage

cpp
#include <expected>

enum class ParseError { invalid_format, out_of_range, empty_input };

std::expected<int, ParseError> parse_int(std::string_view sv) {
    if (sv.empty()) return std::unexpected(ParseError::empty_input);
    // ... parsing ...
    if (out_of_range) return std::unexpected(ParseError::out_of_range);
    return value;   // implicit conversion to expected<int, ParseError>
}

auto result = parse_int("42");

// Check and access:
if (result) {
    use(*result);            // dereference for value
    use(result.value());     // same, throws std::bad_expected_access if error
} else {
    log(result.error());     // access the error
}

// Provide a default:
int n = result.value_or(0);

// Throw on error (when you want to re-raise as exception):
int n = result.value();   // throws bad_expected_access<ParseError> if error

std::unexpected<E> is a tagged wrapper used to construct the error case. Without it, return ParseError::empty_input is ambiguous (is it the value T or the error E?).


Monadic operations (C++23)

The real power of expected comes from its monadic interface, which chains fallible operations without explicit if (!result) checks at each step.

and_then β€” chain fallible operations

and_then(f) calls f(*value) if the expected holds a value, and propagates the error unchanged if it holds an error. f must return an expected.

cpp
std::expected<Config, Error> load_config(std::string_view path);
std::expected<Server, Error> start_server(const Config& cfg);
std::expected<void, Error>   bind_port(Server& srv, int port);

// Without monadic chaining β€” boilerplate-heavy:
auto cfg = load_config("/etc/app.conf");
if (!cfg) return cfg.error();

auto srv = start_server(*cfg);
if (!srv) return srv.error();

auto bound = bind_port(*srv, 8080);
if (!bound) return bound.error();

// With and_then β€” reads as a pipeline:
auto result = load_config("/etc/app.conf")
    .and_then(start_server)
    .and_then([](Server& srv) { return bind_port(srv, 8080); });

If any step returns an unexpected, the rest of the chain is skipped and the error is forwarded.

transform β€” map over the value

transform(f) applies f to the value if present and wraps the result in expected. The error type is preserved unchanged. f returns a plain T, not an expected.

cpp
std::expected<std::string, Error> read_file(std::string_view path);
std::string trim(std::string s);
std::vector<std::string> split_lines(std::string s);

auto lines = read_file("input.txt")
    .transform(trim)
    .transform(split_lines);
// type: std::expected<std::vector<std::string>, Error>

transform_error β€” map over the error

transform_error(f) leaves the value unchanged and applies f to the error. Use it to convert between error types at layer boundaries.

cpp
enum class NetworkError { timeout, refused };
enum class AppError { network, parse, internal };

std::expected<std::string, NetworkError> http_get(std::string_view url);

auto result = http_get("https://api.example.com/data")
    .transform_error([](NetworkError e) -> AppError {
        return AppError::network;
    })
    .and_then(parse_json);   // parse_json returns expected<Json, AppError>
// type: std::expected<Json, AppError>

or_else β€” recover from errors

or_else(f) calls f(error) if the expected holds an error, allowing recovery. f must return an expected<T, E> with the same value type.

cpp
auto result = read_primary_config()
    .or_else([](Error) { return read_fallback_config(); })
    .or_else([](Error) { return default_config(); });

Full pipeline example

cpp
#include <expected>
#include <string>
#include <vector>

struct Config { int port; std::string host; };

std::expected<std::string, std::string>   read_file(std::string_view path);
std::expected<Config, std::string>        parse_config(const std::string& text);
std::expected<void, std::string>          validate_config(const Config& cfg);

std::expected<Config, std::string> load_validated_config(std::string_view path) {
    return read_file(path)
        .and_then(parse_config)
        .and_then([](Config cfg) -> std::expected<Config, std::string> {
            auto v = validate_config(cfg);
            if (!v) return std::unexpected(v.error());
            return cfg;
        });
}

int main() {
    auto cfg = load_validated_config("/etc/app.conf");
    if (cfg) {
        run_server(*cfg);
    } else {
        std::println("startup failed: {}", cfg.error());
    }
}

Designing the error type E

The choice of E shapes the entire error-handling ergonomics.

std::error_code β€” POSIX and system errors

cpp
std::expected<std::size_t, std::error_code> read_bytes(int fd, std::span<char> buf) {
    ssize_t n = ::read(fd, buf.data(), buf.size());
    if (n < 0) return std::unexpected(std::error_code(errno, std::system_category()));
    return static_cast<std::size_t>(n);
}

auto n = read_bytes(fd, buf);
if (!n && n.error() == std::errc::interrupted) {
    // EINTR β€” retry
}

A sum type for domain errors

cpp
enum class DbError { connection_failed, not_found, constraint_violation, io_error };

// Convert to a user message:
std::string_view describe(DbError e) {
    switch (e) {
        case DbError::not_found:            return "record not found";
        case DbError::connection_failed:    return "database unavailable";
        case DbError::constraint_violation: return "constraint violation";
        case DbError::io_error:             return "I/O error";
    }
}

Rich error with context

cpp
struct ParseError {
    std::size_t line;
    std::size_t column;
    std::string message;
};

std::expected<Ast, ParseError> parse(std::string_view src);

Guidelines for E:

  • Prefer enums for domain errors with a fixed set of causes
  • Use std::error_code when interoperating with POSIX APIs or std::filesystem
  • Use a struct with context when callers need to display diagnostics to a user
  • Avoid std::string as E in performance-sensitive code (allocation on every error path)

expected<void, E> for operations that produce no value

For operations that succeed or fail but produce no output value:

cpp
std::expected<void, Error> write_file(std::string_view path, std::string_view content);

auto result = write_file("/tmp/out.txt", data);
if (!result) {
    handle(result.error());
}

// In a chain β€” transform returns expected<void, E>, and_then can continue:
write_file("/tmp/a.txt", a)
    .and_then([&] { return write_file("/tmp/b.txt", b); })
    .and_then([&] { return sync_disk(); });

When to use expected vs exceptions vs optional

SituationRecommended
Error is expected in normal operation (missing key, not found)std::optional or std::expected
Error carries a reason the caller needsstd::expected<T, E>
Error is truly exceptional (constructor failure, I/O disaster)Exception
Multi-step pipeline where any step can failstd::expected + monadic chaining
Exception-disabled environment (embedded, kernel)std::expected
High-frequency hot path where exceptions add overheadstd::expected
Error is always std::error_code (filesystem, networking)std::expected<T, std::error_code>

The honest rule: if a function can fail in a way the caller should handle gracefully, expected often beats exceptions in readability and compile-time safety. If a function is designed to never fail under normal operation (and failure means a programming error), throw an exception or use assert.


Compiler and library support

Compilerstd::expected available
GCC 12+ with -std=c++23Yes
Clang 16+ with -std=c++23Yes
MSVC 2022 17.6+Yes
C++17/20Use tl::expected (single-header, same API)
cmake
# Require C++23:
set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

# Or for C++17/20 with tl::expected:
include(FetchContent)
FetchContent_Declare(tl_expected
    GIT_REPOSITORY https://github.com/TartanLlama/expected.git
    GIT_TAG        v1.1.0
)
FetchContent_MakeAvailable(tl_expected)
target_link_libraries(my_target PRIVATE tl::expected)

Designing error types: enum class vs error_code vs custom structs

The choice of error type E is an API contract β€” it determines what information callers receive, how errors compose across layers, and whether the type is cheap to copy or requires allocation. Pick the simplest type that satisfies all callers' needs.

enum class is the right default for domain-specific error sets where all possible causes are known at compile time:

cpp
enum class ParseError {
    empty_input,
    invalid_format,
    number_out_of_range,
    unexpected_end_of_file,
};

// Exhaustive handling via switch β€” the compiler warns on missing cases:
std::expected<Ast, ParseError> parse(std::string_view src);

auto ast = parse(src);
if (!ast) {
    switch (ast.error()) {
        case ParseError::empty_input:           handle_empty();   break;
        case ParseError::invalid_format:        handle_fmt();     break;
        case ParseError::number_out_of_range:   handle_range();   break;
        case ParseError::unexpected_end_of_file: handle_eof();    break;
    }
}

std::error_code pairs an integer code with an error_category object (a singleton that provides string descriptions and equivalence comparisons). It is the right choice when your errors must interoperate with the standard library, POSIX, or Boost.Asio:

cpp
// Custom category for a database error domain:
class DbCategory : public std::error_category {
public:
    const char* name() const noexcept override { return "database"; }
    std::string message(int code) const override {
        switch (static_cast<DbErrc>(code)) {
            case DbErrc::not_found:            return "record not found";
            case DbErrc::constraint_violation: return "constraint violation";
            case DbErrc::connection_failed:    return "connection failed";
            default:                           return "unknown database error";
        }
    }
};

const DbCategory& db_category() {
    static DbCategory instance;
    return instance;
}

enum class DbErrc { not_found = 1, constraint_violation, connection_failed };

std::error_code make_error_code(DbErrc e) {
    return {static_cast<int>(e), db_category()};
}

// Opt into error_code ADL conversion:
namespace std {
    template<> struct is_error_code_enum<DbErrc> : true_type {};
}

// Usage:
std::expected<Row, std::error_code> find_user(int id);
auto row = find_user(42);
if (!row && row.error() == DbErrc::not_found) { /* ... */ }

A struct with context fields is warranted when callers need diagnostic information beyond the error category β€” source location, offending token, HTTP status code, etc.:

cpp
struct NetworkError {
    int status_code;          // HTTP status, or 0 for transport-level failure
    std::string url;          // which request failed
    std::string detail;       // server-provided message, if any

    // Keep it cheap to move; string heap allocation is acceptable for errors
    // (error paths are cold; avoid premature optimisation here)
};

std::expected<Response, NetworkError> http_get(std::string_view url);

auto resp = http_get("https://api.example.com/items");
if (!resp) {
    auto& err = resp.error();
    std::println("HTTP {} fetching {}: {}", err.status_code, err.url, err.detail);
}

Avoid std::string as a bare error type β€” it conveys no structure, cannot be pattern-matched, and forces string allocation even on hot error paths.


std::expected with std::error_code and std::error_category

std::expected<T, std::error_code> is the bridge between the typed-value world of expected and the extensible error-domain system of the standard library, letting you integrate POSIX system calls, std::filesystem, and custom categories into a single error-handling pipeline. The key is defining a custom error_category so that your domain codes participate in the == comparison machinery.

cpp
#include <expected>
#include <system_error>
#include <filesystem>

// --- Custom category (header) ---
enum class IoErrc {
    ok = 0,
    file_not_found,
    permission_denied,
    disk_full,
};

std::error_code make_error_code(IoErrc e);    // forward declaration
namespace std { template<> struct is_error_code_enum<IoErrc> : true_type {}; }

// --- Category implementation (.cpp) ---
class IoCategory : public std::error_category {
public:
    const char* name() const noexcept override { return "io"; }
    std::string message(int v) const override {
        switch (static_cast<IoErrc>(v)) {
            case IoErrc::file_not_found:   return "file not found";
            case IoErrc::permission_denied: return "permission denied";
            case IoErrc::disk_full:         return "disk full";
            default:                        return "unknown io error";
        }
    }
    // Allow comparison with std::errc (POSIX) codes:
    bool equivalent(const std::error_code& code, int condition) const noexcept override {
        switch (static_cast<IoErrc>(condition)) {
            case IoErrc::file_not_found:
                return code == std::errc::no_such_file_or_directory;
            case IoErrc::permission_denied:
                return code == std::errc::permission_denied;
            default:
                return false;
        }
    }
};

std::error_code make_error_code(IoErrc e) {
    static IoCategory cat;
    return {static_cast<int>(e), cat};
}

// --- Using it ---
std::expected<std::string, std::error_code>
read_text_file(const std::filesystem::path& p) {
    std::error_code ec;
    auto size = std::filesystem::file_size(p, ec);
    if (ec) return std::unexpected(ec);   // propagate filesystem error directly

    std::ifstream f(p);
    if (!f) return std::unexpected(make_error_code(IoErrc::permission_denied));

    std::string content(size, '\0');
    f.read(content.data(), static_cast<std::streamsize>(size));
    return content;
}

// Cross-category comparison works via equivalent():
auto txt = read_text_file("/etc/shadow");
if (!txt && txt.error() == IoErrc::permission_denied) {
    // matched through equivalent(), even though the stored code is std::errc
}

This pattern lets library components return their own error categories while callers compare against either the library-specific enum or the underlying POSIX code, without any lossy conversion.


Propagating errors up the call stack: TRYX macros and tl::expected

The most common complaint about std::expected is that early-return propagation is verbose; a TRYX macro or the tl::expected extensions let you propagate errors with a single expression, mimicking Rust's ? operator. Until C++ gains a language-level equivalent, macros are the pragmatic solution.

A portable TRY macro for C++23 std::expected:

cpp
// try_macros.hpp
#define TRY(expr)                                               \
    ({                                                          \
        auto _result = (expr);                                  \
        if (!_result) return std::unexpected(_result.error()); \
        std::move(*_result);                                    \
    })
// Note: uses GCC/Clang statement-expression extension ({...})
// MSVC alternative: requires C++23 and a different approach (see below)

Usage:

cpp
std::expected<Config, Error> load_config(std::string_view path) {
    auto raw  = TRY(read_file(path));         // propagates read error
    auto json = TRY(parse_json(raw));         // propagates parse error
    auto cfg  = TRY(validate_config(json));   // propagates validation error
    return cfg;
}

For MSVC compatibility, use an explicit lambda wrapper:

cpp
#define TRY(result, expr)                                           \
    auto result = (expr);                                           \
    if (!result) return std::unexpected(result.error())
// Usage: TRY(raw, read_file(path)); β€” `raw` is the bound variable name

tl::expected (the C++17 backport) ships with an OUTCOME_TRY-style macro and also supports a .map_error / .bind chainable API that is slightly richer than the C++23 standard:

cpp
#include <tl/expected.hpp>

tl::expected<Config, Error> load_config(std::string_view path) {
    return tl::expected<std::string, Error>(read_file(path))
        .and_then(parse_json)
        .and_then(validate_config);
}

A C++23-native propagation pattern using a helper that converts expected to a coroutine result (see the coroutines section below) avoids macros entirely.


std::expected in coroutines

co_await-ing an std::expected is not built-in, but a small awaitable adapter lets coroutines propagate errors through their return type without explicit checks, making async pipelines as clean as synchronous ones. The key is implementing operator co_await for expected.

cpp
#include <coroutine>
#include <expected>

// Awaitable wrapper: co_await on an expected<T,E> inside a coroutine
// whose promise_type also yields expected<T,E>.
template<typename T, typename E>
struct ExpectedAwaitable {
    std::expected<T, E> value;

    bool await_ready() const noexcept { return true; }

    void await_suspend(std::coroutine_handle<>) const noexcept {}

    T await_resume() {
        // If value holds error, throw a sentinel to be caught by promise_type
        if (!value) throw typename std::expected<T,E>::unexpected_type(value.error());
        return std::move(*value);
    }
};

template<typename T, typename E>
ExpectedAwaitable<T, E> operator co_await(std::expected<T, E> e) {
    return {std::move(e)};
}

// A coroutine return type backed by expected:
template<typename T, typename E>
struct ExpectedTask {
    struct promise_type {
        std::expected<T, E> result;

        ExpectedTask get_return_object() { return {handle_t::from_promise(*this)}; }
        std::suspend_never initial_suspend() noexcept { return {}; }
        std::suspend_never final_suspend() noexcept { return {}; }

        void return_value(T v) { result = std::move(v); }

        void unhandled_exception() {
            try { std::rethrow_exception(std::current_exception()); }
            catch (const typename std::expected<T,E>::unexpected_type& u) {
                result = std::unexpected(u.value());
            }
        }
        using handle_t = std::coroutine_handle<promise_type>;
    };

    std::expected<T,E>& get() { return handle.promise().result; }
    std::coroutine_handle<promise_type> handle;
};

// --- Usage ---
ExpectedTask<Config, Error> load_config_async(std::string_view path) {
    // co_await propagates errors automatically:
    auto raw  = co_await read_file_async(path);     // expected<string, Error>
    auto json = co_await parse_json_async(raw);     // expected<Json, Error>
    auto cfg  = co_await validate_async(json);      // expected<Config, Error>
    co_return cfg;
}

// Caller:
auto task = load_config_async("/etc/app.conf");
if (!task.get()) {
    handle(task.get().error());
}

This pattern keeps all error propagation implicit inside the coroutine body while making the return type of each coroutine explicitly document its failure mode.


Performance: expected vs exceptions

std::expected has zero overhead on the success path β€” it is a value type, and a function returning expected<T,E> compiles to the same code as one returning T plus a small discriminant. Exceptions incur no overhead when not thrown, but their unwinding tables and handler lookups add binary size and can disable certain compiler optimizations.

Benchmarking the error path:

cpp
// expected error path: branch + return struct (cheap)
std::expected<int, Error> parse_int(std::string_view s) {
    if (s.empty()) return std::unexpected(Error::empty);
    return 42;
}
// Assembly: test + conditional return of {value, tag} β€” 3-4 instructions

// Exception error path: throw allocates heap, unwinds stack, runs handlers
int parse_int_exc(std::string_view s) {
    if (s.empty()) throw ParseException("empty");
    return 42;
}
// Assembly on success path: same 3-4 instructions
// On error path: call __cxa_allocate_exception, __cxa_throw,
//   stack unwinding via .eh_frame tables, handler lookup β€” O(stack depth)

Key performance characteristics:

Propertystd::expectedExceptions
Success-path overheadNoneNone (zero-cost model)
Error-path overheadO(1) β€” branch + returnO(stack depth) unwinding
Binary size impactMinimal.eh_frame / .pdata tables added per frame
Branch predictorSingle conditional per call siteNo branch at call site; indirect via unwind tables
Exception-disabled (-fno-exceptions)WorksImpossible

For functions that fail frequently (e.g., parsing user input, probing cache), expected can be 10–100Γ— faster on the error path. For functions that almost never fail (constructor of a stable data structure), the difference is unmeasurable.

expected is also friendly to Profile-Guided Optimization: the branch if (!result) at each call site is easy for the CPU's branch predictor to learn, whereas exception handling bypasses the normal branch predictor entirely.


Converting between expected and exceptions

Because most existing C++ code uses exceptions, and std::expected is most useful in new or exception-disabled code, bridging patterns in both directions are necessary. The standard library does not provide these adapters, but they are trivial to write.

Wrapping an exception-throwing API in expected

cpp
template<typename F>
auto to_expected(F&& f) noexcept
    -> std::expected<std::invoke_result_t<F>, std::exception_ptr>
{
    try {
        return std::forward<F>(f)();
    } catch (...) {
        return std::unexpected(std::current_exception());
    }
}

// Usage: wrap a legacy API that throws
auto result = to_expected([] {
    return LegacyParser::parse(input);   // may throw ParseException
});

if (!result) {
    try {
        std::rethrow_exception(result.error());
    } catch (const ParseException& e) {
        log_error(e.what());
    }
}

For a typed error E (rather than exception_ptr):

cpp
template<typename E, typename F>
auto to_expected(F&& f) noexcept -> std::expected<std::invoke_result_t<F>, E> {
    try {
        return std::forward<F>(f)();
    } catch (const E& e) {
        return std::unexpected(e);
    }
}

Throwing from an expected (value_or_throw pattern)

cpp
// .value() already throws std::bad_expected_access<E> on error.
// For a domain-specific exception:
template<typename T, typename E, typename Ex = E>
    requires std::constructible_from<Ex, E>
T value_or_throw(std::expected<T, E>&& exp) {
    if (!exp) throw Ex(std::move(exp.error()));
    return std::move(*exp);
}

// Convert expected<T, DbError> to throw on error:
Row row = value_or_throw<Row, DbError, DbException>(find_user(42));

Round-tripping through expected boundaries

When an expected-based library must call back into exception-based code (e.g., STL algorithms that call user-provided comparators that may throw), wrap the callback:

cpp
std::expected<std::vector<Row>, DbError> sorted_rows(Table& t) {
    std::vector<Row> rows = t.all_rows();
    try {
        std::ranges::sort(rows, [](const Row& a, const Row& b) {
            return a.key() < b.key();   // comparator might throw on corrupt data
        });
    } catch (const CorruptDataError& e) {
        return std::unexpected(DbError::io_error);
    }
    return rows;
}

tl::expected for C++17

tl::expected is a single-header, C++17-compatible implementation of std::expected with an API that is nearly identical to the C++23 standard, making it a drop-in backport for projects that cannot yet use C++23. It is maintained by Sy Brand (now at Microsoft) and is widely used in production.

Install via CMake FetchContent:

cmake
include(FetchContent)
FetchContent_Declare(
    tl_expected
    GIT_REPOSITORY https://github.com/TartanLlama/expected.git
    GIT_TAG        v1.1.0
    GIT_SHALLOW    TRUE
)
FetchContent_MakeAvailable(tl_expected)
target_link_libraries(my_target PRIVATE tl::expected)

Or via vcpkg:

bash
vcpkg install tl-expected
# CMakeLists.txt:
find_package(tl-expected CONFIG REQUIRED)
target_link_libraries(my_target PRIVATE tl::expected)

Usage is identical to C++23 std::expected β€” just swap the header and namespace:

cpp
// C++23:
#include <expected>
using std::expected;
using std::unexpected;

// C++17 with tl::expected:
#include <tl/expected.hpp>
using tl::expected;
using tl::unexpected;

tl::expected also provides TL_ASSERT integration and ships with tl::make_unexpected, a convenience helper not in the standard:

cpp
// tl extension: make_unexpected deduces E
auto err = tl::make_unexpected(ParseError::invalid_format);
// equivalent to: tl::unexpected<ParseError>(ParseError::invalid_format)

Migration path when upgrading to C++23: replace tl::expected with std::expected using a namespace alias, then flip the alias once the codebase is on C++23:

cpp
// compat/expected.hpp β€” single migration shim
#if __cplusplus >= 202302L && __has_include(<expected>)
#  include <expected>
   namespace project { using std::expected; using std::unexpected; }
#else
#  include <tl/expected.hpp>
   namespace project { using tl::expected; using tl::unexpected; }
#endif

Write all new code against project::expected; the migration to the standard type is then a one-line change.