June 3, 2026
Don’t Hide Failure in C++ APIs: std::optional vs std::expected:
std::optional and std::expected are both useful modern C++ return types. But they should not be used for the same kind of problem.
Both can represent a result that may not contain a value. Both make APIs cleaner than raw pointers, magic numbers or output parameters. But they communicate different intent.
std::optional says: there may be no value.
std::expected says: this operation may fail, and the caller may need the reason.
This difference looks small in syntax, but it has a large impact on API design. When you use std::optional for an operation that can fail in several meaningful ways, you hide information from the caller.
Note
std::expected is a C++23 type. If your project is still on C++17 or C++20, the same design idea is often implemented with libraries such as tl::expected or a project-specific result type.

The key difference before we continue
std::optional<T> is a wrapper around a value that may or may not exist. It is useful when “no value” is a valid result, not necessarily a failure.
std::optional<int> findPort(std::string_view name)
{
if (name == "http") {
return 80;
}
return std::nullopt;
}
The caller only checks whether the value exists:
if (auto port = findPort("http")) {
connectTo(*port);
} else {
useDefaultPort();
}
std::expected<T, E> also returns either a value or something else, but that “something else” is an error. It is useful when the caller should know why the operation failed.
enum class ParseError {
EmptyInput,
InvalidNumber
};
std::expected<int, ParseError> parsePort(std::string_view text)
{
if (text.empty()) {
return std::unexpected(ParseError::EmptyInput);
}
if (text == "8080") {
return 8080;
}
return std::unexpected(ParseError::InvalidNumber);
}
Now the caller can react to the failure reason:
auto port = parsePort(input);
if (!port) {
report(port.error());
return;
}
connectTo(*port);
Tip
A good first question is: does the caller need to know why there is no value? If yes, std::expected is usually a better fit than std::optional.
The problem with hiding failure
In real projects I often see APIs like this:
std::optional<Config> loadConfig(std::filesystem::path path);
At first glance this looks modern and clean. No raw pointer. No output parameter. No magic return code.
But the API hides an important question: why was there no config?
- Was the file missing?
- Was the file unreadable?
- Was the JSON invalid?
- Was a required field missing?
- Was the config version unsupported?
All of these cases are collapsed into the same result: std::nullopt.

That may be acceptable for a cache lookup. It is usually not acceptable for configuration loading, validation, parsing, device communication, file I/O, protocol handling or anything where debugging the failure matters.
When std::optional is a good fit
Use std::optional<T> when absence is expected and does not need a detailed explanation.
std::optional<User> findUserById(UserId id);
std::optional<Token> getCachedToken();
std::optional<Temperature> lastMeasuredTemperature();
These APIs say: there may be a value, but not having one is not necessarily an error.
A missing user can be a normal lookup result. A cache can be empty. A sensor may not have produced a first reading yet.
if (auto user = findUserById(id)) {
showProfile(*user);
} else {
showUserNotFound();
}
This is a good use of std::optional. The API is simple because the domain is simple.
When std::expected is a better fit
Use std::expected<T, E> when failure is part of the contract and the caller should be able to react to it.
enum class ConfigError {
FileNotFound,
PermissionDenied,
InvalidJson,
MissingRequiredField,
UnsupportedVersion
};
std::expected<Config, ConfigError>
loadConfig(std::filesystem::path path);
Now the function communicates more than success or absence. It communicates the reason why the operation could not produce a value.
auto config = loadConfig("/etc/my-app/config.json");
if (!config) {
switch (config.error()) {
case ConfigError::FileNotFound:
createDefaultConfig();
break;
case ConfigError::PermissionDenied:
showPermissionError();
break;
case ConfigError::InvalidJson:
case ConfigError::MissingRequiredField:
case ConfigError::UnsupportedVersion:
showConfigIsInvalid();
break;
}
return;
}
startApplication(*config);
This call site is slightly longer, but it is also honest. It shows that different failures lead to different decisions.

A common smell: optional plus logging
One common workaround is to log the error inside the function and still return std::optional.
std::optional<Config> loadConfig(std::filesystem::path path)
{
auto file = openFile(path);
if (!file) {
logError("could not open config file");
return std::nullopt;
}
auto config = parseConfig(*file);
if (!config) {
logError("could not parse config file");
return std::nullopt;
}
return config;
}
This is often a sign that the API wants to return an error but the type does not allow it.
Warning
Logging is not a replacement for error handling. Logs are useful for diagnostics, but they do not help the caller decide what to do next.
The caller still sees only this:
auto config = loadConfig(path);
if (!config) {
// Why did it fail?
}
If the caller needs to branch based on the reason, use std::expected.
Do not put everything into std::expected
The opposite mistake is also possible. Not every missing value is a failure.
std::expected<User, LookupError> findUserById(UserId id);
This may be too heavy if “user not found” is a normal result and there is no useful error information to carry.
In that case, this is simpler:
std::optional<User> findUserById(UserId id);
std::expected should not be used just because it looks more explicit. Use it when the error side is meaningful.
A better rule of thumb
Before choosing the return type, ask this question:
Tip
If the caller only needs to know whether a value exists, use std::optional<T>. If the caller needs to know why the value could not be produced, use std::expected<T, E>.
Some practical examples:
- Cache miss:
std::optional<T>, because no detailed reason is usually needed. - Map lookup:
std::optional<T>, because a missing key can be a normal result. - Parsing user input:
std::expected<T, ParseError>, because the caller should know what was invalid. - Opening a file:
std::expected<File, FileError>, because a missing file and a permission error are different. - Loading configuration:
std::expected<Config, ConfigError>, because different failures may need different handling.
Keep the error type useful
The error type in std::expected<T, E> should help the caller make a decision.
This is often not enough:
std::expected<Config, bool> loadConfig(std::filesystem::path path);
A boolean error gives no useful information. It only says that something failed.
This is usually better:
enum class ConfigError {
FileNotFound,
PermissionDenied,
InvalidSyntax,
MissingRequiredField
};
std::expected<Config, ConfigError>
loadConfig(std::filesystem::path path);
For some APIs, an enum is enough. For others, you may want a small struct:
struct ParseError {
ParseErrorCode code;
std::size_t line;
std::size_t column;
};
std::expected<Config, ParseError>
parseConfig(std::string_view text);
The goal is not to create a complex error hierarchy. The goal is to return enough information for the next layer to make a useful decision.
Watch the call site
Return types are not only about function declarations. They shape how the caller reads the code.
With std::optional, the call site says:
if (!value) {
useFallback();
}
With std::expected, the call site says:
if (!value) {
handle(value.error());
}
That extra .error() is not noise when the failure matters. It is documentation in the code.
Common mistakes
A few small mistakes make APIs harder to understand than they need to be.
- Using
std::optionalfor many different failure modes. The caller gets no context. - Logging internally and returning
std::nullopt. The log may help later, but the caller still cannot decide now. - Using
std::expected<T, bool>. This often hides the reason again. - Using
std::expectedfor normal absence. Not every missing value is an error. - Designing only from the function body. Look at the call site before deciding that the API is clean.
The key idea
If you remember only one thing, remember this:
Tip
std::optional is for a value that may not exist. std::expected is for an operation that may fail and should explain why.
std::optional is not a general error-handling mechanism. It is a good way to express that a value may be absent.
std::expected is a better choice when an operation can fail and the caller needs the reason.
Good C++ APIs do not only return values. They communicate intent.
Source code
The examples from this article are available as a small C++23 CMake project.