C++ Error Handling Strategies Best Practices Guide
|

C++ Error-Handling Strategies: optional, expected & More

Back to C++ RoadmapC++ Programming Course • 65 Lessons

Why Multiple Strategies?

C++ offers more error-handling options than any other mainstream language. Exceptions, return codes, std::optional, std::expected, errno, std::error_code — each has tradeoffs in performance, safety, and expressiveness. No single approach fits every situation.

Exceptions work well for errors that are truly unexpected and require unwinding several call levels. Return codes are fast and predictable but easy to ignore. std::optional elegantly handles “no result” cases. std::expected combines “result or error” in a single type. Real codebases often use multiple strategies, with clear boundaries between them.

This lesson covers every major strategy, compares their tradeoffs, and gives guidelines for choosing the right one.

Return Codes

The simplest and oldest strategy: return a value that indicates success or failure. This is what C uses, and many C++ codebases still prefer it for performance-critical code:

#include <iostream>
#include <string>
using namespace std;

enum class ErrorCode {
    OK = 0,
    FileNotFound,
    PermissionDenied,
    DiskFull,
    InvalidFormat
};

string errorMessage(ErrorCode e) {
    switch (e) {
        case ErrorCode::OK:              return "Success";
        case ErrorCode::FileNotFound:    return "File not found";
        case ErrorCode::PermissionDenied: return "Permission denied";
        case ErrorCode::DiskFull:        return "Disk full";
        case ErrorCode::InvalidFormat:   return "Invalid format";
    }
    return "Unknown error";
}

// Return error code, output through parameter
ErrorCode readFile(const string& path, string& content) {
    if (path.empty()) return ErrorCode::InvalidFormat;
    if (path == "/secret") return ErrorCode::PermissionDenied;
    if (path == "/missing") return ErrorCode::FileNotFound;
    content = "File contents of " + path;
    return ErrorCode::OK;
}

int main() {
    string content;
    ErrorCode err = readFile("/data.txt", content);
    if (err == ErrorCode::OK) {
        cout << content << endl;
    } else {
        cout << "Error: " << errorMessage(err) << endl;
    }

    err = readFile("/missing", content);
    if (err != ErrorCode::OK) {
        cout << "Error: " << errorMessage(err) << endl;
    }
    return 0;
}

Pros: zero overhead, predictable performance, no hidden control flow, works everywhere (no compiler flags needed).

Cons: callers can ignore the return value, output parameters are awkward, error propagation is manual (every caller must check and forward the error code).

Modern C++ has [[nodiscard]] to help with the ignoring problem:

[[nodiscard]] ErrorCode riskyOperation() {
    return ErrorCode::DiskFull;
}

int main() {
    riskyOperation();  // WARNING: ignoring return value of nodiscard function
}

errno and C-Style Errors

C APIs use a global (thread-local) variable errno to report errors. You must check it immediately after a function call:

#include <iostream>
#include <cstdio>
#include <cerrno>
#include <cstring>
#include <cmath>
using namespace std;

int main() {
    // File I/O with errno
    FILE* f = fopen("/nonexistent/file.txt", "r");
    if (!f) {
        cout << "fopen failed: " << strerror(errno) << endl;
        // fopen failed: No such file or directory
    }

    // Math functions set errno
    errno = 0;
    double result = log(-1.0);  // domain error
    if (errno == EDOM) {
        cout << "log(-1): domain error" << endl;
    }

    errno = 0;
    result = exp(1000);  // overflow
    if (errno == ERANGE) {
        cout << "exp(1000): range error (overflow)" << endl;
    }

    return 0;
}

Avoid errno in new C++ code. It is easy to forget to check, easy to check too late (another call may overwrite it), and does not work with C++ types. Use it only when calling C library functions that require it.

std::optional (C++17)

std::optional<T> represents a value that may or may not exist. It replaces the common patterns of returning sentinel values (-1, nullptr, empty strings) to indicate “no result”:

#include <iostream>
#include <optional>
#include <string>
#include <map>
using namespace std;

class Database {
    map<int, string> users = {{1, "Alice"}, {2, "Bob"}, {3, "Charlie"}};

public:
    // Returns the user if found, or nothing
    optional<string> findUser(int id) const {
        auto it = users.find(id);
        if (it != users.end()) {
            return it->second;       // has value
        }
        return nullopt;              // no value
    }

    // Returns the parsed integer, or nothing
    static optional<int> parseInt(const string& s) {
        try {
            return stoi(s);
        } catch (...) {
            return nullopt;
        }
    }
};

int main() {
    Database db;

    // Check with has_value()
    auto user = db.findUser(2);
    if (user.has_value()) {
        cout << "Found: " << user.value() << endl;
    }

    // Check with implicit bool
    if (auto u = db.findUser(99)) {
        cout << "Found: " << *u << endl;
    } else {
        cout << "User 99 not found" << endl;
    }

    // value_or() provides a default
    cout << db.findUser(1).value_or("Unknown") << endl;  // Alice
    cout << db.findUser(99).value_or("Unknown") << endl; // Unknown

    // Parse integers safely
    auto num = Database::parseInt("42");
    cout << "Parsed: " << num.value_or(-1) << endl;  // 42

    auto bad = Database::parseInt("abc");
    cout << "Parsed: " << bad.value_or(-1) << endl;  // -1

    return 0;
}

When to use optional: when “no result” is a normal, expected outcome — not an error. A user lookup returning nothing is expected. A file read failing is an error. optional says “there might be nothing,” not “something went wrong.”

When NOT to use optional: when the caller needs to know why there is no value. optional cannot distinguish between “user not found,” “database disconnected,” and “permission denied.” For that, use std::expected or exceptions.

std::expected (C++23)

std::expected<T, E> holds either a value of type T (success) or an error of type E (failure). It combines the benefits of return codes (caller must handle the error) with the ergonomics of optional (no output parameters):

#include <iostream>
#include <expected>
#include <string>
#include <system_error>
using namespace std;

enum class ParseError {
    EmptyInput,
    InvalidCharacter,
    Overflow
};

string errorString(ParseError e) {
    switch (e) {
        case ParseError::EmptyInput: return "empty input";
        case ParseError::InvalidCharacter: return "invalid character";
        case ParseError::Overflow: return "number too large";
    }
    return "unknown";
}

expected<int, ParseError> parsePositiveInt(const string& s) {
    if (s.empty()) return unexpected(ParseError::EmptyInput);

    int result = 0;
    for (char c : s) {
        if (c < '0' || c > '9') return unexpected(ParseError::InvalidCharacter);
        int digit = c - '0';
        if (result > (INT_MAX - digit) / 10) return unexpected(ParseError::Overflow);
        result = result * 10 + digit;
    }
    return result;  // success
}

// Chaining with and_then / transform
expected<double, ParseError> parseThenHalve(const string& s) {
    return parsePositiveInt(s)
        .transform([](int val) { return val / 2.0; });
}

int main() {
    auto r1 = parsePositiveInt("42");
    if (r1) {
        cout << "Value: " << *r1 << endl;  // 42
    }

    auto r2 = parsePositiveInt("abc");
    if (!r2) {
        cout << "Error: " << errorString(r2.error()) << endl;
        // Error: invalid character
    }

    auto r3 = parsePositiveInt("");
    if (!r3) {
        cout << "Error: " << errorString(r3.error()) << endl;
        // Error: empty input
    }

    // Chaining
    auto r4 = parseThenHalve("100");
    cout << "Half of 100: " << r4.value_or(0.0) << endl;  // 50

    return 0;
}

std::expected is the modern C++ answer to Rust’s Result<T, E>. The caller must check for errors before accessing the value (accessing the value on an error calls undefined behavior or throws). The transform and and_then methods enable functional-style chaining.

Note: std::expected requires C++23. If your compiler does not support it, use the tl::expected or boost::outcome libraries, which provide the same functionality.

std::error_code and std::error_category

The <system_error> header provides a cross-platform error framework used by the standard library (filesystem, networking):

#include <iostream>
#include <system_error>
#include <fstream>
using namespace std;

void demonstrateErrorCode() {
    // Create from errno values
    error_code ec = make_error_code(errc::no_such_file_or_directory);
    cout << "Code: " << ec.value() << endl;
    cout << "Message: " << ec.message() << endl;
    cout << "Category: " << ec.category().name() << endl;

    // Check for specific errors
    if (ec == errc::no_such_file_or_directory) {
        cout << "File not found!" << endl;
    }

    // error_code is falsy when no error
    error_code ok;
    if (!ok) {
        cout << "No error" << endl;  // error_code() == 0 → no error
    }
}

// Functions can take error_code as output parameter
bool openFile(const string& path, error_code& ec) {
    ifstream file(path);
    if (!file.is_open()) {
        ec = make_error_code(errc::no_such_file_or_directory);
        return false;
    }
    ec.clear();
    return true;
}

int main() {
    demonstrateErrorCode();

    cout << endl;

    error_code ec;
    if (!openFile("/nonexistent.txt", ec)) {
        cout << "Failed: " << ec.message() << endl;
    }
    return 0;
}

The std::filesystem library uses this pattern extensively — every function has an overload that takes error_code& instead of throwing:

#include <filesystem>
namespace fs = std::filesystem;

// Throwing version:
auto size = fs::file_size("/some/file");  // throws on error

// Non-throwing version:
error_code ec;
auto size2 = fs::file_size("/some/file", ec);  // sets ec on error
if (ec) { /* handle error */ }

Strategy Comparison

Strategy Performance Safety Error Info Best For
Exceptions Slow when thrown Cannot ignore Rich (custom types) Rare, unexpected errors
Return codes Zero overhead Easy to ignore Limited (int/enum) Performance-critical C APIs
std::optional Minimal Must check None (just absent) “No result” is normal
std::expected Minimal Must check Rich (error type E) Expected failures with reason
error_code Zero overhead Easy to ignore Moderate (code+msg) System/platform errors

How to Choose

Use exceptions when:

  • The error is truly exceptional (file corruption, out of memory, network failure)
  • The error must propagate through many call levels
  • The code already uses exceptions (consistency matters)

Use std::optional when:

  • A missing result is normal and expected (lookup, search, parse)
  • The caller does not need to know why there is no result

Use std::expected when:

  • Failure is expected but the caller needs to know the reason
  • You want return-value-based error handling with rich error info
  • You are writing a library that should work in exception-free codebases

Use return codes / error_code when:

  • Performance is critical and errors are frequent
  • Interfacing with C APIs
  • Exceptions are disabled (-fno-exceptions)

The worst approach: mixing strategies randomly. Pick one primary strategy for your codebase and use others only at boundaries (e.g., wrapping C APIs).

Mixing Strategies at Boundaries

Real code often wraps one strategy in another at API boundaries:

#include <iostream>
#include <optional>
#include <stdexcept>
#include <string>
#include <cerrno>
#include <cstring>
using namespace std;

// C API uses errno
extern "C" {
    int c_read_sensor(int sensorId, double* value) {
        if (sensorId < 0 || sensorId > 10) {
            errno = EINVAL;
            return -1;
        }
        *value = 23.5 + sensorId;
        return 0;
    }
}

// C++ wrapper converts to optional
optional<double> readSensor(int id) {
    double value;
    if (c_read_sensor(id, &value) == 0) {
        return value;
    }
    return nullopt;
}

// Higher-level function converts to exception
double readSensorOrThrow(int id) {
    auto value = readSensor(id);
    if (!value) {
        throw runtime_error("Failed to read sensor " + to_string(id));
    }
    return *value;
}

int main() {
    // Use optional where failure is expected
    for (int id : {1, 5, 99}) {
        auto temp = readSensor(id);
        cout << "Sensor " << id << ": "
             << (temp ? to_string(*temp) : "N/A") << endl;
    }

    cout << endl;

    // Use exception where failure is unexpected
    try {
        cout << "Critical sensor: " << readSensorOrThrow(3) << endl;
        cout << "Bad sensor: " << readSensorOrThrow(99) << endl;
    } catch (const exception& e) {
        cout << "ALERT: " << e.what() << endl;
    }
    return 0;
}

The pattern: C API → optional wrapper → exception wrapper. Each layer uses the strategy appropriate for its callers. This is how professional C++ codebases handle the transition between C and C++ error models.

Real-World Example: HTTP Client

#include <iostream>
#include <optional>
#include <string>
#include <map>>
#include <variant>
using namespace std;

// Error types
struct NetworkError {
    string message;
    int code;
};

struct HttpResponse {
    int statusCode;
    map<string, string> headers;
    string body;

    bool isSuccess() const { return statusCode >= 200 && statusCode < 300; }
    bool isClientError() const { return statusCode >= 400 && statusCode < 500; }
    bool isServerError() const { return statusCode >= 500; }
};

// Result type: either a response or an error
using HttpResult = variant<HttpResponse, NetworkError>;

// Simulated HTTP client
class HttpClient {
public:
    HttpResult get(const string& url) {
        // Simulate different outcomes
        if (url.find("timeout") != string::npos) {
            return NetworkError{"Connection timed out", -1};
        }
        if (url.find("404") != string::npos) {
            return HttpResponse{404, {{"content-type", "text/html"}}, "Not Found"};
        }
        if (url.find("500") != string::npos) {
            return HttpResponse{500, {}, "Internal Server Error"};
        }
        return HttpResponse{200, {{"content-type", "application/json"}},
                           R"({"status": "ok", "data": [1, 2, 3]})"};
    }
};

// Helper to extract successful response or nullopt
optional<string> fetchJson(HttpClient& client, const string& url) {
    auto result = client.get(url);

    if (auto* err = get_if<NetworkError>(&result)) {
        cout << "Network error: " << err->message << endl;
        return nullopt;
    }

    auto& response = get<HttpResponse>(result);
    if (!response.isSuccess()) {
        cout << "HTTP " << response.statusCode << ": " << response.body << endl;
        return nullopt;
    }

    return response.body;
}

int main() {
    HttpClient client;

    cout << "--- Successful request ---" << endl;
    if (auto json = fetchJson(client, "https://api.example.com/data")) {
        cout << "Got: " << *json << endl;
    }

    cout << "\n--- 404 request ---" << endl;
    fetchJson(client, "https://api.example.com/404");

    cout << "\n--- Timeout ---" << endl;
    fetchJson(client, "https://api.example.com/timeout");

    return 0;
}

This example uses std::variant as a result type (success or error), then std::optional for the higher-level helper that only cares about success or failure. No exceptions are used — errors are values that flow through the type system. This is the style preferred by codebases that avoid exceptions.

Practice Exercises

Exercise 1: Write a parseInt function three ways: one using exceptions, one using std::optional, and one using return codes. Compare the caller code for each. Which is most readable?

Exercise 2: Create a Result<T> template class (your own std::expected) that holds either a value or an error string. Implement value(), error(), has_value(), and value_or().

Exercise 3: Wrap the C file I/O functions (fopen, fread, fwrite, fclose) in a modern C++ class that uses RAII for cleanup and std::optional or std::expected for error handling. Test by reading a file that exists and one that does not.

Exercise 4: Build a configuration loader that reads a JSON-like config file. Use std::expected for parsing (returns parse errors), std::optional for looking up config keys (missing key is normal), and exceptions for truly unexpected errors (out of memory).

Summary

C++ provides multiple error-handling strategies, each suited for different scenarios. Return codes are fast but easy to ignore. errno is legacy — avoid in new code. std::optional cleanly represents “no result” without explaining why. std::expected (C++23) is the modern answer to “result or error” with type safety. std::error_code provides a cross-platform error framework. Exceptions handle truly unexpected failures with automatic propagation. The key principle: pick one primary strategy for your codebase and use others only at boundaries. Never mix strategies randomly — consistency matters more than picking the “best” approach.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *