Standard Library[lib]
std::expected in C++23: return a value or an error
- Section
- Standard Library [lib]
- Standard
- C++23
- Compilers
- MSVC 19.44, Clang 19.1 (MSVC STL), GCC 16.2, Clang 23.1 (libc++)
- Published
- Length
- 11 min read
In this answer9 clauses
- 1Return a value or an error
- 2Read a std::expected: what each accessor does
- 3What happens when you read the value of an error
- 4Why the error goes in std::unexpected
- 5Chain calls: and_then, transform, or_else, transform_error
- 6
std::expected<void, E>: nothing to return, but it can fail - 7What to use for E, and what it costs
- 8An ignored std::expected: which compilers warn
- 9std::expected, std::optional, an exception or an error code
Abstract
std::expected<T, E> (C++23) holds either the value a function produced or the error that stopped it, inside the object. Return the error as std::unexpected(e); test with if (r), then read *r or r.error(), each undefined behavior in the wrong state, or call r.value(), which throws std:: on an error. Use it when failing is ordinary and the caller needs the reason, std::optional when no reason is needed, and an exception when the caller cannot go on.

std::expected<T, E> (C++23, header <expected>) holds either the value a function produced or the error that stopped it, inside the object. Return the value, or the error wrapped in std::unexpected(e). Test it with if (r), then read *r or r.error(); reading the one it does not hold is undefined behavior (a hardened precondition in C++26), while r.value() throws std::. Use it when failing is ordinary and the caller needs the reason; use std::optional when no reason is needed, and an exception when the caller cannot go on.
This answer is written for C++23, which added std::expected (P0323R12) and its and_then, transform, or_else and transform_error (P2505R5). After it:
- C++26 (technically complete since March 2026):
*,->anderror()get hardened preconditions (P3471R4), and parts of<expected>become freestanding (P2833R2). - The working draft after C++26:
has_error(), the opposite ofhas_value()(P3798R1). None of the four compilers below had it, in C++23 or C++26 mode.
The samples were built with four compilers on three standard libraries: MSVC 19.44 and Clang 19.1 on Microsoft’s STL (Windows x64), GCC 16.2 with libstdc++ and Clang 23.1 with libc++ (Compiler Explorer, Linux x86-64). Each output shown is what all four printed, unless the text says otherwise. First releases are in Since When’s C++23 table; every member is on cppreference’s std::expected page.
What changed: a function that can fail returns an error code, and its result through an out-parameter std::expected<T, E>.
since C++23
Return a value or an error
The running example parses a port number and says why it failed; std::from_chars does the parsing (C++ string to int covers it):
enum class ParseError { empty, not_a_number, out_of_range };
std::expected<int, ParseError> parse_port(std::string_view text) {
if (text.empty())
return std::unexpected(ParseError::empty); // the error
int port = 0;
const char* last = text.data() + text.size();
auto [end, error] = std::from_chars(text.data(), last, port);
if (error == std::errc::result_out_of_range)
return std::unexpected(ParseError::out_of_range);
if (error != std::errc{} || end != last)
return std::unexpected(ParseError::not_a_number);
if (port < 1 || port > 65535)
return std::unexpected(ParseError::out_of_range);
return port; // the value
}
// ...
std::expected<int, ParseError> port = parse_port(text);
if (port)
std::cout << '"' << text << "\": port " << *port << '\n';
else
std::cout << '"' << text << "\": " << name(port.error())
<< '\n';— end example]
With name() returning each enumerator’s name, four inputs printed:
"8443": port 8443
"": empty
"eighty": not_a_number
"70000": out_of_range— end example]
Read a std::expected: what each accessor does
| You write | It holds a value | It holds an error |
|---|---|---|
if (r), r.has_value() |
true |
false |
*r, r->m |
the value | undefined behavior; C++26: hardened precondition |
r.value() |
the value | throws std::, which carries the error |
r.error() |
undefined behavior; C++26: hardened precondition | the error |
r.value_or(x) |
a copy of the value | x, converted to T |
r.error_or(e) |
e, converted to E |
a copy of the error |
value() is the checked read. Catch the exception by its type and read the error from it:
std::expected<int, ParseError> bad = parse_port("eighty");
// ...
try {
int port = bad.value(); // holds an error: throws
std::printf("port %d\n", port); // never printed
} catch (const std::bad_expected_access<ParseError>& e) {
std::printf("what(): %s\n", e.what());
std::printf("error(): %s\n", name(e.error()));
}— end example]
On Microsoft’s STL it printed:
what(): Bad expected access
error(): not_a_number— end example]
libstdc++’s what() reads bad access to std:: and libc++’s bad access to std::, so never compare the text. e.error() gave not_a_number on all four, and a catch (const std:: caught the exception too.
With exceptions turned off (-fno-exceptions), value() on an error cannot throw: built that way, GCC 16.2 and Clang 23.1 with libc++ ended the program with SIGABRT, libc++ after printing bad_expected_access was thrown in -fno-exceptions mode.
What happens when you read the value of an error
The standard does not require *r, r->m or r.error() to check: in C++23, a read of the state the expected does not hold is undefined behavior. C++26 makes has_value() a hardened precondition of * and ->, and !has_value() one of error() ([expected.object.obs], from P3471R4). A hardened implementation treats such a read as a contract violation; a non-hardened one leaves it undefined.
Asking for C++26 switched on no check in any of the three libraries. Each has its own switch, which works under C++23. What *r on an error and r.error() on a value did:
| Library | Without a flag | Switch | With the switch |
|---|---|---|---|
| Microsoft’s STL | not checked in a release build; checked in a debug build (/MDd) |
_MSVC_STL_HARDENING=1, from VS 2022 17.14 |
nothing printed; exit code 0xC0000409 |
| libstdc++ | checked at -O0 since GCC 15, not at -Og and up |
_GLIBCXX_ASSERTIONS |
Assertion '_M_has_value' failed. ('!_M_has_value' for error()) on stderr; SIGABRT |
| libc++ | not checked | _LIBCPP_HARDENING_MODE=, from LLVM 18 |
nothing printed; SIGILL |
Since GCC 15, libstdc++ turns its assertions on by itself in unoptimized builds (GCC 15 release notes); -D_GLIBCXX_NO_ASSERTIONS turns that default off. A Microsoft debug build reported operator*() called on a std:: for the same read, and stopped.
Why the error goes in std::unexpected
No constructor of std::expected takes a bare E as its error, so returning one does not compile:
std::expected<int, ParseError> parse_port(std::string_view text) {
if (text.empty())
return ParseError::empty; // error: not wrapped
// ...— end example]
All four rejected it. MSVC:
error C2440: 'return': cannot convert from 'ParseError' to 'std::expected<int,ParseError>'— end example]
When E converts to T, a bare error compiles and becomes the value. Two such pairs, a plain enum with int and const char* with std::string:
enum ParseError { empty, not_a_number, out_of_range }; // not a class
std::expected<int, ParseError> parse_port(std::string_view text) {
if (text.empty())
return empty; // compiles: the value 0
// ...
std::expected<std::string, const char*> find_setting(
const Settings& s, std::string_view key) {
auto it = s.find(key);
if (it == s.end())
return "missing key"; // compiles: the value
return it->second;
}— end example]
No compiler warned, and both calls report success:
parse_port(""): has_value() 1, *port 0
find_setting: has_value() 1, *host "missing key"— end example]
An enum class as E closes the first hole; writing std::unexpected on every error return closes both.
Chain calls: and_then, transform, or_else, transform_error
Four members pass a value or an error along without an if per step ([expected.object.monadic]):
| Member | Calls f when it holds |
f returns |
The result |
|---|---|---|---|
and_then(f) |
a value | an expected<U, E> with the same E |
what f returned, or the error passed on |
transform(f) |
a value | a plain U |
expected<U, E>: f’s result, or the error |
or_else(f) |
an error | an expected<T, G> with the same T |
what f returned, or the value passed on |
transform_error(f) |
an error | a plain G |
expected<T, G>: the value, or f’s result |
The running example gets a second step that fails for its own reason: reading the port from settings, where the key may be missing. With ifs, the ParseError becomes a ConfigError by hand:
enum class ConfigError { missing_key, bad_port };
using Settings = std::map<std::string, std::string, std::less<>>;
// ...
std::expected<std::string, ConfigError> find_setting(
const Settings& s, std::string_view key) {
auto it = s.find(key);
if (it == s.end())
return std::unexpected(ConfigError::missing_key);
return it->second;
}
// ...
std::expected<std::string, ConfigError> server_url(
const Settings& settings) {
std::expected<std::string, ConfigError> text =
find_setting(settings, "port");
if (!text)
return std::unexpected(text.error());
std::expected<int, ParseError> port = parse_port(*text);
if (!port)
return std::unexpected(ConfigError::bad_port); // by hand
return to_url(*port);
}
// ...
std::expected<std::string, ConfigError> url = server_url(s);
if (!url)
url = default_url(url.error());
std::cout << *url << '\n';— end example]
to_url(int) formats http://localhost: and the port; default_url(ConfigError) prints the error’s name and returns a std:: holding the URL for 8080, since or_else needs an expected back. With the members, the conversion moves into the and_then step:
ConfigError to_config_error(ParseError) {
return ConfigError::bad_port;
}
std::expected<std::string, ConfigError> server_url(
const Settings& settings) {
return find_setting(settings, "port")
.and_then([](const std::string& text) {
return parse_port(text).transform_error(to_config_error);
})
.transform(to_url);
}
// ...
std::expected<std::string, ConfigError> url =
server_url(s).or_else(default_url);
std::cout << *url << '\n';— end example]
Both versions printed this for a port of 8443, a missing key and eighty:
http://localhost:8443
(missing_key, using 8080) http://localhost:8080
(bad_port, using 8080) http://localhost:8080— end example]

The conversion sits inside the step because and_then keeps the error type: .and_then(parse_port) alone gives it a function whose error is ParseError where the chain carries ConfigError.
auto port_setting(const Settings& settings) {
return find_setting(settings, "port") // E is ConfigError
.and_then(parse_port); // error: E is ParseError
}— end example]
All four rejected it with a static_assert. GCC’s reads:
the function passed to std::expected<T, E>::and_then must return a std::expected with the same error_type— end example]
The four members reached each library later than std::expected itself:
| Library | std::expected |
The four members |
|---|---|---|
| libstdc++ | GCC 12.1 | GCC 13.1 |
| libc++ | 16 | 17 |
| Microsoft’s STL | VS 2022 17.3 | VS 2022 17.6 |
std::expected<void, E> : nothing to return, but it can fail
For a function that returns nothing on success, T is void and success is return {};:
std::expected<void, ConfigError> save_port(Settings& s,
std::string_view text) {
if (!parse_port(text))
return std::unexpected(ConfigError::bad_port);
s["port"] = text;
return {}; // success: nothing to return
}
// ...
std::expected<void, ConfigError> saved = save_port(s, "8443");
if (saved)
std::cout << "saved: " << s["port"] << '\n';
saved = save_port(s, "eighty");
if (!saved)
std::cout << "not saved: " << name(saved.error()) << '\n';
// ...
try {
save_port(s, "70000").value(); // returns void, or throws
} catch (const std::bad_expected_access<ConfigError>& e) {
std::cout << "caught: " << name(e.error()) << '\n';
}— end example]
It printed:
saved: 8443
not saved: bad_port
caught: bad_port— end example]
*savedcompiles, returnsvoidand does nothing when it holds a value; on an error it is the same wrong-state read as*r. There is nooperator->and novalue_or:saved.value_or(0)failed on all four, GCC withhas no member named 'value_or'; did you mean 'value'?.- The four chaining members work on it; the callables of
and_thenandtransformtake no argument.
What to use for E, and what it costs
- An
enum class, when the reasons are a closed list the caller switches on. std::error_code, when the reason comes from the system or a library that reports one;std::from_charsreports astd::errc, theecof its result.- A struct, when the caller needs context, such as where the text stopped and a message.
std::expected<int, std::error_code> parse_port(std::string_view text) {
int port = 0;
const char* last = text.data() + text.size();
auto [end, error] = std::from_chars(text.data(), last, port);
if (error != std::errc{}) // from_chars says why
return std::unexpected(std::make_error_code(error));
// ...
struct ParseFailure {
ParseError code;
std::size_t position; // where in the text it stopped
std::string message;
};
// ...
auto at = static_cast<std::size_t>(end - text.data());
if (error == std::errc{} && end != last)
return std::unexpected(ParseFailure{
ParseError::not_a_number, at,
"'" + std::string(1, *end) + "' is not a digit"});— end example]
For "80a", the three versions printed this on Microsoft’s STL; on Linux, message() reads Invalid argument, with the same category and value:
"80a"
enum: not_a_number
code: generic:22 invalid argument
struct: not_a_number at 2: 'a' is not a digit— end example]
The value or the error is stored inside the object, “nested within” it ([expected.object.general]), and the standard fixes no size. In release builds on x64, Microsoft’s STL and libstdc++ agreed on every sizeof; libc++, whose std::string is 24 bytes to their 32, differs where a string is involved:
E |
sizeof(E) |
expected<int, E> |
expected<std:: |
expected<void, E> |
|---|---|---|---|---|
ParseError |
4 | 8 | 40; libc++ 32 | 8 |
std::error_code |
16 | 24 | 40; libc++ 32 | 24 |
ParseFailure |
48; libc++ 40 | 56; libc++ 48 | 56; libc++ 48 | 56; libc++ 48 |
- In the same builds, copying the error into an expected allocated nothing for the enum or
std::error_code. AParseFailureallocated when its message outgrew the string’s own buffer: a 9-character message nowhere, an 18-character one on Microsoft’s STL and libstdc++, a 26-character one on all three. - The standard makes the copy constructor and destructor trivial when
T’s andE’s are, and LWG 4026 adds the assignments in C++26.expected<int, ParseError>was trivially copyable on Microsoft’s STL and libstdc++ 16.2, and not on libc++ 23, whose copy assignment is not trivial. - A move-only
Eworks forerror(),std::move(r).error(),value_or, and the rvalue forms oferror_orand the chaining members.value()needs a copyableEeven on an rvalue (the standard mandates it for both overloads), and so doerror_orandand_thenon an lvalue, which copy the error. With astd::unique_ptrinE, all of these failed on all four (libc++:error_type has to be copy constructible).
An ignored std::expected: which compilers warn
The standard does not mark std::expected [[nodiscard]], so a dropped result warns only where the library adds the attribute. A call to parse_port whose result is ignored:
| Compiler and library | No attribute of your own | [[nodiscard]] on the function |
|---|---|---|
| MSVC 19.44, Microsoft’s STL | warning C4834 | warning C4834 |
| Clang 19.1, Microsoft’s STL | -Wunused-result |
-Wunused-result |
| GCC 16.2, libstdc++ | -Wunused-result |
-Wunused-result |
| Clang 23.1, libc++ | no warning | -Wunused-result |
Microsoft’s STL has marked the type since Visual Studio 2022 17.14, and GCC 16.2’s header declares class [[nodiscard]] expected. libc++ marks members such as value() and error(), not the type. Mark your own functions, and cast to void where dropping the result is deliberate, which silenced all four:
[[nodiscard]] std::expected<int, ParseError> parse_port(
std::string_view text) {
// ...
parse_port("eighty"); // warns: [[nodiscard]] above
(void)parse_port("8443"); // deliberate: no warning— end example]
std::expected, std::optional, an exception or an error code
| Situation | Use | Since |
|---|---|---|
| No result is a normal outcome and needs no reason | std::optional<T> |
C++17 |
| Failing is an ordinary outcome and the caller needs the reason | std::expected<T, E> |
C++23 |
| The caller needs the reason, and exceptions are turned off | std::expected<T, E>, read with if (r), *r and error(), never value() |
C++23 |
| The function cannot do its job and the caller cannot go on | an exception | every standard |
| No C++23 | tl::expected, a single-header library, or an error code used systematically |
C++11 for tl::expected |
| One of several types, none of them an error | std::variant |
C++17 |
The C++ Core Guidelines draw the same line. I.10 says to “Use exceptions to signal a failure to perform a required task”, and its note says a refused connection “is not by itself an error”: return “a result that the caller should always check”. E.27 covers code that cannot throw: “use error codes systematically”.
More answers about the library’s types are in Standard library.
Questions
What is std::unexpected?
The C++23 wrapper that marks a returned value as the error: return std::unexpected(e); makes the std::expected hold e as its error, not its value. Its type is deduced from the argument, so std:: is a std::. To build the error in place instead, pass std::unexpect first: std::expected<int, ParseError> r(std::unexpect, ParseError::); holds the error.
Can I use std::expected in C++20 or C++17?
No: std::expected and std::unexpected are C++23, and under -std=c++20 (/std:c++20 on MSVC) all four compilers rejected them; GCC says ‘std::expected’ is only available from C++23 onwards. Before C++23, tl::expected is a single-header implementation for C++11, 14 and 17 with the interface of an earlier draft of the proposal (P0323R3); its README documents map and map_error, and its header also has the standard’s names: transform since v1.0.0 and transform_error since v1.1.0. Boost.Outcome’s result can be configured to act like it.
Can std::expected hold a reference?
No. A reference as T is ill-formed, and all four compilers rejected std::expected<int&, ParseError>, each in its own words; Microsoft's STL says 'T must not be a reference type'. Return a pointer, as in std::expected<std::string*, ConfigError>, or a std:: instead: with either, the caller wrote into the map entry it referred to.
How do I convert a std::expected to a std::optional?
By hand: there is no conversion in either direction, and std::optional<int> o = r; did not compile on any of the four compilers. Drop the reason with r ? std::optional(*r) : std::nullopt, and supply one going back with o ? std::expected<int, ParseError>(*o) : std::.
What does a default-constructed std::expected hold?
A value-initialized value, not an error: std::expected<int, ParseError> r; holds 0, and a default std::expected<std::string, ParseError> holds an empty string. When T has no default constructor, the expected has none either. To start in the error state, initialize it from std::unexpected(e).
Can I compare a std::expected with a value or an error?
r == 8080 is true when r holds the value 8080 and false when it holds an error; r == std:: is true when it holds that error. A bare error is compared with the value: with an enum class ParseError, r == ParseError::empty did not compile on any of the four compilers. With a plain enum it compiled, false for an expected holding the error empty and true for one holding the value 0. Compare errors through std::unexpected(e); two expecteds holding equal values, or equal errors, compare equal.
Sources
- std::expectedcppreference.com
- [expected.object.obs] ObserversC++ standard working draft
- [expected.object.monadic] Monadic operationsC++ standard working draft
- P0323R12: std::expectedWG21
- P2505R5: Monadic functions for std::expectedWG21
- P3471R4: Standard library hardeningWG21
- I.10: Use exceptions to signal a failure to perform a required taskC++ Core Guidelines
- expected: C++11/14/17 std::expected with functional-style extensionstl::expected on GitHub