Last Update:
Using std::expected from C++23
Table of Contents
In this article, we’ll go through a new vocabulary type introduced in C++23. std::expected
is a type specifically designed to return results from a function, along with the extra error information.
Motivation
Imagine you’re expecting a certain result from a function, but oops… things don’t always go as planned:
/*RESULT*/ findRecord(Database& db, int recordId) {
if (!db.connected())
return /*??*/
auto record = db.query(recordId);
if (record.valid) {
return record;
return /*??*/
}
What can you do? What should you return or output through /*RESULT*/
?
Here are a few options:
- Throw an exception,
- Terminate the application,
- Use
std::optional
to return a value or “nothing” to indicate an error, - Use a separate struct like
my_func_result
that would store the value and also an extra error message, - Use
std::variant <ValueType, Error>
so that it’s type-erased and occupies less space, - Use some ugly output pointer/reference to output the value and return an error code
In the case of findRecord
teminating an application is probably an overkill. std::optional
only indicates if we have a value or not, and that may not be enough for some scenarios.
That’s where std::expected
comes to the rescue. It’s a clever way to say,
I’m expecting this value, but hey, I’m also prepared for something unexpected!
Here’s why std::expected
is a great option:
- Clearer Error Handling: using return codes or exceptions to handle errors is a common technique, but sometimes, this can make your code look a bit messy, or you might miss an error sneaking by. With
std::expected,
errors get their spotlight. - The right vocabulary type for the job:
std::expected
is something betweenstd::optional
andstd::variant
, but specifically designed to handle errors. - Rich Error Info:
std::expected
lets you pack a whole bunch of useful info into the result type. - Efficiency:
std::expected
is designed to be efficient, avoiding some of the performance hits you might get with exceptions. It’s also an excellent choice for environments where exceptions are not an option. - Familiarity: If you’ve worked with other languages like Rust or Haskell,
std::expected
will be very familiar to you. It’s similar to Rust’sResult
or Haskell’sEither.
We can rewrite the initial findRecord
function and apply std::expected
:
std::expected<DatabaseRecord, std::string> findRecord(Database& db, int recordId) noexcept {
if (!db.connected())
return std::unexpected("DB not connected");
auto record = db.query(recordId);
if (record.valid)
return record;
return std::unexpected("Record not found");
}
int main() {
Database myDatabase;
auto result = findRecord(myDatabase, 1024);
if (result)
std::cout << "got record " << result.value().valid << '\n';
else
std::cout << result.error();
}
Run @Compiler Explorer.
In the above example, the function returns DatabaseRecord
or std::string
to indicate an error. If the result is invalid, then we can access the error message via the error()
member function.
Use Cases
Here are the places where std::expected
can be used. I’d say there are three core use cases:
- Enhanced Function Return Types - For functions that might fail, use
std::expected
to return either a successful result or a detailed error, making the outcomes clear and explicit. - Complex Process Error Handling - In multi-step processes where each step could fail,
std::expected
encapsulates the success or failure of each stage, simplifying error tracking and handling. - Functional Programming Patterns - Thanks to monadic extensions, you can use functional programming styles for operations that might succeed or fail, similar to ‘Either’ in Haskell or ‘Result’ in Rust.
One comment is that std::expected
is similar to Rust’s “Result” and I think the name “Result” conveys more information about where it’s used. As you can see, std::expected
is closely related to returning results from functions. I don’t think we can/should use it as data members. For data members, it’s better to rely on std::optional
or std::variant
.
After some basic introduction, let’s have a look at some details,
Creating std::expected
objects
std::expected
is quite flexible when it comes to creating and initializing its instances. Let’s have a look at the options:
Default Construction
A std::expected
object can be default constructed. This initializes the object to contain the expected value, which is default constructed.
std::expected<int, std::string> result;
The value stored is 0
. See @Compiler Explorer.
Direct Initialization with Expected Value
You can directly initialize std::expected
with a value of the expected type.
std::expected<int, std::string> success(42);
The value stored is 42
. See @Compiler Explorer.
In-Place Construction of the Expected Value
For complex types, or when you want to directly construct the expected value in place, use std::in_place
and provide the constructor arguments.
std::expected<std::vector<int>, std::string> success(std::in_place, {1, 2, 3});
std::expected<std::pair<bool, bool>, int> success(std::in_place, true, false);
Initialization with an Unexpected Value
To represent an error or unexpected situation, you can initialize std::expected
with std::unexpected
. The argument passed to std::unexpected
should be of the error type.
std::expected<int, std::string> error(std::unexpected("Error occurred"));
In-place Initialization with an Unexpected Value
There’s also an option to use in-place initialization of the error value:
std::expected<int, std::string> error{std::unexpect, "error"};
Notice that std::unexpect
is an instance of the type std::unexpect_t
, and this is a very similar name to std::unexpected
. Easy to confuse those types.
Copy and Move Construction
For completeness, std::expected
objects can be copy and move-constructed.
std::expected<int, std::string> copy = success;
std::expected<int, std::string> moved = std::move(error);
Assignment
std::expected
supports both copy and move assignment. It can be assigned from values of the expected type or from std::unexpected
.
std::expected<int, std::string> result;
result = 10;
result = std::unexpected("New error");
Accessing the Stored Value or Error in std::expected
Once you have a std::expected
object, accessing its contained value or error is straightforward but flexible, allowing you to handle each scenario appropriately. Here are the primary ways to access the stored value or error, along with examples:
Checking for a Value
Before accessing the contained value, it’s wise to check whether the object holds the expected value or an error.
Using has_value()
Method:
std::expected<int, std::string> result = getSomeResult();
if (result.has_value())
std::cout << "Success: " << *result << '\n';
else
std::cout << "Error: " << result.error() << '\n';
Implicit Conversion to bool
:
std::expected
can be implicitly converted to bool,
indicating the presence of an expected value.
if (result)
std::cout << "Success: " << *result << '\n';
Accessing the Expected Value
Using operator*
or operator->
: Directly dereference the std::expected
object to access the expected value, assuming the value is present (UB if not).
std::cout << "Value: " << *result << '\n';
Using value()
: This method returns a reference to the contained value. If the object holds an error, it throws std::bad_expected_access<E>
.
int main() {
std::expected<int, std::string> result{std::unexpected("")};
try {
std::cout << "Value: " << result.value() << '\n';
} catch (const std::bad_expected_access<std::string>& e) {
std::cout << "Caught error: " << e.what() << '\n';
}
}
Getting Value with a Default
Using value_or()
Returns the contained value if available; otherwise, it returns a default value provided as an argument.
std::expected<int, std::string> result{std::unexpected("")};
int value = result.value_or(0); // Returns 0 if result holds an error
std::cout << "Value: " << value << '\n';
See @Compiler Explorer.
Accessing the Error
Using error()
retrieves the stored error. The behavior is undefined if has_value()
is true
.
std::expected<int, std::string> result{std::unexpected("")};
if (!result)
std::cout << "Error: " << result.error();
Modifying the Contained Value
If std::expected
contains a value, you can modify it directly through a dereference.
std::expected<int, std::string> result{32};
if (result)
*result += 10;
The same can be achieved via the .value()
function, as it returns a non-const reference.
Modifying the Contained Error
There’s also an option to modify the error:
std::expected<int, std::string> result{std::unexpected{"err"}};
if (!result)
result.error() += std::string{" in the filesystem"};
Requirements on the Stored Value and Error Types in std::expected
So far, the examples have used some regular types as a value or error, but let’s see some details on the exact requirements. In short, std::expected
is designed to be flexible, but there are some constraints on the types it can hold:
- The value type
T
should be an object type orvoid
. This means fundamental types, classes, and pointers can be used, but functions, C-style arrays, and references cannot. - It should satisfy the Destructible requirements
std::expected
does not allowT
to be anotherstd::expected
type,std::in_place_t
, orstd::unexpect_t
.
For example, you cannot use references, but std::reference_wrapper
is fine:
//std::expected<int&, int> ptr2; // error
int v = 10;
std::expected<std::reference_wrapper<int>, int> ref{std::ref(v)};
Or you cannot use functions, but std::function
is fine:
// std::expected<void(), std::string> invalidFunction; // err
// fine:
std::expected<std::function<void()>, std::string> validFunction;
validFunction = []{};
You cannot use std::unexpected
as the expected type:
// std::expected<std::unexpected<std::string>, std::string> invalidExpected; // err!
As for arrays. C-style arrays are invalid, but std::array
is fine:
// std::expected<int[10], std::string> invalidArray; // err
std::expected<std::array<int, 10>, int> validArray; // fine
There’s also an intriguing case with void
, as it can be used for the value type:
std::expected<void, std::string> performAction(bool condition) noexcept {
if (condition)
return {};
else
return std::unexpected("Action failed");
}
int main() {
if (auto result = performAction(false); result)
std::cout << "Action performed successfully\n";
else
std::cout << "Error: " << result.error() << '\n';
}
See @Compiler Explorer.
As for the Error Type:
- The error type
E
must also be an object type and satisfy the Destructible requirements. - It should be a valid type for use with
std::unexpected
, and semantically meaning it should be suitable for holding error information. Typically, this can be an error code, a string, or a custom error class. - The error type
E
should not be an array type (C-style), a non-object type (like function types), or a cv-qualified (const
orvolatile
) version of these.
Functional extensions
In my article about How to Use Monadic Operations for `std::optional` in C++23 - C++ Stories we learned about functional/monadic extensions. And what’s cool is that in C++23, similar functionality is also applied to std::expected
. If you know how to use it for std::optional,
then you can also apply this knowledge to our expected type.
The feature was accepted as “Monadic operations for std::expected - P2505R5”.
Here are the member functions:
and_then()
transform()
or_else()
transform_error()
(this one is not available for optional)
Those functions are a distinct and significant topic, so let’s wait for a separate article.
A basic example
To conclude our journey, let’s pick a standard case of number conversion and let’s use std::expected
to convey the error message. The small twist with this example is that I used std::string to pass the error, and I “translate” error codes into strings:
#include <charconv>
#include <expected>
#include <string>
#include <system_error>
#include <iostream>
std::expected<int, std::string> convertToInt(const std::string& input) noexcept {
int value{};
auto [ptr, ec] = std::from_chars(input.data(), input.data() + input.size(), value);
if (ec == std::errc())
return value;
if (ec == std::errc::invalid_argument)
return std::unexpected("Invalid number format");
else if (ec == std::errc::result_out_of_range)
return std::unexpected("Number out of range");
return std::unexpected("Unknown conversion error");
}
int main() {
std::string userInput = "111111111111111";
auto result = convertToInt(userInput);
if (result)
std::cout << "Converted number: " << *result << '\n';
else
std::cout << "Error: " << result.error() << '\n';
}
While we could use std::optional
, the version with std::expected
conveys more information about the error.
Summary
I hope you like this introduction to std::expected
from C++23.
In this text, I showed you how to create std::expected
instances and how to access its value or error. Then, we went through a couple of examples.
There’s still more to describe, so stay tuned for more articles, for example, about refactoring and monadic extensions.
See the next one about internals of std::expected
Back to you
- Have you played with
std::expected
? - Have you worked with similar types in other languages or libraries?
Share your comments below.
Notes
I've prepared a valuable bonus for you!
Learn all major features of recent C++ Standards on my Reference Cards!
Check it out here: