Capstone 2: error handling

The tracker has three kinds of failure: a user typed something wrong, the input violates a rule, and the program has a bug. The first two have messages, the third has an exception, and the command layer is where the three become a line of text and a status.

std::expected of lesson 134 is the return type for the first two. This lesson runs a failure from the parser to the message, then collects several errors at once, and ends with the call that throws when the check was skipped.

The three kinds of failure also decide the tests: a user error is a table row, a rule violation is a model test and a bug is the one a sanitizer catches. Mixing them in one handler loses the distinction.

A failure that is a value

parse_id returns std::expected<int, std::string>: an int on success and the text of the reason on failure. The caller gets both cases in the type and cannot forget the second one without writing value() on purpose.

The program parses a bad id and a good one and prints the error of the first and the value of the second. The output is not a number: x 42, and no exception was thrown for a user's typo.

The message is built where the knowledge is: the parser knows that the text is not a number, and the command layer only prints what it receives. A layer that rewrote the message would lose the detail the parser had.

#include <expected> #include <iostream> #include <string> std::expected<int, std::string> parse_id(const std::string& text) { if (text.empty() || text.find_first_not_of("0123456789") != std::string::npos) { return std::unexpected{"not a number: " + text}; } return std::stoi(text); } int main() { auto bad = parse_id("x"); auto good = parse_id("42"); std::cout << bad.error() << ' ' << good.value() << '\n'; }

g++ -std=c++23 -Wall -Wextra expected.cpp -o tasks_expected
./tasks_expected

not a number: x 42

The message and the status

The command layer prints the error to the error stream and returns a status. The user sees one sentence, and a script sees a non-zero status, which are the two channels a terminal program has.

The program is run with a bad argument and with a good one. The bad run prints the message and exit=1; the good run prints the parsed id and exit=0.

The error stream and the normal stream are separate on purpose: a script can read the result while the message goes to the terminal or a log. Lesson 067 covered the two streams.

#include <expected> #include <iostream> #include <string> std::expected<int, std::string> parse_id(const std::string& text) { if (text.empty() || text.find_first_not_of("0123456789") != std::string::npos) { return std::unexpected{"not a number: " + text}; } return std::stoi(text); } int main(int argc, char** argv) { std::string text = argc > 1 ? argv[1] : ""; auto id = parse_id(text); if (!id) { std::cerr << id.error() << '\n'; return 1; } std::cout << id.value() << '\n'; return 0; }

g++ -std=c++23 -Wall -Wextra message.cpp -o tasks_id
./tasks_id x; echo "exit=$?"
./tasks_id 42; echo "exit=$?"

not a number: x
exit=1
42
exit=0

Several errors at once

Validation is the one place where stopping at the first error is not helpful: the user can fix three fields in one edit. validate returns a vector of messages, and the caller prints all of them.

The program validates a task with an empty title, a zero id and an impossible date, and prints the three messages. The count is 3, and each message names its field.

The vector is empty when the task is valid, so the caller tests the vector rather than a bool. A single error would be a vector of one, which is the price of the general form and a small one.

#include <chrono> #include <iostream> #include <string> #include <vector> struct task { int id; std::string title; std::chrono::year_month_day due; }; std::vector<std::string> validate(const task& item) { std::vector<std::string> errors; if (item.id <= 0) { errors.push_back("id must be positive"); } if (item.title.empty()) { errors.push_back("title is empty"); } if (!item.due.ok()) { errors.push_back("due date does not exist"); } return errors; } int main() { task broken{0, "", std::chrono::year{2026} / 2 / 30}; std::vector<std::string> errors = validate(broken); for (const std::string& message : errors) { std::cout << message << '\n'; } std::cout << errors.size() << " errors\n"; }

g++ -std=c++23 -Wall -Wextra collect.cpp -o tasks_collect
./tasks_collect

id must be positive
title is empty
due date does not exist
3 errors

The check that was skipped

value() on an expected that holds an error throws std::bad_expected_access<E>. The program below skips the check and catches the exception instead, which is what a bug looks like when the input comes from a place the parser trusted.

The output is error: not a number: x: the catch reads the stored error and prints it with the same words the checker would have used.

The exception is the safety net, not the plan. The command layer checks the expected and reports the error; a value() without the check is a line that a review should catch, and the tests of the next lesson are the other net.

#include <expected> #include <iostream> #include <string> std::expected<int, std::string> parse_id(const std::string& text) { if (text.empty() || text.find_first_not_of("0123456789") != std::string::npos) { return std::unexpected{"not a number: " + text}; } return std::stoi(text); } int main() { auto id = parse_id("x"); try { std::cout << id.value() << '\n'; } catch (const std::bad_expected_access<std::string>& error) { std::cout << "error: " << error.error() << '\n'; } }

g++ -std=c++23 -Wall -Wextra throw.cpp -o tasks_throw
./tasks_throw

error: not a number: x

Exercises

  1. Add a third layer that checks whether the id exists and returns its own expected.
  2. Make validate return expected> and check the empty success.
  3. Print the errors to the error stream and return the count as the exit code.

Questions

A new set of questions in a new order every time. Choose one answer for each, then check.

  1. What does std::expected<int, std::string> express?

    Answer

    Either an int or the message of a failure

    The two types are the success value and the error value, and the caller must handle both.

  2. Why is expected better than an int code here?

    Answer

    The error message travels with the failure

    The code alone would need a second lookup to become a sentence for the user.

  3. Where is the error message written?

    Answer

    Where the failure is detected

    Each layer knows its own failure best, and the caller only prints the text it receives.

  4. What does this program print? #include <expected> #include <iostream> #include <string> std::expected<int, std::string> parse_id(const std::string& text) { if (text.empty() || text.find_first_not_of("0123456789") != std::string::npos) return std::unexpected{"not a number: " + text}; return std::stoi(text); } int main() { std::cout << parse_id("x").error() << ' ' << parse_id("42").value(); }

    Answer

    not a number: x 42

    The bad text produces the message and the good one produces 42.

  5. What does this program print? #include <expected> #include <iostream> #include <string> std::expected<int, std::string> parse_id(const std::string& text) { if (text.empty() || text.find_first_not_of("0123456789") != std::string::npos) return std::unexpected{"not a number: " + text}; return std::stoi(text); } int main() { auto id = parse_id("x"); if (!id) { std::cout << id.error(); return 1; } std::cout << id.value(); }

    Answer

    not a number: x

    The parse fails, the message is printed, and main returns 1 instead of a value.

  6. What does this program print? #include <iostream> #include <string> #include <vector> struct task { int id; std::string title; }; std::vector<std::string> validate(const task& item) { std::vector<std::string> errors; if (item.id <= 0) errors.push_back("id must be positive"); if (item.title.empty()) errors.push_back("title is empty"); return errors; } int main() { task broken{0, ""}; auto errors = validate(broken); for (const auto& message : errors) std::cout << message << '\n'; std::cout << errors.size() << " errors"; }

    Answer

    id must be positive / title is empty / 2 errors

    Each field fails its own check, so both messages are collected and counted.

  7. Why does validate return a vector instead of the first error?

    Answer

    The user can fix several fields in one edit

    Reporting all the problems saves a round trip through the editor.

  8. What does value() throw on an error?

    Answer

    std::bad_expected_access<E>

    The exception carries the stored error and is templated on its type.

  9. What does this program print? #include <expected> #include <iostream> #include <string> std::expected<int, std::string> parse_id(const std::string& text) { if (text.empty() || text.find_first_not_of("0123456789") != std::string::npos) return std::unexpected{"not a number: " + text}; return std::stoi(text); } int main() { auto id = parse_id("x"); try { std::cout << id.value(); } catch (const std::bad_expected_access<std::string>& error) { std::cout << "error: " << error.error(); } }

    Answer

    error: not a number: x

    The unchecked value() throws, and the catch prints the stored message.

  10. Which stream should a user-facing error go to?

    Answer

    stderr

    The error stream keeps the normal output clean for a script that reads it.

The code on this page was built with g++ 15.2.0 and run on Alpine Linux 3.24.3 (x86_64) in a Docker container.

Article author: Arthur Isaev

Banner image