C++23 std::expected: Exception-Free Error Handling That Doesn't Suck
C++23's std::expected gives exception-free code a type-safe way to return values or errors — forcing callers to check before use. Here's when it beats error codes, where it falls short, and how to verify support in your toolchain.
27 Jul 2025, 01:24 UTC

The Problem: Exceptions Are Expensive, Error Codes Are Painful
You're writing a hot path in a game engine, a trading system, or an embedded controller. Exceptions are disabled (-fno-exceptions) because the unwind tables bloat the binary and the runtime cost is unpredictable. So you reach for error codes: int returns, std::error_code out-parameters, or a std::pair<T, Error> convention. The call sites become a cascade of if (ec) return ec; boilerplate. Worse, nothing forces the caller to check. A missed check silently corrupts state.
std::expected<T, E> (C++23, <expected>) gives you a third option: a type that must be inspected before the value is accessible, with zero-cost abstraction when you do check. It's not a silver bullet, but it changes the ergonomics of error propagation in exception-free codebases.
What It Actually Does
std::expected<T, E> holds either a T (success) or an E (failure). You construct it from either type. The API forces a branch before you can get the value:
has_value()/operator bool()— check without extractingvalue()— returnsT&or throwsstd::bad_expected_access<E>if errorerror()— returnsE&(precondition:!has_value())value_or(default)— unwrap or substituteand_then(fn)/transform(fn)— monadic chaining for success paths
The key difference from std::variant or std::optional: expected encodes intent. The error type E is part of the signature, so callers know what failures to handle. And unlike exceptions, there's no hidden control flow — every propagation is visible in the source.
Worked Example: Parsing a Config File in a Hot Path
Imagine a latency-sensitive service that reloads a JSON config on SIGHUP. The parser runs on a worker thread; exceptions are banned. Here's a minimal expected-based design:
// config.hpp
#include <expected>
#include <string>
#include <system_error>
struct Config { int port; std::string host; };
enum class ConfigError { MissingFile, BadJson, InvalidSchema };
std::expected<Config, ConfigError> load_config(const std::string& path);
// config.cpp
#include "config.hpp"
#include <fstream>
#include <charconv>
// ... JSON parser of your choice (simulated here)
std::expected<Config, ConfigError> load_config(const std::string& path) {
std::ifstream f(path);
if (!f) return std::unexpected(ConfigError::MissingFile);
// Simulated parse — replace with real JSON lib
std::string content((std::istreambuf_iterator<char>(f)), {});
if (content.empty()) return std::unexpected(ConfigError::BadJson);
// Extremely naive extraction for demo
int port = 8080;
std::string host = "localhost";
// ... real parsing would go here
if (port <= 0 || port > 65535)
return std::unexpected(ConfigError::InvalidSchema);
return Config{port, host};
}
// caller.cpp
#include "config.hpp"
#include <iostream>
void on_sighup_reload() {
auto result = load_config("/etc/myapp/config.json");
if (!result) {
// Structured logging with the actual error
std::cerr << "Config reload failed: "
<< static_cast<int>(result.error()) << '\n';
return; // keep running with old config
}
// Only here do we have a valid Config& — no unchecked access possible
apply_config(result.value());
}
Compile with a C++23-capable toolchain:
# GCC 13+, Clang 16+, MSVC 19.37+ (VS 2022 17.7+)
g++ -std=c++23 -O2 -Wall -Wextra config.cpp caller.cpp -o reload_demo
Where to run: Any Linux/macOS/Windows box with a supported compiler. Permissions: Regular user for compile; the binary reads /etc/myapp/config.json so adjust path or run with appropriate file access. Expected check: Build succeeds and ./reload_demo prints nothing on success or an error code on missing/invalid config. Risk: If your standard library lacks <expected>, the include fails — see verification below.
Trade-offs and Limitations
1. Compiler and Standard Library Support Is Uneven
As of late 2026, std::expected works in GCC 13+ (libstdc++), Clang 16+ (libc++), and MSVC 19.37+ (MSVC STL). If you're stuck on RHEL 9's GCC 11 or an older embedded toolchain, you're out of luck — no backport exists in the standard library. A common workaround is tl::expected, a header-only polyfill that matches the C++23 API and works back to C++11.
2. value() Still Throws
Calling value() on an error state throws std::bad_expected_access<E>. This defeats the "no exceptions" goal if you're careless. Prefer if (auto& v = result) { use(*v); } or result.transform(...) for non-throwing access. Some teams ban value() entirely via static analysis.
3. ABI Is Not Stable Across Standard Libraries
The layout of std::expected<T, E> differs between libstdc++, libc++, and MSVC STL. Do not pass it across shared-library boundaries compiled with different toolchains. If you need a stable ABI, wrap it in a C-compatible struct or serialize.
4. Error Type Explosion
Every function gets its own E. Composing multiple fallible calls means either a union error type (std::variant<ErrorA, ErrorB>) or mapping to a common domain error. The monadic and_then helps but doesn't eliminate the design work.
How to Verify It Works in Your Toolchain
Before adopting, run this check in your CI or dev container:
# check_expected.sh
cat > /tmp/test_expected.cpp < 'EOF'
#include <expected>
#include <iostream>
int main() {
std::expected<int, std::error_code> e = 42;
if (e) std::cout << "OK: " << *e << '\n';
#ifdef __cpp_lib_expected
std::cout << "Feature macro: " << __cpp_lib_expected << '\n';
#endif
return 0;
}
EOF
# Test with your actual compiler flags
${CXX:-g++} -std=c++23 /tmp/test_expected.cpp -o /tmp/test_expected && /tmp/test_expected
If it prints OK: 42 and a feature macro value (e.g., 202202L), you're good. If the include fails or the macro is missing, your standard library doesn't ship it yet — use the polyfill or upgrade.
Closing: When to Reach for It
Use std::expected when:
- Exceptions are disabled or forbidden by policy
- You want the compiler to enforce error checking at call sites
- The error type carries meaningful domain information (not just "success/fail")
- Your toolchain supports C++23
<expected>or you can vendortl::expected
Stick with exceptions when:
- Error propagation spans many layers and you'd rather not thread error types through every signature
- You're integrating with libraries that already throw
- Binary size and unwind cost are acceptable (most desktop/server apps)
Stick with error codes when:
- You need C ABI compatibility
- The error is truly trivial (e.g.,
boolsuccess) and boilerplate is minimal - You're on a pre-C++11 compiler
Start small: pick one performance-critical module, disable exceptions for that TU (-fno-exceptions per-file via pragma or build config), and replace its error-code returns with std::expected. Measure binary size, instruction count, and — most importantly — how many missed-error bugs your static analyzer catches.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.