Why std::expected Belongs in Your Toolkit

When I started working on a configuration parser a few years ago, I quickly grew tired of juggling exception stacks or ad‑hoc error‑code enums. Every function either threw, returned a sentinel value, or delegated to a global error object, and the call chain became a maze of if checks and try/catch blocks. The turning point came when C++23 introduced std::expected. It gives us a value‑or‑error type that reads like a natural return type for functions that can fail.

Think of it as a std::optional that knows how to carry an error description. Instead of guessing whether a nullptr means "not found" or "allocation failed", the caller sees a clear std::expected. The compiler even forces you to handle both branches, which makes the code more robust and easier to reason about.

A Real‑World Scenario: Loading Application Settings

Our application needs to read a JSON file that describes logging levels, database connections, and feature flags. In the old version we did something like this:

  • Parse the file with a third‑party library that returns a raw std::string.
  • Manually check for parse errors and set a global bool g_parseOk.
  • Scatter if (!g_parseOk) abort(); throughout the code.

The result was a maintenance nightmare. With std::expected we can express the intent directly in the function signature. The parser either yields a fully‑formed Config object or an error message, and the caller can decide what to do without extra global state.

The Snippet: Returning std::expected from a Parser

// config_parser.h
#pragma once
#include <expected>
#include <string>
#include "config.h"

// Returns either a valid Config or an error description.
std::expected<Config, std::string> parse_config(const std::string& file_path);

// config_parser.cpp
#include "config_parser.h"
#include <fstream>
#include <nlohmann/json.hpp>

std::expected<Config, std::string> parse_config(const std::string& file_path) {
    // Open the file; throw on OS errors – std::expected can wrap that too.
    std::ifstream file(file_path);
    if (!file) {
        return std::unexpected("Unable to open config file: " + file_path);
    }

    nlohmann::json j;
    try {
        file >> j;
    } catch (const std::exception& e) {
        return std::unexpected("JSON parse error: " + std::string(e.what()));
    }

    Config cfg;
    // Simple mapping – real code would be more elaborate.
    if (j.contains("log_level")) {
        cfg.log_level = j["log_level"].get<std::string>();
    }
    if (j.contains("db_host")) {
        cfg.db_host = j["db_host"].get<std::string>();
    }
    // … more fields …

    return cfg; // implicit conversion to std::expected
}

The header declares the function returning std::expected<Config, std::string>. In the implementation, any failure is expressed with std::unexpected. Success is simply returning the Config object.

Why This Approach Is Powerful

  • Clarity. The return type itself documents the two possible outcomes. No more magic numbers or nullptr checks.
  • Composability. You can chain operations using and_then and or_else. For example:
    auto result = parse_config("/etc/myapp.json")
        .and_then([](const Config& c) { return validate(c); })
        .or_else([](const std::string& err) {
            logger::error(err);
            return std::expected<Config, std::string>(std::unexpected("fallback"));
        });
  • Exception safety. The function can still throw for truly unexpected problems (e.g., out‑of‑memory). The caller can decide whether to catch them or let them propagate.
  • Debugging aid. When the expectation fails, you have a human‑readable error string attached. No need to maintain separate error‑code enums and lookup tables.

Tips for Production Use

Even though std::expected is convenient, a few best‑practice pointers keep the code clean:

  1. Pick a sensible error type. std::string works for ad‑hoc messages, but for larger projects consider a enum class ErrorCode paired with std::string for details.
  2. Don't hide exceptions inside std::expected unless you have a good reason. If a function can throw, let it throw; let the caller decide to catch or not.
  3. Use std::unexpected only for error conditions. Returning an empty std::expected (i.e., std::expected<Config, std::string>{}) is ill‑formed and should be avoided.
  4. When mixing std::expected with existing APIs, employ adapters such as std::expected<T, E>::transform_error to convert error types.
  5. Remember that std::expected is move‑only if the contained type is move‑only. Declare copy constructors/assignment operators only when you need them.

Pro tip: In a large codebase, centralize error creation with a helper like make_unexpected. This ensures consistency and makes future changes to the error type straightforward.

Bringing It All Together

Here is a short driver that shows how the parser integrates into the rest of the application:

// main.cpp
#include "config_parser.h"
#include <iostream>

int main() {
    auto config = parse_config("/etc/myapp.json");
    if (!config) {
        std::cerr << "Failed to load configuration: " << config.error() << '\n';
        return EXIT_FAILURE;
    }

    // Use config.log_level, config.db_host, etc.
    std::cout << "Configuration loaded successfully.\n";
    return EXIT_SUCCESS;
}

The caller checks has_value() via the boolean conversion, prints the error via error(), and proceeds only when the expectation holds. No global state, no sentinel values—just a clean flow.

Conclusion

Using std::expected transforms error handling from a bookkeeping chore into a first‑class citizen of your type system. It encourages you to think about failure upfront, makes the API self‑documenting, and gives you powerful composition tools like and_then and or_else>. Whether you are parsing configs, performing I/O, or building a library, adopting std::expected> can reduce the cognitive load on your team and lead to more reliable code.p>

I started with a handful of if (!g_parseOk) abort();> lines, and now my functions return std::expected> wherever they might fail. The change feels natural, and the resulting code reads like a story rather than a checklist. Give it a try in your next project—you’ll likely find yourself reaching for it more often than you expected.p>