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
  1. 1Return a value or an error
  2. 2Read a std::expected: what each accessor does
  3. 3What happens when you read the value of an error
  4. 4Why the error goes in std::unexpected
  5. 5Chain calls: and_then, transform, or_else, transform_error
  6. 6std::expected<void, E>: nothing to return, but it can fail
  7. 7What to use for E, and what it costs
  8. 8An ignored std::expected: which compilers warn
  9. 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::bad_expected_access<E> 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.

Two std::expected objects as cells. Top, a value: a hatched storage cell beside a flag cell with a magenta mark. Bottom, an error: a smaller pale magenta block in the same storage, the flag empty.
Figure 1 — A std::expected is one block that holds the value or the error, and a flag that says which.

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::bad_expected_access<E>. 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): *, -> and error() get hardened preconditions (P3471R4), and parts of <expected> become freestanding (P2833R2).
  • The working draft after C++26: has_error(), the opposite of has_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):

[Example 1:
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:

[Example 2:
"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::bad_expected_access<E>, 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:

[Example 3:
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:

[Example 4:
what(): Bad expected access
error(): not_a_number

— end example]

libstdc++’s what() reads bad access to std::expected without expected value and libc++’s bad access to std::expected, so never compare the text. e.error() gave not_a_number on all four, and a catch (const std::exception&) 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=_LIBCPP_HARDENING_MODE_FAST, 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::expected that contains an error, not a value 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:

[Example 5:
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:

[Example 6:
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:

[Example 7:
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:

[Example 8:
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:

[Example 9:
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::expected<std::string, ConfigError> holding the URL for 8080, since or_else needs an expected back. With the members, the conversion moves into the and_then step:

[Example 10:
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:

[Example 11:
http://localhost:8443
(missing_key, using 8080) http://localhost:8080
(bad_port, using 8080) http://localhost:8080

— end example]

Three boxes in a row: find_setting; and_then, which runs parse_port and then transform_error; transform(to_url); then an arrow labelled expected. Below, missing_key drops from find_setting; not_a_number drops from and_then through a magenta transform_error box, becoming bad_port. Both run along a lower track into the same arrow.
Figure 2 — A step that fails sends its error down the other track, past the steps that remain, to the caller.

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.

[Example 12:
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:

[Example 13:
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 {};:

[Example 14:
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:

[Example 15:
saved: 8443
not saved: bad_port
caught: bad_port

— end example]

  • *saved compiles, returns void and does nothing when it holds a value; on an error it is the same wrong-state read as *r. There is no operator-> and no value_or: saved.value_or(0) failed on all four, GCC with has no member named 'value_or'; did you mean 'value'?.
  • The four chaining members work on it; the callables of and_then and transform take 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_chars reports a std::errc, the ec of its result.
  • A struct, when the caller needs context, such as where the text stopped and a message.
[Example 16:
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:

[Example 17:
"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::string, E> 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. A ParseFailure allocated 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 and E’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 E works for error(), std::move(r).error(), value_or, and the rvalue forms of error_or and the chaining members. value() needs a copyable E even on an rvalue (the standard mandates it for both overloads), and so do error_or and and_then on an lvalue, which copy the error. With a std::unique_ptr in E, 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:

[Example 18:
[[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::unexpected(ParseError::empty) is a std::unexpected<ParseError>. To build the error in place instead, pass std::unexpect first: std::expected<int, ParseError> r(std::unexpect, ParseError::not_a_number); 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::reference_wrapper<std::string> 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::unexpected(ParseError::not_a_number).

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::unexpected(ParseError::empty) 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.

Read next

  1. std::optional in C++: how to use it and when not toStandard Library · Miriam Castellanos · written for C++17
  2. std::println in C++23: print without std::cout or printfStandard Library · Miriam Castellanos · written for C++23
  3. How to write comparators for std::sort and std::mapStandard Library · Miriam Castellanos · written for C++20