Modern C++: Error Handling with Result

An illustration of C++ code in the article

Error handling is an essential part of API design, and there are countless ways to do it. Let’s look at how to bridge the gap between old and new by building a Result type from scratch in C++.

First, a disclaimer: this is proof-of-concept code only meant to satisfy my curiosity. Use a library like Boost Outcome in your codebase instead.

Status codes

Status codes, or error codes, are the essential form of reporting errors. Each code, typically an integer, represents a specific error, class of errors, or a successful operation.

If you’ve spent time with C and POSIX, you may be familiar with errno, the set of standard error codes for operating system calls. The tradition is carried on in modern code, especially at the low level where compatibility with C is expected, for example, zx_status_t in Fuchsia.

Here is status_t, the status code that represents all kinds of errors in our system:

typedef int32_t status_t;

#define S_OK (0)
#define S_ERR_FOO (-1)
#define S_ERR_BAR (-2)
#define S_ERR_BAD_ARGS (-3)

Using status_t

Let’s write a function that divides two integers. In typical C style, it returns the status code, and upon success, the result of the division through an out parameter:

#include <iostream>

// Sets |result| to the value of |x| divided by |y|.
// 
// Returns S_ERR_BAD_ARGS if |result| is a nullptr, or |y| is zero,
// and S_OK on success.
status_t divide(int x, int y, float* out_result) {
    if (out_result == nullptr || y == 0) {
        return S_ERR_BAD_ARGS;
    }
    
    *out_result = float(x) / y;

    return S_OK;
}

// Returns a string representation of the given status.
constexpr std::string_view status_to_string(status_t status) {
    switch (status) {
        case S_OK: return "S_OK";
        case S_ERR_FOO: return "S_ERR_FOO";
        case S_ERR_BAR: return "S_ERR_BAR";
        case S_ERR_BAD_ARGS: return "S_ERR_BAD_ARGS";
    }
    return "(unknown)";
}

void do_division(int x, int y) {
    std::cout << x << " / " << y << " = ";

    float result;
    
    if (auto status = divide(x, y, &result); status != S_OK) {
        std::cout << "(error: " << status_to_string(status) << ')' << std::endl;
        return;
    }

    std::cout << result << std::endl;
}

Looks pretty good. How would we handle the specific error code?

auto status = divide(x, y, &result);
if (status == S_ERR_BAD_ARGS) {
    std::cout << "You can't divide by zero! You will regret this!" << std::endl;
} else if (status != S_OK) {
    // Oops, we should never get here. Let's add some boilerplate logging to be safe.
    std::cout << "Unexpected error: " << status_to_string(status) << std::endl;
    // On the other hand, this is clearly a bug!
    panic();
}

The problem with catch-all error code types is that they force developers to predict the future by hand-waving away error codes that should “never happen.” If divide starts returning something other than S_ERR_BAD_ARGS or S_OK, which its API signature says is perfectly reasonable, the error handling falls apart.

The Result type

Result is a generic type that represents the result of an operation, either success or failure, wrapping either the desired value, or an error.

The API is heavily inspired by Rust’s Result type.

template <typename T>
class Ok {
  public:
    explicit constexpr Ok(T value) : value(std::move(value)) {}

    constexpr T&& take_value() { return std::move(value); }

    T value;
};

template <typename T>
class Err {
  public:
    explicit constexpr Err(T value) : value(std::move(value)) {}

    constexpr T&& take_value() { return std::move(value); }

    T value;
};

template <typename OkT, typename ErrT>
class Result {
  public:
    using VariantT = std::variant<Ok<OkT>, Err<ErrT>>;

    constexpr Result(Ok<OkT> value) : variant(std::move(value)) {}
    constexpr Result(Err<ErrT> value) : variant(std::move(value)) {}

    constexpr bool is_ok() const { return std::holds_alternative<Ok<OkT>>(variant); }
    constexpr bool is_err() const { return std::holds_alternative<Err<ErrT>>(variant); }

    constexpr OkT ok_value() const { return std::get<Ok<OkT>>(variant).value; }
    constexpr ErrT err_value() const { return std::get<Err<ErrT>>(variant).value; }

    constexpr OkT&& take_ok_value() { return std::get<Ok<OkT>>(variant).take_value(); }
    constexpr ErrT&& take_err_value() { return std::get<Err<ErrT>>(variant).take_value(); }

    VariantT variant;
};

We can replace the out parameter with a Result<float, status_t> that contains either the divided value, or a status code:

// Returns |x| divided by |y|.
// 
// Returns an error result with the value S_ERR_BAD_ARGS
// if |y| is zero, and an OK result with the value on success.
Result<float, status_t> divide_2(int x, int y) {
    if (y == 0) {
        return Err(S_ERR_BAD_ARGS);
        // problem: what if we return Err(S_OK)?
    }
    
    return Ok(float(x) / y);
}

void do_division_2(int x, int y) {
    std::cout << x << " / " << y << " = ";

    auto result = divide_2(x, y); 
    if (result.is_err()) {
        std::cout << "(error: " << status_to_string(result.take_err_value()) << ')' << std::endl;
        return;
    }

    std::cout << result.ok_value() << std::endl;
}

This still does not solve the problem of handling unrelated error codes. Let’s introduce a specific error type, DivisionError, that is a subset of status_t:

enum class DivisionError : status_t {
    DIVISION_BY_ZERO = (S_ERR_BAD_ARGS),
};

std::ostream& operator<<(std::ostream& os, DivisionError err) {
    if (err == DivisionError::DIVISION_BY_ZERO) {
        os << "DIVISION_BY_ZERO";
    }
    return os;
}

Now, clients don’t need to handle every possible value of status_t, yet we can easily convert it back to a status_t for backwards-compatibility. It also ensures divide only returns error codes that make sense for that function - the type system prevents misuse like Err(S_OK).

// Returns |x| divided by |y|.
// 
// Returns an error result with the error DivisionError::DIVISION_BY_ZERO
// if |y| is zero, and an OK result with the value on success.
Result<float, DivisionError> divide_3(int x, int y) {
    if (y == 0) {
        return Err(DivisionError::DIVISION_BY_ZERO);
    }
    
    return Ok(float(x) / y);
}

void do_division_3(int x, int y) {
    std::cout << x << " / " << y << " = ";

    auto result = divide_3(x, y); 
    if (result.is_err()) {
        std::cout << "(error: " << result.err_value() << ')' << std::endl;
        return;
    }

    std::cout << result.ok_value() << std::endl;
}

Back to C

We can get back to the original C-compatible signature by wrapping the function and extracting the value and error code:

status_t divide_4(int x, int y, float* result) {
    auto res = divide_3(x, y); 
    if (res.is_err()) {
        return static_cast<status_t>(res.err_value());
    }
    *result = res.ok_value();
    return S_OK;
}

void do_division_4(int x, int y) {
    std::cout << x << " / " << y << " = ";

    float result;
    
    if (auto status = divide_4(x, y, &result); status != S_OK) {
        std::cout << "(error: " << status_to_string(status) << ')' << std::endl;
        return;
    }

    std::cout << result << std::endl;
}

Another way of doing the same thing is with std::visit, but this is likely to be less efficient due to the use of vtables.

// https://en.cppreference.com/w/cpp/utility/variant/visit
// https://dev.to/tmr232/that-overloaded-trick-overloading-lambdas-in-c17
template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
// explicit deduction guide (not needed as of C++20)
template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>;

status_t divide_5(int x, int y, float* out_result) {
    if (out_result == nullptr) {
        return S_ERR_BAD_ARGS;
    }

    status_t status{S_OK};

    // Watch out for code bloat and vtables!
    std::visit(overloaded {
        [&](Ok<float> ok) { *out_result = ok.value; },
        [&](Err<DivisionError> err) { status = static_cast<status_t>(err.value); },
    }, divide_3(x, y).variant);
    
    return status;
}

void do_division_5(int x, int y) {
    std::cout << x << " / " << y << " = ";

    float result;
    
    if (auto status = divide_5(x, y, &result); status != S_OK) {
        std::cout << "(error: " << status_to_string(status) << ')' << std::endl;
        return;
    }

    std::cout << result << std::endl;
}

If you have a bunch of these functions, you might want a generic way to extract a status_t out from the Result:

template <typename OkT, typename ErrT = status_t>
status_t status_of(Result<OkT, ErrT> result, OkT* out_value) {
    if (out_value == nullptr) {
        return S_ERR_BAD_ARGS;
    }
    if (result.is_err()) {
        return static_cast<status_t>(result.err_value());
    }
    *out_value = result.ok_value();
    return S_OK;
}

void do_division_6(int x, int y) {
    std::cout << x << " / " << y << " = ";

    float result;
    
    if (auto status = status_of(divide_3(x, y), &result); status != S_OK) {
        std::cout << "(error: " << status_to_string(status) << ')' << std::endl;
        return;
    }

    std::cout << result << std::endl;
}

Don’t like static_cast? Future C++ versions might include the std::to_underlying function that will hide the cast:

// https://github.com/cplusplus/papers/issues/460
// http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2019/p1682r0.html
template <typename T>
constexpr std::underlying_type_t<T> to_underlying(T value) noexcept {
    return static_cast<std::underlying_type_t<T>>(value);
}