Assertions, error codes, expected values and exceptions are four ways to report failure in C++, and choosing between them is a design decision rather than a style preference. The rule of thumb is that a failure the caller can reasonably be expected to handle should be reported in the return value, and a failure that indicates a broken invariant should stop the program.
assert checks an invariant
assert aborts the program when its condition is false. It is for checking things that must be true, not for handling things that might not be.
#include
#include
int divide(int numerator, int denominator) {
assert(denominator != 0);
return numerator / denominator;
}
int main() {
std::cout << "10 / 2 = " << divide(10, 2) << '\n';
std::cout << "all checks passed\n";
return 0;
}The output shows the successful path:
10 / 2 = 5
all checks passedIf the denominator had been zero, the program would have aborted with a message naming the file and the line of the failed assertion.
assert disappears in release builds
By default, assert is compiled out unless NDEBUG is defined, which is what a release build does. The macro expands to nothing at all.
#include
#include
int main() {
std::cout << "assert is enabled: " <<
#ifdef NDEBUG
false
#else
true
#endif
<< '\n';
assert(1 == 1); // a failing assertion aborts the program instead
std::cout << "still running\n";
return 0;
}Compile the program with a failing assertion such as assert(1 == 2) and a debug build aborts at that line, printing the file, the line and the condition. In a release build the assertion is compiled out entirely and the program prints "still running". This is why an assertion must never be used for input validation — the check simply is not there in the build you ship.
Error codes report expected failures
An error code in the return value is the fastest and most predictable mechanism, and it costs nothing when it does not fire.
#include
enum class PaymentStatus {
Approved,
Declined,
InsufficientFunds
};
PaymentStatus charge(int amount, int balance) {
if (amount <= 0) {
return PaymentStatus::Declined;
}
if (amount > balance) {
return PaymentStatus::InsufficientFunds;
}
return PaymentStatus::Approved;
}
int main() {
PaymentStatus status = charge(500, 100);
switch (status) {
case PaymentStatus::Approved:
std::cout << "approved\n";
break;
case PaymentStatus::Declined:
std::cout << "declined\n";
break;
case PaymentStatus::InsufficientFunds:
std::cout << "insufficient funds\n";
break;
}
return 0;
}The output shows the failure being reported without any exception machinery:
insufficient fundsThe weakness is that the status has to be checked, and a caller who forgets to check it will carry on with a failed operation. The strength is that the failure is impossible to ignore by accident if the type system is designed so the result must be examined.
std::expected combines both
C++23 adds std::expected, which holds either a value or an error object in the return type. The caller must handle the error case to get the value, so the check is enforced by the compiler.
#include
#include
// A simplified stand-in for std::expected.
template
class Result {
public:
static Result ok(const T& value) {
return Result(value, true, "");
}
static Result error(const std::string& message) {
return Result(T{}, false, message);
}
bool has_value() const { return holds_value_; }
const T& value() const { return data_; }
const std::string& message() const { return message_; }
private:
Result(const T& data, bool holds_value, const std::string& message)
: data_(data), holds_value_(holds_value), message_(message) {}
T data_;
bool holds_value_;
std::string message_;
};
Result charge(int amount) {
if (amount <= 0) {
return Result::error("amount must be positive");
}
return Result::ok(amount * 100);
}
int main() {
Result result = charge(250);
if (result.has_value()) {
std::cout << "charged " << result.value() << " cents\n";
} else {
std::cout << "failed\n";
}
return 0;
}The output shows the success path:
charged 25000 centsChoosing a strategy
- Assertion — an invariant that must hold. If it does not, the program is broken and continuing is pointless.
- Error code or status — a failure the caller is expected to handle and that happens often.
std::optional— a value that may simply not be there, with no error detail needed.std::expected— a value or an error object, where the error needs to be reported.- Exception — a failure that is genuinely exceptional, and where the call stack is the right place to handle it.
The deciding question is usually: how often does this fail? If it fails regularly — a network that is down, a user who types nonsense, a file that is not there — the failure is part of the normal flow and belongs in the return type. If it fails rarely and indicates something wrong, an exception is a reasonable way to unwind to a handler that knows what to do.
A mixed approach
Real code uses all of them, in the places each fits.
#include
#include
#include
#include
#include
std::optional lookup(const std::string& id) {
if (id == "p-001") {
return 100;
}
return std::nullopt;
}
int main() {
// An assertion for an invariant.
assert(lookup("p-001").has_value());
// An optional for a value that may not exist.
auto found = lookup("p-009");
if (!found.has_value()) {
std::cout << "not found, using a default\n";
}
// An exception for a genuine programming error.
try {
throw std::logic_error("the payment table is corrupt");
} catch (const std::logic_error& error) {
std::cout << "caught: " << error.what() << '\n';
}
return 0;
}The output shows each strategy doing its own job:
not found, using a default
caught: the payment table is corruptQuick reference
assertchecks an invariant and aborts; it is removed in release builds.- Never use
assertfor validating input. - Error codes are fast and predictable but must be checked.
std::optionalsays "no value" without detail;std::expectedcarries an error.- Exceptions suit rare failures and unwinding to a handler.
- A common failure should be in the return type; a broken invariant should stop the program.
Try it yourself: write three functions: one that asserts a precondition, one that returns an error code, and one that throws. Call each with a good input and a bad one, and time the failing path. Then compile the assertion version with [[-DNDEBUG]] and confirm the check disappears.