Compile-Time Magic: C++23’s std::expected and the Limits of Compile-Time Computation

Today, let’s talk about a “black magic” tool brought by C++23—std::expected. It is not just an upgraded version of error handling; it can perfectly combine with compile-time computation (constexpr), allowing you to handle “expected values” or “unexpected errors” at the compilation stage. Imagine calculating complex expressions during code compilation, returning error messages directly if something goes wrong, rather than crashing at runtime. Isn’t this compile-time magic?

This article will start with the basics of std::expected, delve into its constexpr applications, and explore the limits of compile-time computation. The code examples are abundant and suitable for intermediate to advanced C++ developers. Let’s go!

std::expected: The “Modern Cure” for Error Handling

Traditional error handling in C++ relies on exceptions, a pair of return values (value + error code), or std::optional (which only indicates “has/no value”). However, these approaches have their pain points: exceptions have significant performance overhead, error codes can be easily overlooked, and optional cannot carry error details.

C++23 introducesstd::expected<T, E> (<span><expected></span> header), inspired by Rust’s Result. It represents: either an expected value T or an unexpected error E. The core API is similar to std::optional but includes an additional error() member.

Basic Syntax and Example

First, let’s look at a simple example: a division function that may divide by zero.

#include <expected>
#include <iostream>
#include <string>

std::expected<int, std::string> divide(int a, int b) {
    if (b == 0) {
        return std::unexpected("Division by zero!");  // Unexpected error
    }
    return a / b;  // Expected value
}

int main() {
    auto result = divide(10, 2);
    if (result) {  // has_value()
        std::cout << "Result: " << *result << std::endl;  // Dereference to get value
    } else {
        std::cout << "Error: " << result.error() << std::endl;
    }

    auto bad = divide(10, 0);
    // bad.has_value() == false
}

Output:

Result: 5
Error: Division by zero!
  • std::unexpected: Wraps an error.
  • operator* / operator->: Retrieves the expected value (must check has_value()).
  • value_or(default): Returns it if there is a value, otherwise returns the default.
  • Monadic operations (enhanced in C++23, P2505 proposal): and_then, or_else, transform.

Chained example:

auto chained = divide(10, 2)
    .and_then([](int v) { return std::expected<std::string, std::string>(std::to_string(v * 2)); })
    .or_else([](const std::string& err) { return std::expected<std::string, std::string>("Recovered: " + err); });

This makes error handling as smooth as functional programming, avoiding nested if statements.

Compared to std::optional: optional only states “no value”, while expected explains “why there is no value”. Compared to exceptions: no stack unwinding overhead, suitable for performance-sensitive scenarios like embedded systems or games.

constexpr Magic: Winning at Compile-Time

C++11 introduced constexpr, allowing compile-time computation of constant expressions. C++23 further strengthens this: std::optional/variant fully supports constexpr, and std::expected naturally follows suit. Most member functions (like constructors, and_then) are constexpr.

This means: Functions return expected at compile-time, and if there is an error, compilation fails or is handled with static assertions.

Compile-Time Parsing of Strings to Integers

A classic scenario: a constexpr function that parses string numbers, returning an error if it fails.

#include <expected>
#include <string_view>
#include <charconv>  // from_chars, C++17+

constexpr std::expected<int, const char*> parse_int(std::string_view sv) {
    int value;
    auto [ptr, ec] = std::from_chars(sv.data(), sv.data() + sv.size(), value);
    if (ec != std::errc{}) {
        return std::unexpected("Invalid number or out of range");
    }
    return value;
}

// Compile-time usage
static_assert(parse_int("42").value() == 42);
static_assert(parse_int("abc").has_value() == false);  // Compile-time error check

Here, parse_int is constexpr. On success, it computes the value at compile-time; on failure, static_assert captures it.

Even cooler: combining with template metaprogramming.

template<std::string_view const& Str>
constexpr auto ParsedValue = parse_int(Str).value();  // If it fails, compile error

Advanced Play: Compile-Time Error Propagation and Monadic Chains

Use and_then for compile-time chained computations.

constexpr std::expected<int, const char*> add_after_parse(std::string_view s1, std::string_view s2) {
    return parse_int(s1).and_then([&](int a) {
        return parse_int(s2).transform([&](int b) { return a + b; });
    });
}

static_assert(add_after_parse("10", "20").value() == 30);

If either parsing fails, the entire chain returns unexpected, and static_assert fails with an error message.

This is the “compile-time magic”: zero runtime overhead, with errors exposed at compile time!

The Limits of Compile-Time Computation: Where is the Ceiling?

constexpr is powerful, but there are hard limits.

  1. Recursion Depth: constexpr recursion (like template specialization for Fibonacci) is limited by the compiler. GCC/Clang defaults to ~1000-2000 levels; exceeding this results in stack overflow and compilation failure.

    Example: Compile-time Fibonacci

constexpr int fib(int n) {
    if (n <= 1) return n;
    return fib(n-1) + fib(n-2);
}
static_assert(fib(30) == 832040);  // OK
// static_assert(fib(10000));  // Compilation timeout/stack overflow
  1. Compilation Time and Memory: Complex computations (like large number factorization) take exponential time. The C++ standard has no hard limits, but compilers have timeouts (e.g., Clang -ftime-trace).

  2. constexpr Limitations:

  • No dynamic allocation (new/delete, except C++20 allocators).
  • No undefined behavior or exception throwing.
  • std::expected requires T/E to be constexpr constructible/destructible.
  • Virtual functions and RTTI are disabled.
  • Real Limit Cases:

    • Parsing large JSON (constexpr string_view has length limits).
    • Compile-time ray tracing small scenes: possible, but stalls beyond 10^6 operations.
    • Tools: Use consteval (C++20) to enforce compile-time execution.

    The limits are not bugs but trade-offs: moving to compile-time makes runtime faster; but compilation slower. Optimization tips: use iteration instead of recursion, cache intermediate results.

    Conclusion: Embrace the Future, Unlimited Magic

    std::expected + constexpr transforms C++ error handling from “runtime surprises” to “compile-time certainty”. It is not just a tool but a shift in thinking: encoding logic with the type system.

    The future C++26 may be even stronger (e.g., reflection aiding compile-time). Try replacing optional/error codes in your projects.

    References: cppreference, C++ Stories, etc.

    Leave a Comment