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.

optionalvsexpected

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.

hiddenfailureproblem

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.

expectedmakesfailurereasonsisible

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::optional for 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::expected for 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.