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:
// 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
#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 errorstd::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.
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.
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.
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.
auto result = read_primary_config()
.or_else([](Error) { return read_fallback_config(); })
.or_else([](Error) { return default_config(); });Full pipeline example
#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
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
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
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_codewhen interoperating with POSIX APIs or std::filesystem - Use a struct with context when callers need to display diagnostics to a user
- Avoid
std::stringasEin 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:
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
| Situation | Recommended |
|---|---|
| Error is expected in normal operation (missing key, not found) | std::optional or std::expected |
| Error carries a reason the caller needs | std::expected<T, E> |
| Error is truly exceptional (constructor failure, I/O disaster) | Exception |
| Multi-step pipeline where any step can fail | std::expected + monadic chaining |
| Exception-disabled environment (embedded, kernel) | std::expected |
| High-frequency hot path where exceptions add overhead | std::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
| Compiler | std::expected available |
|---|---|
GCC 12+ with -std=c++23 | Yes |
Clang 16+ with -std=c++23 | Yes |
| MSVC 2022 17.6+ | Yes |
| C++17/20 | Use tl::expected (single-header, same API) |
# 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:
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:
// 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.:
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.
#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:
// 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:
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:
#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 nametl::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:
#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.
#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:
// 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:
| Property | std::expected | Exceptions |
|---|---|---|
| Success-path overhead | None | None (zero-cost model) |
| Error-path overhead | O(1) β branch + return | O(stack depth) unwinding |
| Binary size impact | Minimal | .eh_frame / .pdata tables added per frame |
| Branch predictor | Single conditional per call site | No branch at call site; indirect via unwind tables |
Exception-disabled (-fno-exceptions) | Works | Impossible |
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
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):
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)
// .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:
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:
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:
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:
// 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:
// 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:
// 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; }
#endifWrite all new code against project::expected; the migration to the standard type is then a one-line change.