C++ / ERRORS, EXCEPTIONS, AND UNDEFINED BEHAVIOUR
Error codes and expected versus exceptions
Decide whether a function should return its failure as std::expected or throw it, and write both styles plus the adapter between them.
What you will learn
- Return std::expected<T,E> for routine failures; throw for rare or distant ones
- Check has_value() before reading; *r on the error state is undefined behaviour
- Mark fallible functions [[nodiscard]] so a caller cannot silently drop the error
- Wrap a throwing API in one try/catch to hand callers an expected instead
Understanding Error codes and expected versus exceptions
C++ has two channels for reporting failure: the return value and the exception path. Anything on the return channel, whether an int status, an errno-style code, std::error_code, std::optional or std::expected, appears in the function's type, so every caller sees it and has to write something, even if that something is discarding it. Anything on the exception channel is invisible in the signature and passes straight through frames that never mention it. The old return-code form has one weakness that std::expected fixes: when the answer and the status are separate objects, typically an out-parameter plus a code, nothing stops you reading an answer that was never written.
Choose the channel by how routine the failure is and how far it must travel. A malformed line of input, a missing key, a file that may not exist: the immediate caller usually has a plan, so returning the failure keeps the plan next to the call. A broken invariant or a violated precondition often has no local remedy and must cross many frames, and carrying it by hand makes every one of those frames grow a test-and-forward, while throwing lets them contain no error-handling code at all. Constructors and overloaded operators have no spare return slot, which is why failures there are normally thrown.
std::expected<T, E> holds exactly one of a T or an E and remembers which; the contextual bool or has_value() tells you, value() and * read the T, error() reads the E. That pairing is only partly enforced: value() on the error state throws std::bad_expected_access, but *r in that state is undefined behaviour, so checking is your job. Mark fallible functions [[nodiscard]], because a returned error is the one kind of error a caller can drop by writing nothing at all. Prefer expected over optional whenever the caller needs the reason: optional can only report that no value arrived.
<charconv>
<expected>
<iostream>
<string_view>
<system_error>
enum class PortError { not_a_number, trailing_junk, out_of_range };
const char* describe(PortError e) {
switch (e) {
case PortError::not_a_number: return "no digits at the start";
case PortError::trailing_junk: return "extra characters after the number";
case PortError::out_of_range: return "outside 1..65535";
}
return "unknown";
}
// The failure modes are part of the return type, so no caller can pretend they do not exist.
[[nodiscard]] std::expected<int, PortError> parse_port(std::string_view text) {
int value = 0;
const char* last = text.data() + text.size();
auto [stop, ec] = std::from_chars(text.data(), last, value);
if (ec == std::errc::invalid_argument) return std::unexpected(PortError::not_a_number);
if (ec == std::errc::result_out_of_range) return std::unexpected(PortError::out_of_range);
if (stop != last) return std::unexpected(PortError::trailing_junk);
if (value < 1 || value > 65535) return std::unexpected(PortError::out_of_range);
return value;
}
int main() {
for (std::string_view text : {"8080", "8080x", "http", "70000"}) {
if (auto port = parse_port(text))
std::cout << text << " -> " << *port << '\n';
else
std::cout << text << " -> rejected: " << describe(port.error()) << '\n';
}
std::cout << "default when unparsable: " << parse_port("").value_or(80) << '\n';
}
A returned error is part of a function's type and every caller must deal with it, while a thrown error is part of control flow and only the callers that can act on it need to mention it.
Worked examples
A dropped code versus a thrown one
Shows what the program does when the returned status is ignored, and how the throwing version of the same operation removes that option.
<charconv>
<iostream>
<stdexcept>
<string>
<system_error>
int main() {
const std::string text = "x12";
std::cout << std::boolalpha;
int a = -1;
std::from_chars(text.data(), text.data() + text.size(), a); // status discarded
std::cout << "code ignored: a is still " << a << '\n';
int b = -1;
auto [stop, ec] = std::from_chars(text.data(), text.data() + text.size(), b);
std::cout << "code checked: invalid_argument=" << (ec == std::errc::invalid_argument)
<< ", consumed=" << (stop - text.data())
<< ", b is still " << b << '\n';
try {
int c = std::stoi(text);
std::cout << "stoi returned " << c << '\n';
} catch (const std::invalid_argument&) {
std::cout << "stoi threw: no value was produced, so none can be misused\n";
}
}
Example explained
Line 1from_chars leaves a untouched when it fails, so discarding the status lets the program carry on with -1 as if it had been parsed.
Line 2ec == std::errc::invalid_argument is the only evidence that no digits were found; the integer alone cannot say whether it was written.
Line 3consumed=0 is how far the parse got, and it must be compared with the end separately: "12x" would give 12, consumed=2 and no error.
Line 4std::stoi reports the same failure by throwing, so there is no execution path where the caller keeps the stale -1.
Translating a throwing API into expected
Wraps std::stoi in a single boundary function so callers above it branch on a value instead of writing handlers.
<expected>
<iostream>
<stdexcept>
<string>
// One try/catch at the boundary; code above this function never sees an exception.
[[nodiscard]] std::expected<int, std::string> to_int(const std::string& s) {
try {
return std::stoi(s);
} catch (const std::invalid_argument&) {
return std::unexpected("no leading digits in " + s);
} catch (const std::out_of_range&) {
return std::unexpected(s + " does not fit in an int");
}
}
int main() {
for (const std::string s : {"41", "forty-one", "99999999999999999999"}) {
auto r = to_int(s);
if (r)
std::cout << "value + 1 = " << *r + 1 << '\n';
else
std::cout << "error: " << r.error() << '\n';
}
}
Example explained
Line 1return std::stoi(s) inside the try builds the value side of the expected from the int that stoi produced.
Line 2Each catch clause turns one exception type into one message on the error side, which is where the information from the throwing API is preserved.
Line 3main contains no try at all: the whole error handling is the if on r, and *r is only reached when r holds a value.
Line 4E is std::string here, so the reason survives; std::optional<int> would reduce both failures to the same empty state.
Important notes
std::expected is C++23 (libstdc++ 12 and later, libc++ 16 and later); compile with -std=c++23, or pair std::optional with a separate error value on older toolchains.
Returning errors does not make a function exception-free: allocation, constructors and containers can still throw, so a body full of expected returns is not automatically noexcept.
Common mistakes
Reading *r or r.value() before checking: value() throws std::bad_expected_access, while *r on the error state is undefined behaviour because it reads a T that was never constructed.
Treating a nonzero-looking result as proof of success with from_chars or strtol: "12x" parses to 12 with no error at all, so unconsumed input slips through unless the end pointer is compared.
Declaring an out-parameter without initialising it and using it after a failed call, which feeds a stale or indeterminate value into the rest of the program as if it were data.
Try it yourself
Change, predict, then run
Write [[nodiscard]] std::expected<unsigned, HexError> parse_hex_byte(std::string_view) that rejects empty input, non-hex characters, and values above 0xFF using its own HexError enum. Print the outcome for "ff", "1g", and "100".
Open the C++ workspaceCheck your understanding
A function five frames below a top-level handler can fail, and none of the intermediate frames can do anything useful about that failure. What is the real argument for throwing rather than returning std::expected?
- Throwing is faster than returning a value on the failure path.
- Returning std::expected would force the top-level frame to handle the error, while throwing lets it choose.
- The five intermediate frames then contain no error-handling code at all, instead of a test-and-forward in each one.
- std::expected can only carry integral error codes, so the failure reason would be lost on the way up.
Show answer
The cost of carrying an error in the return value is paid once per intermediate frame as an if-and-return, and throwing removes code that would otherwise have to exist and be kept correct. Option 0 is the tempting one but is backwards: the throw path is normally far more expensive than a branch, so the argument is about the code you do not write, not about speed. E in std::expected can be any type, including a string or a struct, so nothing is lost by returning it.