Using std::expected for Cleaner, More Explicit Error Handling in C++
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_thenandor_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:
- Pick a sensible error type.
std::stringworks for ad‑hoc messages, but for larger projects consider aenum class ErrorCodepaired withstd::stringfor details. - Don't hide exceptions inside
std::expectedunless you have a good reason. If a function can throw, let it throw; let the caller decide to catch or not. - Use
std::unexpectedonly for error conditions. Returning an emptystd::expected(i.e.,std::expected<Config, std::string>{}) is ill‑formed and should be avoided. - When mixing
std::expectedwith existing APIs, employ adapters such asstd::expected<T, E>::transform_errorto convert error types. - 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 I started with a handful of 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>
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>