std::expected
C++23 vocabulary type holding either a success value T or an error value E, making failure modes explicit in the return type without exceptions.
std::expected<T, E>since C++23A sum type that holds either a value of type T (success) or a value of type E (error), making failure modes explicit in the function signature without exceptions or out-parameters.
Overview
std::expected<T, E> is a vocabulary type β a standard representation for "this operation either produces a T or fails with an E", recognised across codebases without custom result wrappers. It occupies the same design space as Rust's Result<T, E>, but integrates cleanly with C++ value semantics, move semantics, and the existing <system_error> infrastructure.
The key distinction from std::optional<T> is that the absent case carries information β the error value tells the caller why the operation failed, not just that it did.
Reach for expected when:
- A function performs fallible I/O, parsing, or network operations and the caller needs to act on the failure reason
- You want the type system to force acknowledgment of failure paths
- You are building pipelines where multiple operations chain and errors propagate linearly
- Exceptions are off-limits (embedded, real-time, or policy constraints)
Do not use it for:
- Programmer errors (precondition violations, out-of-bounds) β use
assertor terminate - Code already built around exceptions, where mixing models adds friction
- Simple optional returns with no meaningful error β
std::optionalis the right vocabulary there
Syntax
#include <expected> // C++23
// Primary template
template<class T, class E>
class std::expected;
// Void specialisation β success/failure only, no value produced
template<class E>
class std::expected<void, E>;
// Sentinel wrapper for constructing the error state
template<class E>
class std::unexpected; // C++23
// In-place construction tags
std::in_place // constructs T in-place (shared with optional/variant)
std::unexpect // constructs E in-place (type: std::unexpect_t, C++23)Type constraints: T must be an object type (possibly cv-qualified) or void. E must be a destructible, non-array object type β void is not permitted as E; use std::monostate if no error data is needed.
Key members
| Member | Description |
|---|---|
has_value() / operator bool() | true if the object holds a value |
operator*(), operator->() | Access value β UB if !has_value() |
value() | Access value β throws std::bad_expected_access<E> if !has_value() |
error() | Access error β precondition: !has_value() |
value_or(u) | Return value or fallback u |
emplace(args...) | Destroy current state; construct T in-place from args; return T& |
and_then(f) | If value: invoke f(*val) β expected<U,E>; otherwise pass error through |
transform(f) | If value: wrap f(*val) in expected<U,E>; otherwise pass error through |
or_else(f) | If error: invoke f(err) β expected<T,G>; otherwise pass value through |
transform_error(f) | Map error E β G; leave value unchanged |
Examples
Layered file parsing
#include <expected>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <system_error>
#include <charconv>
enum class ParseError { EmptyInput, InvalidFormat, OutOfRange };
std::expected<std::string, std::error_code>
read_file(const std::filesystem::path& p) {
std::ifstream f(p);
if (!f)
return std::unexpected(std::make_error_code(std::errc::no_such_file_or_directory));
std::ostringstream ss;
ss << f.rdbuf();
return ss.str();
}
std::expected<int, ParseError>
parse_port(std::string_view s) {
if (s.empty()) return std::unexpected(ParseError::EmptyInput);
int n = 0;
auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), n);
if (ec != std::errc{}) return std::unexpected(ParseError::InvalidFormat);
if (n < 1 || n > 65535) return std::unexpected(ParseError::OutOfRange);
return n;
}Manual propagation vs. monadic chaining
Both are valid. Manual propagation is explicit and easy to annotate with logging or branching. Monadic chaining eliminates boilerplate for truly linear pipelines.
// Manual β clear, debuggable, easy to extend
std::expected<Config, AppError> load_config(std::string_view path) {
auto text = read_file(path);
if (!text) return std::unexpected(to_app_error(text.error()));
auto doc = parse_json(*text);
if (!doc) return std::unexpected(doc.error());
return validate(*doc);
}
// Monadic β concise when every step is a pure transformation
std::expected<Config, AppError> load_config_v2(std::string_view path) {
return read_file(path)
.transform_error(to_app_error) // std::error_code β AppError
.and_then(parse_json) // string β expected<JsonDoc, AppError>
.and_then(validate); // JsonDoc β expected<Config, AppError>
}transform and transform_error
// transform maps the value; transform_error maps the error type
auto result =
parse_port(user_input) // expected<int, ParseError>
.transform_error([](ParseError e) { // C++23
return AppError{static_cast<int>(e), "port parse failed"};
}) // β expected<int, AppError>
.transform([](int port) {
return std::format("connecting to :{}", port);
}); // β expected<string, AppError>or_else for partial recovery
auto port =
parse_port(user_input)
.or_else([](ParseError e) -> std::expected<int, ParseError> {
if (e == ParseError::OutOfRange) return 80; // recover with default
return std::unexpected(e); // re-raise anything else
});expected<void, E> β operations with no success value
std::expected<void, std::error_code>
write_file(const std::filesystem::path& p, std::string_view data) {
std::ofstream f(p);
if (!f) return std::unexpected(std::make_error_code(std::errc::permission_denied));
f << data;
if (!f) return std::unexpected(std::make_error_code(std::errc::io_error));
return {}; // success: value-initialised void
}
// Monadic chaining works identically on the void specialisation
auto r = write_file("out.txt", content)
.and_then([&] { return write_file("out.bak", content); })
.transform_error([](std::error_code ec) { return AppError{ec.message()}; });In-place construction and emplace
// Construct T without copies when T has a multi-arg constructor
std::expected<std::vector<int>, std::string> r{std::in_place, {1, 2, 3}};
// Construct E in-place with std::unexpect
std::expected<int, std::string> err{std::unexpect, "bad input"};
// Replace current state in-place β avoids an extra copy/move
r.emplace(10, 0); // constructs vector<int>(10, 0); returns vector<int>&Monadic Operations in Depth (C++23)
The four monadic methods form a composable algebra over expected. Understanding exactly which state each one acts on is essential before building pipelines.
| Method | Acts on | Skips |
|---|---|---|
and_then(f) | value | error (passed through unchanged) |
transform(f) | value | error (passed through unchanged) |
or_else(f) | error | value (passed through unchanged) |
transform_error(f) | error | value (passed through unchanged) |
and_then requires that f returns an expected<U, E> β it is the flatMap / bind operation. transform wraps f's return value automatically, so f is just a plain function T -> U. The distinction matters when f itself can fail:
// C++23
// and_then: f returns expected β used when the next step is also fallible
auto r1 = parse_int(str) // expected<int, Error>
.and_then(validate_range); // int -> expected<int, Error>
// transform: f returns a plain value β used when the next step cannot fail
auto r2 = parse_int(str) // expected<int, Error>
.transform([](int n) { return n * 2; }); // int -> int, wrapped automaticallyA complete pipeline demonstrates how errors short-circuit at the first failure and skip every subsequent step until an or_else handles them:
// C++23
auto result = parse_int(str) // expected<int, Error>
.and_then(validate_range) // int -> expected<int, Error>
.transform([](int n) { return n * 2; }) // int -> int
.or_else([](Error e) -> std::expected<int, Error> { // recovery at the end
return 0; // substitute default on any error
});
// result is always expected<int, Error> with a value β the or_else guarantees itIf parse_int returns an error, validate_range and the transform lambda are never called. The error object travels untouched through each step until or_else intercepts it. This short-circuit property is what makes the chain safe to write without intermediate if checks.
transform_error is the dual of transform: it leaves a success value alone and maps only the error type. It is particularly useful when crossing a layer boundary that uses a different error vocabulary:
// C++23
std::expected<int, ParseError>
parse_count(std::string_view s);
std::expected<int, AppError>
get_item_count(std::string_view s) {
return parse_count(s)
.transform_error([](ParseError e) {
return AppError{.domain = "parse", .code = static_cast<int>(e)};
});
}Designing the Error Type E
The choice of E affects every caller. Three patterns cover most situations.
Pattern 1: std::error_code β system errors and composable infrastructure
std::error_code is the lowest-overhead option. It is a value type (two words: an integer and a pointer to a category), cheap to copy, and already used by the standard library, Boost, and POSIX wrappers. Prefer it when the error originates from the OS or a third-party library that already surfaces error_code.
#include <system_error>
std::expected<std::string, std::error_code>
read_env(const char* name) {
const char* v = std::getenv(name);
if (!v)
return std::unexpected(
std::make_error_code(std::errc::no_such_file_or_directory));
return v;
}std::error_code is also composable: error categories from different libraries can coexist, and callers can compare against well-known codes without knowing the full hierarchy.
Pattern 2: Domain-specific enum β structured, exhaustive failures
When a subsystem has a fixed set of failure modes that callers must distinguish, a scoped enum gives you exhaustive switch coverage and zero overhead:
enum class ParseError {
InvalidSyntax,
Overflow,
Empty
};
std::expected<int, ParseError> parse_int(std::string_view s) {
if (s.empty())
return std::unexpected(ParseError::Empty);
// ...
}
// Caller gets compiler-enforced exhaustiveness
void handle(std::expected<int, ParseError> r) {
if (!r) {
switch (r.error()) {
case ParseError::InvalidSyntax: /* ... */ break;
case ParseError::Overflow: /* ... */ break;
case ParseError::Empty: /* ... */ break;
}
}
}Best Practice. Keep domain enums small. If every case in the switch ends up doing the same thing, merge them. If callers routinely ignore the distinction, collapse to a single-value enum or switch to std::error_code.
Pattern 3: Rich struct β detailed diagnostics
When callers need both a machine-readable code and human-readable context, a struct carries both. This is appropriate for network or RPC layers where the error includes a status code, a message, and possibly a trace:
struct NetworkError {
int code;
std::string message;
// Avoid adding heap-heavy members β see below
};
std::expected<Response, NetworkError>
fetch(std::string_view url);Best Practice. Keep E cheap to copy. expected stores the error inline; if E is large, every propagation copies it. Move-only errors are allowed but awkward with value_or and the monadic API. If the error can be large, store the expensive part behind a std::unique_ptr or use a std::shared_ptr<const ErrorDetail> pattern so copies remain pointer-sized.
std::unexpected<E> β Tagging Error Returns
std::unexpected<E> is a thin wrapper whose sole purpose is disambiguation. The expected<T, E> constructor cannot know whether a bare E value is intended as success or error when T and E happen to be the same type, or when implicit conversion would otherwise be ambiguous. std::unexpected removes all ambiguity by tagging the value as the error arm.
// C++23
std::expected<int, ParseError> parse_count(std::string_view s) {
if (s.empty())
return std::unexpected(ParseError::Empty); // clearly the error arm
// ...
return 42; // clearly the value arm
}std::unexpected has CTAD (class template argument deduction), so you rarely need to spell out the template argument. The type deduced is exactly the argument's type after decay, so pass a named value when the type is complex:
NetworkError err{503, "Service Unavailable"};
return std::unexpected(err); // deduces unexpected<NetworkError>
// or with a move
return std::unexpected(std::move(err));std::unexpected is also the type returned by or_else lambdas that want to re-raise an error unchanged. It is not just for function returns β it appears anywhere you need to construct the error arm of an expected.
Returning expected<void, E>
Some operations either succeed β producing no value β or fail with an error. Writing files, flushing buffers, and sending packets all fall into this category. Use expected<void, E> to express this contract. The success state is constructed by return {}, which value-initialises the void specialisation:
// C++23
#include <expected>
#include <system_error>
std::expected<void, std::error_code>
truncate_file(const std::filesystem::path& p) {
std::error_code ec;
std::filesystem::resize_file(p, 0, ec);
if (ec)
return std::unexpected(ec);
return {}; // success β no value to return
}Chaining void-returning steps with and_then requires a nullary lambda (or a function taking void) because operator* on expected<void, E> yields nothing:
auto r = truncate_file("cache.db")
.and_then([]() { return truncate_file("cache.db-wal"); })
.and_then([]() { return truncate_file("cache.db-shm"); });Each lambda receives nothing (the void value) and returns expected<void, std::error_code>. If any step fails, subsequent lambdas are skipped and the error flows out.
Error Propagation: The Manual TRY Pattern
C++ has no built-in ? operator (Rust's early-return-on-error syntax). Two idioms fill the gap.
The manual early-return
The cleanest approach for occasional propagation is an explicit check and forwarding of the error:
// C++23
std::expected<Config, AppError> load_config(std::string_view path) {
auto raw = read_file(path);
if (!raw) return std::unexpected(raw.error()); // propagate
auto doc = parse_json(*raw);
if (!doc) return std::unexpected(doc.error()); // propagate
return validate(*doc);
}This is verbose but maximally debuggable. Insert a log or breakpoint at any if (!x) without disrupting structure.
The TRY macro
For codebases where the pattern appears dozens of times per file, a macro reduces noise. The trick is a do { } while (false) wrapper and a __VA_ARGS__ forward to preserve complex expressions:
// C++23
#define TRY(expr) \
({ \
auto _res = (expr); \
if (!_res) \
return std::unexpected(std::move(_res.error())); \
std::move(*_res); \
})
// Usage
std::expected<Config, AppError> load_config(std::string_view path) {
auto raw = TRY(read_file(path)); // returns early on error
auto doc = TRY(parse_json(raw)); // same
return validate(doc);
}The GNU statement-expression extension (({ ... })) is supported by GCC and Clang but is not standard C++. For strictly conforming code, wrap the check in a helper function or use the monadic API instead. Some teams write a TRYX variant that also handles error-type conversion.
Common Pitfall. The TRY macro silently moves from the result. Do not use the original variable after TRY β the value has been moved out.
expected vs Exceptions vs std::optional β Decision Table
| Criterion | std::expected<T, E> | Exceptions | std::optional<T> |
|---|---|---|---|
| Caller must acknowledge failure | Yes β type system enforces it | No β can silently ignore | No β empty state ignorable |
| Failure carries a reason | Yes | Yes (what(), type) | No |
| Failure is a normal code path | Yes | No β for truly exceptional events | Yes |
Works with -fno-exceptions | Yes | No | Yes |
| Stack unwinding overhead | None | Yes | None |
| Composable pipelines | Yes β monadic API | Awkward | Yes β monadic API (C++23) |
| Use for programmer errors (bugs) | No | Debatable | No |
| Natural fit | Parsing, I/O, config, network | Deep library errors, constructors | Lookup results, optional config |
Choose std::optional when absence is a normal, uninteresting condition and the caller needs no explanation. Choose exceptions when the failure is genuinely unexpected, stack unwinding is needed to clean up, or you are in a codebase that already uses exceptions uniformly. Choose std::expected when failure is routine, the reason matters, and you want the type system to enforce handling β especially in exception-free environments.
tl::expected β C++17 Backport
std::expected is a C++23 feature. For projects targeting C++14 or C++17, the TartanLlama/expected library (tl::expected) provides an API-compatible implementation:
// Drop-in header-only library β same API as std::expected
#include <tl/expected.hpp>
tl::expected<int, ParseError> parse_count(std::string_view s);
// All four monadic methods present:
auto result = parse_count(input)
.and_then(validate_range)
.transform([](int n) { return n * 2; })
.or_else([](ParseError) -> tl::expected<int, ParseError> { return 0; });tl::expected works from C++14 and compiles under MSVC, GCC, and Clang without modifications. The type is in the tl namespace; a migration to std::expected later requires only a namespace change (assuming no implementation-specific extensions were used). The library is header-only and available via vcpkg, Conan, and as a single-file download.
Best Practice. If you are writing a library that will be consumed by both C++17 and C++23 users, typedef the type in a compatibility header:
// compat/expected.hpp
#if __cplusplus >= 202302L
#include <expected>
template<class T, class E> using Expected = std::expected<T, E>;
template<class E> using Unexpected = std::unexpected<E>;
#else
#include <tl/expected.hpp>
template<class T, class E> using Expected = tl::expected<T, E>;
template<class E> using Unexpected = tl::unexpected<E>;
#endifCoroutine Integration
std::expected does not integrate with coroutines out of the box, but adding a custom operator co_await makes it possible to propagate errors with the same conciseness as co_await on awaitables. The pattern mirrors Rust's ? inside an async function.
The key insight: a coroutine that returns std::expected<T, E> can use co_await on inner expected values, and if one holds an error the coroutine immediately co_returns that error without executing subsequent statements.
// C++20 coroutines + C++23 expected
// Minimal awaiter that propagates errors through a coroutine returning expected<T,E>
template<class T, class E>
struct ExpectedAwaiter {
std::expected<T, E> value;
bool await_ready() const noexcept { return value.has_value(); }
// Called when !await_ready() β propagate the error via the coroutine promise
template<class Promise>
void await_suspend(std::coroutine_handle<Promise> h) noexcept {
h.promise().result = std::unexpected(std::move(value.error()));
h.destroy();
}
T await_resume() noexcept { return std::move(*value); }
};
template<class T, class E>
ExpectedAwaiter<T, E> operator co_await(std::expected<T, E> e) {
return ExpectedAwaiter<T, E>{std::move(e)};
}With this in place (and a coroutine promise type whose return_value stores into std::expected<T, E>), you can write:
// C++20 + C++23
Task<std::expected<Config, AppError>> load_config_coro(std::string_view path) {
auto raw = co_await read_file_async(path); // propagates error automatically
auto doc = co_await parse_json_async(raw); // same
co_return validate(doc);
}If read_file_async returns an expected holding an error, await_suspend fires, stores the error in the promise, destroys the coroutine frame, and parse_json_async is never reached. The caller receives a completed Task whose result is the propagated error.
Common Pitfall. The operator co_await overload above must be visible at the call site. Place it in the same namespace as expected (which you cannot do for std), or use a project-wide header included everywhere. ADL will not find it if it is in an unrelated namespace.
Best Practices
Pick an error type that matches caller needs. std::error_code integrates with <system_error> for POSIX/OS errors and is cheap to copy. A scoped enum class works well for domain-specific failures. std::string loses machine-readable structure and should be a last resort. Over-engineered error hierarchies are as harmful as under-engineered ones β model only the distinctions callers actually make.
Push value_or to the boundary. Deep inside domain logic, acknowledge errors explicitly. Convert to a default only at the edge β a UI renderer, a serialiser, a log line β where a fallback is genuinely correct.
Remember that expected does not propagate automatically. Unlike exceptions, an error sitting in an expected stays put until code explicitly handles or forwards it. Design error paths intentionally: propagate with and_then, return early with if (!r) return std::unexpected(r.error()), or recover with or_else.
Prefer monadic chaining for pure pipelines; manual propagation when you need side effects. and_then and transform cannot easily log, mutate state, or branch mid-pipeline. A plain if (!r) guard is often clearer and more maintainable when the pipeline is not purely functional.
Keep E cheap. expected stores E inline. If the error type is large (e.g., contains a std::string message and a stack trace), copies during propagation are expensive. Either keep E small, use move semantics diligently, or store heavy data behind a shared pointer.
Common Pitfalls
Forgetting std::unexpected
// Does not compile β std::string cannot implicitly construct the error state
std::expected<int, std::string> r = "error message";
// Correct
std::expected<int, std::string> r = std::unexpected(std::string{"error message"});Accessing value or error without checking
std::expected<int, std::string> r = std::unexpected("bad");
int v = *r; // UB β precondition: has_value()
int v2 = r.value(); // throws std::bad_expected_access<std::string>
std::string e = r.error(); // OK β precondition satisfiedoperator*, operator->, and error() carry preconditions, not safety nets. Only value() converts a violated precondition into a catchable exception; the others invoke undefined behaviour when misused. In hot paths, check first and access with operator*.
Using expected for programmer errors
A violated precondition β null pointer, out-of-bounds index, wrong argument type β is a bug. Encoding bugs as std::unexpected trains callers to check for bugs rather than eliminate them. Use assert, [[unlikely]] with terminate, or forthcoming C++ contracts for precondition enforcement.
Mixing expected and exceptions in the same layer
expected-based and exception-based code can coexist in a codebase but not within the same call chain without an explicit adapter. Pick a single error-propagation model per layer and convert at the boundaries.
Discarding [[nodiscard]]-equivalent returns silently
std::expected is not [[nodiscard]] by default in the standard, but many teams annotate their own wrappers with it. Even without the attribute, discarding a returned expected without checking is a silent logic error. Code review and static analysis (clang-tidy's bugprone-unused-return-value) can catch these.
See Also
std::optionalβ value-or-nothing without a typed errorstd::variantβ general discriminated union;expectedis a specialised variant- Error handling β comparing exceptions, error codes, and
expected std::error_codeβ composable OS and library error values, a naturalEchoice