Why I Switched from Exceptions to std::expected

When I first started using C++23, I was surprised how quickly I grew accustomed to std::expected. For years I relied on exceptions for error propagation and manual error codes for performance‑critical paths. Both approaches have merit, but they each carry drawbacks: exceptions can obscure control flow, while error codes force callers to check a series of if statements. In a project that deals with network I/O and file parsing, I needed a way to express that a function either returns a value or a descriptive error without sacrificing type safety. std::expected gave me exactly that.

A Real‑World Scenario: Parsing Configuration Files

Our service reads a JSON configuration at startup. The parsing routine can fail for many reasons—malformed JSON, missing required fields, or permission errors. The old code looked like this:

bool ParseConfig(const std::string& data, Config& out) {
    // ... parse logic ...
    if (parse_failed) {
        std::cerr << "Parse error at line " << line_num;
        return false;
    }
    out = parsed;
    return true;
}

Every caller had to remember to check the return value, and the error information was lost after the first error. Switching to std::expected<Config, std::string> let us express the intent directly:

std::expected<Config, std::string> ParseConfig(const std::string& data) {
    // ... parse logic ...
    if (parse_failed) {
        return std::unexpected("Parse error at line " + std::to_string(line_num));
    }
    return parsed;
}

Now the caller can write auto cfg = ParseConfig(data) and either use cfg.value() or handle the error via cfg.error(). The type system guarantees that we cannot accidentally ignore the error.

What std::expected Actually Is

std::expected is a template that holds either a value of type T or an error of type E. It mirrors std::optional​> but adds an explicit error type. The interface includes:p>

  • std::expected<T, E> obj​] value initializationli>
  • bool has_value() const noexceptcode> – tells us if we have a valueli>
  • T& value() const​] – throws if errorli>
  • E& error() const​] – throws if valueli>
  • Conversion to bool​> via operator bool() const noexceptcode>li> ul>

    Because the error type is part of the template parameters, the compiler can warn us about mismatched handling. This is a huge win over raw std::string​> error messages.p>

    Composing Expected Valuesh2>

    One of the most compelling features is the ability to chain operations. If we have a function that reads a file and returns std::expected<std::string, std::string>​de>, we can pipe it through a parser:p>

    Using and_thencode> and or_elsecode> lets us write flat, readable error‑propagation chains that look like synchronous code.​n>p> blockquote>

    auto read_file(const std::string& path) {
        // returns std::expected<std::string, std::string>
    }
    
    auto parse_json(const std::string& text) {
        // returns std::expected<Config, std::string>
    }
    
    auto config = read_file(path)
              .and_then(parse_json)
              .or_else([](const std::string& err) {
                  std::cerr << "Failed to load config: " << err;
                  return std::expected<Config, std::string>{std::unexpect, "fallback"};
              });code>pre>
    

    Notice how the error handling is explicit and does not involve stack unwinding. The composition stays in the same thread, which is crucial for low‑latency services.p>

    Performance Considerationsh2>

    At first I worried about the overhead of an extra pointer or small‑object allocation. In practice, std::expected​de> is essentially a union​> with a tag, similar to std::optional​>. The compiler can optimize away the union when the value type is small and trivially copyable. For most use cases, the performance impact is negligible compared to the safety gains.p>

    When to Prefer std::expected Over Exceptions​n>
    • Functions that are called from performance‑critical hot paths where exception overhead is undesirable.li>
    • APIs that need to be used in no‑except​> contexts.li>
    • Library code that wants to provide both success and error types without exposing a common base class.li> ul>

      Even in those scenarios, I still keep exceptions for truly unexpected failures—things like out‑of‑memory or assertion violations. std::expected​de> shines when the error is part of the normal operation domain.p>

      Best Practices​n>
      1. Choose a lightweight error type, such as std::string​>, std::error_code​>, or a custom enum wrapped in a struct.​n>
      2. Use and_thencode> and or_else​> for linear error handling; avoid deep nesting.​n>
      3. Never mix std::expected​de> with raw nullptr​> checks; the type system should enforce consistency.​n>
      4. Remember that std::expected​de> is C++23​>; provide a fallback for older compilers if needed.​n> ol>

        By following these guidelines, you get the clarity of algebraic data types without the boilerplate of hand‑rolled result types.p>

        Conclusion​n>

        Integrating std::expected​de> into our configuration‑parsing pipeline was a small change that dramatically improved code readability and reliability. The type system now guarantees that errors are either handled or propagated, and the composition operators let us write error‑aware code that looks almost like synchronous code. If you are working on any C++23 project, I highly recommend giving std::expected​de> a try—you’ll likely find it becomes an essential part of your modern error‑handling toolkit.p>