1. Two approaches to errors: jump vs value
When you write a function, you always have a hidden question: “what should be done if something goes wrong?”. In C++ there are two large families of answers. The first — exceptions, where an error is a separate control-flow path (a conditional “jump” from deep code to where someone is ready to handle it). The second — error as a value, where the function returns a result that explicitly says: “success” or “failure” (for example, via std::optional or std::variant).
These tools are not rivals in a “who's stronger” sense; they are more like a hammer and a screwdriver: both useful, but for different tasks.
By the way, std::optional and std::variant are not some “new fashion”, they are standard library tools (added to the standard library with C++17), which is exactly why they are convenient to use as a language for function contracts.
Error as part of the contract: expected vs unexpected
The most frequent source of arguments about exceptions is that people argue not about syntax but about meaning: is an error a normal part of a function's operation or is it a failure. Thinking in terms of a contract helps a lot here.
If you call a function, you usually have an expectation: “it can work under these conditions”. If conditions are violated, it can be:
- an expected outcome — for example, a search may find nothing, the user may enter an invalid command, a file may be missing (a fact of life, not a catastrophe);
- an unexpected problem — for example, an internal data-structure invariant is violated, or a function received an argument that by program logic “should never be like this”.
The idea is simple: if failure is a normal scenario, it's more convenient to make it visible in the return type. If failure is “this should not happen”, it's often more logical to make it an exception, because continuing as if nothing happened is dangerous.
2. When exceptions are appropriate
Exceptions are appreciated because they allow you to avoid threading “error wires” through every function and every if. But they are not always appropriate. Discipline matters: an exception is a signal “we cannot continue normal work on this path”.
A practical rule: exceptions are good for rare, "abnormal" situations, especially when the decision about how to react to the error lives higher up in the code. That is, where low-level functions have nothing to do but say: “we have a problem, decide at the top”.
Argument contract violation
When you write a function you often assume certain conditions for inputs. For example: “task id must be positive”, “command string must not be empty”, “the vector of indices must not be empty, otherwise there is nothing to remove”. If these are required conditions, then a violation is a good candidate for an exception std::invalid_argument.
Small example in a “contract violated” style:
#include <stdexcept>
#include <string>
void require_non_empty(const std::string& s) {
if (s.empty()) {
throw std::invalid_argument{"command must not be empty"};
}
}
Here it's important not to confuse two situations. If a user entered an empty string in the CLI — this can be expected, and you can handle it without an exception. But if your internal function “get command” by architecture guarantees a non-empty string (for example, you've already filtered input), then an empty string means the code is broken at the program logic level.
Internal invariants: the program should not have reached this point
There are errors that should not normally occur in user life. A typical example — an inconsistent container state, a “corrupted” object, an impossible branch of a switch. In such cases it's often better to “blast” with an exception (or at least not continue as if everything is fine), because further work can make things worse: save incorrect data, delete the wrong thing, show the user a lie.
For example, imagine we have tasks, and each must have a unique id. If you suddenly find two tasks with the same id, it's not a “user error” — it's an error in your logic:
#include <stdexcept>
#include <vector>
struct Task { int id{}; };
void ensure_unique_ids(const std::vector<Task>& tasks) {
for (std::size_t i = 0; i < tasks.size(); ++i) {
for (std::size_t j = i + 1; j < tasks.size(); ++j) {
if (tasks[i].id == tasks[j].id) {
throw std::runtime_error{"duplicate task id detected"};
}
}
}
}
The code isn't optimal in complexity (two loops), but it's fine for illustration: an exception is appropriate here because continuing with a “corrupted” set of tasks will lead to chaos.
Error deep down and handling boundary
In practice exceptions are especially useful when an error occurs deep (for example, while parsing a command), and the decision about what to do next is at the top level — usually in main() or in the "command loop".
The diagram looks like this:
flowchart TD
A[parseCommand] -->|ok| B[executeCommand]
A -->|throw ParseError| C[main / command loop]
B -->|ok| C
B -->|throw runtime_error| C
C --> D[print message and continue loop]
The idea: low-level functions don't decide UX, they just report what happened. And main() decides: show the error, ask the user to retry, exit the program.
3. When "error as a value" is better
If an exception is a “jump”, then error as a value is a “return with a tag”. And often this is much more honest for the reader of the code, because from a function's signature it's immediately visible: “it may fail”.
Rule: if failure is a normal outcome and is expected often, it's better to make it part of the return value. This reduces surprises and makes error handling more transparent (for you and for future you who already forgot everything).
std::optional: when only the presence/absence matters
std::optional<T> is good when the result has two states: “there is a value” and “there is no value”, and the reason for absence is unimportant or is known from context.
A classic example — lookup. If a user asks “show task with id=10” and such task doesn't exist — this is not a catastrophe, it's a normal scenario.
#include <optional>
#include <vector>
struct Task { int id{}; };
std::optional<std::size_t> find_task_index(const std::vector<Task>& tasks, int id) {
for (std::size_t i = 0; i < tasks.size(); ++i) {
if (tasks[i].id == id) return i;
}
return std::nullopt;
}
Note: we do not throw an exception because "not found" is a normal branch, especially in an interactive program. Exceptions here would only complicate life: you'd have to wrap ordinary user actions in try/catch, which starts to look like a series "guess where it will throw today".
std::variant: when there are multiple meaningful outcomes
Sometimes "didn't work" can mean different things and the reasons matter: the user entered an unknown command, entered a command with a bad argument format, or the command is valid. If you want this to be visible in the type, it's handy to use std::variant.
A minimal model of command parsing for our console task app (let's call it TaskCLI). Commands will be simple: ADD text, DONE id, LIST.
#include <string>
#include <variant>
struct AddCmd { std::string text; };
struct DoneCmd { int id{}; };
struct ListCmd { };
struct ParseError { std::string message; };
using ParseResult = std::variant<AddCmd, DoneCmd, ListCmd, ParseError>;
Why is this "error as a value"? Because ParseError is just another variant of the result, like AddCmd. We don't "jump" through the stack, we say: "this is what was parsed".
And yes, variant is also a standard tool, designed as a "type-safe union".
User input and frequent mistakes
User input is bad because the user is a human. And a human may type DONE cat instead of DONE 5. If you throw an exception for each such mistake, try/catch will become your main runtime loop. This is not always bad, but most often it's excessive: user errors are expected and frequent, so it's better to return a clear result and print a message.
Mini-example: parse an int without exceptions, returning optional:
#include <optional>
#include <string>
std::optional<int> parse_int(const std::string& s) {
if (s.empty()) return std::nullopt;
try {
return std::stoi(s);
} catch (...) {
return std::nullopt;
}
}
Here we "locally" use an exception (stoi throws), but outwardly we provide a calm optional. This is an example of combining approaches (we will return to it).
4. Combining approaches: jump inside, be polite outside
Real programs rarely live in a single religion. Often the healthiest design is when inside modules you use exceptions where they help not to carry error checks through every level, and on the boundary (for example, in the command handler) you convert them into a clear result or message.
Below are two popular compromises: "exceptions inside, result outside" and "result inside, exceptions only for invariants".
Exception inside, clear result outside
Imagine you already have a strict parser that throws ParseError (we defined our own type in a previous lecture). But your UI/CLI code wants to work without try/catch everywhere. We write an adapter:
#include <string>
#include <variant>
class ParseException; // suppose this is your exception type
std::variant<int, std::string> safe_parse_id(const std::string& s) {
try {
return std::stoi(s);
} catch (...) {
return std::string{"id must be a number"};
}
}
Yes, this is variant<int, string> — a primitive "success/error". We don't introduce new topics, just use the familiar variant: left is success, right is error text. Now any higher-level code can handle this without exceptions.
Error as a value for normal cases, exception for impossible ones
A very useful habit: ask yourself “if I continue working, will the result be correct?”. If yes — probably not an exception. If no — an exception is appropriate.
For example, the command DONE 10: if task 10 doesn't exist — it's not a catastrophe, just tell the user "not found". But if suddenly the id is negative, although by contract id is always positive, this is suspicious: somewhere you didn't validate input, or you're storing garbage.
#include <stdexcept>
#include <vector>
struct Task { int id{}; bool done{}; };
void mark_done(std::vector<Task>& tasks, int id) {
if (id <= 0) {
throw std::invalid_argument{"task id must be positive"};
}
for (auto& t : tasks) {
if (t.id == id) {
t.done = true;
return;
}
}
// "not found" — not an exception, handled above
}
The function "didn't find" and simply returned. This is debatable, but sometimes acceptable if the function contract is “try to mark as done”. But a negative id is clearly bad input.
5. Where to catch exceptions in TaskCLI
One of the most unpleasant beginner mistakes is either not catching exceptions at all ("let it crash, that's honest"), or catching them in every function ("to be sure it won't crash"). In practice a third way is needed: catch where you can make a decision, i.e. at the scenario boundary.
For a console task application a convenient boundary is handling a single command. We want: one command should not crash the whole program, at most — print an error and continue the loop.
Skeleton of the command loop:
#include <exception>
#include <iostream>
#include <string>
int main() {
try {
std::string line;
while (std::getline(std::cin, line)) {
// parse + execute one command
}
} catch (const std::exception& e) {
std::cout << "fatal: " << e.what() << '\n';
}
}
This is the "final frontier": if we didn't handle something at the command level, at least we will print the reason and not leave the user with a silent terminate.
And inside the loop it's often convenient to make another try/catch, but for a single command, so as not to take down the whole loop:
#include <exception>
#include <iostream>
#include <string>
void process_line(const std::string& line); // your logic
int main() {
std::string line;
while (std::getline(std::cin, line)) {
try {
process_line(line);
} catch (const std::exception& e) {
std::cout << "error: " << e.what() << '\n';
}
}
}
This design creates a clear semantics: "an error in one command is just a message, not the end of the world".
6. Mini cheat-sheet for choosing
When you are starting out, it's very helpful to have an "internal cheat-sheet", otherwise your brain will choose between throw and optional like between pizza and sushi: "I want it all, and preferably yesterday". Here we won't do philosophy, only practical math for everyday life.
Decision table
| Situation in code | What to choose | Why |
|---|---|---|
| "Not found" during lookup | |
It's a normal outcome, no need to make it an emergency |
| Invalid user input (frequent) | |
Expected and regular; convenient to give a clear message |
| Argument contract violated (shouldn't happen by program logic) | |
This is a design/logic-level error |
| Internal invariant broken | |
Continuing is dangerous, state is suspicious |
| Error occurs deep, decision is at the top | Exception + catch at the boundary | Avoid propagating checks through every level |
Two-question flowchart
flowchart TD
A[Is error possible?] -->|no| B[Normal return]
A -->|yes| C[Is this an expected branch?]
C -->|yes| D[Error as value: optional/variant]
C -->|no| E[Can you continue correctly?]
E -->|yes| D
E -->|no| F[Exception: throw]
The point of this diagram: don't start with the question "do I like exceptions or optional?". Start with "is this normal for the function or an emergency?".
7. Practical example: parsing and execution
Now let's assemble a small piece of TaskCLI to show how approaches coexist. We'll make parse_command which returns ParseResult (error as a value), and execute_command may throw an exception on invariant violation (exception as an emergency).
Parsing: error as a value
#include <string>
#include <variant>
struct AddCmd { std::string text; };
struct ListCmd { };
struct ParseError { std::string message; };
using ParseResult = std::variant<AddCmd, ListCmd, ParseError>;
ParseResult parse_command(const std::string& line) {
if (line == "LIST") return ListCmd{};
if (line.rfind("ADD ", 0) == 0) return AddCmd{ line.substr(4) };
return ParseError{"unknown command"};
}
Notice how easy it is to read: the function doesn't "jump", it simply returns one of the variants. For a CLI this is often ideal.
Execution: exception for the impossible
Suppose we have an invariant: task text must not be empty (by application logic an "empty task" is meaningless). Then execute may throw an exception if it receives an empty value (i.e. the parser or caller missed a check).
#include <stdexcept>
#include <string>
#include <vector>
struct Task { int id{}; std::string text; };
void add_task(std::vector<Task>& tasks, const std::string& text) {
if (text.empty()) throw std::invalid_argument{"task text is empty"};
tasks.push_back(Task{static_cast<int>(tasks.size() + 1), text});
}
Here throw is a "siren": if we reached empty text, there's a hole in the program, and it's better to highlight that hole.
8. Common mistakes
Mistake #1: using exceptions for "not found".
When a student first learns throw, there's a temptation to throw everywhere: didn't find a task — throw, unknown command — throw, user pressed Enter on an empty line — also throw (just in case). The problem is that lookup and "not found" are normal branches. If you make them exceptions, your try/catch becomes the main control mechanism of the interface, and the code becomes harder to read and test.
Mistake #2: returning "magic values" instead of an explicit result.
Returning -1 as an "error" index, an empty string as an "error" name, 0 as an "error" id — classic. It works until the first case when -1 is also a valid value (or someone forgets to check). std::optional and std::variant are good because the error stops being a hidden secret and becomes part of the type.
Mistake #3: catching an exception too early and "swallowing" it silently.
Sometimes people write catch (...) {} — and the program continues as if nothing happened. This is worse than crashing: you lose the signal of a problem and get a "silent corruption" of state. If you catch an exception, do at least: print what() or rethrow if you don't know what to do.
Mistake #4: printing error messages at every level.
A low-level function writes to std::cout "error!", then a higher level also writes "error!", then main() adds "error!" — and the user receives a triple scream. It's much cleaner to keep the rule: either a function returns an error as a value and does not print, or it throws an exception and also does not print; printing is done at the boundary (for example, in the command handler).
Mistake #5: mixing styles without agreement and ending up with a "patchwork API".
If one function returns nullopt for "not found", another throws out_of_range, a third returns -1, and a fourth prints to the console and returns false, then using this set is nearly impossible: every call must be remembered separately. Even in a learning project it's worth choosing a simple policy: user errors — as values, impossible states — exceptions, printing — at the scenario boundary.
GO TO FULL VERSION