CodeGym /Courses /C++ SELF /Custom exceptions — naming, message and context

Custom exceptions — naming, message and context

C++ SELF
Level 53, Lesson 3
Available

1. Why custom exceptions and when runtime_error is not enough

When you're just starting out, it's tempting to do this: “Oh, an error — throw std::runtime_error("something went wrong")”. And that's… not terrible. It's even better than “return -1 and pretend it's success”. But as soon as your application gets a bit more alive, you find that a single text is not enough: you want to distinguish an error parsing a command from a business-logic error, and not found from bad format.

Custom exceptions are not for aesthetics or because “OOP is everything”. They exist so that the error type becomes part of the contract: code higher on the stack can understand the error category by type instead of trying to parse the message (that would be… ironic: parsing parser errors).

Imagine you are writing a small console application “expense tracking” (we'll develop one example). The user enters a command:

add 250 coffee

And then suddenly enters:

add -999999999999999999999 coffee

Or:

add 250

Or:

remove 10

In one case the problem is the number, in another — missing arguments, in the third — an invalid id. These are different errors, and sometimes you'd like to handle them differently: somewhere suggest the format, somewhere say “no such entry”, and somewhere — “number too big”.

2. Naming: how to choose categories

When you invent an exception name, you have two goals: make it understandable to a human and make it useful for code. Naming an exception MyException is like naming a variable x2: formally allowed, but practically about as useful as a sieve umbrella.

Usually exception names are built around the category of the error. A category answers the question: “What exactly went wrong at the semantic level?”.

Good, “practical” categories for a tutorial CLI app are typically: command parsing errors, input validation errors, domain-logic errors. You can use the suffix Error or Exception. In C++ Error is often seen in application code and ..._error/..._argument in the standard library. The main thing is consistency.

Here is a small table (not a dogma, just a “map”):

Name When appropriate What the reader understands without context
ParseError
the string/command cannot be parsed “format is invalid”
CommandError
the command is syntactically ok but logically impossible “command is invalid in the current state”
ValidationError
data violates rules (empty/too long/wrong) “bad input”
NotFoundError
element not found “searched and did not find”

A very common pitfall is to name an exception by place rather than by meaning. For example, LedgerError (“ledger error”). That can be fine as a base type, but for specifics it's better to use TransactionNotFound, InvalidAmount, ParseError. Place (module) often changes, while the meaning of the error usually stays stable.

3. What to inherit from: std::runtime_error vs std::exception

At this point I'd like to give a practical recipe without a three-volume philosophical treatise. In C++ you can throw anything, but if you want to play nicely with the rest of the code and standard handlers, it makes sense to be compatible with std::exception, so that what() works and a common catch (const std::exception&) can be used.

There are two common approaches: inherit directly from std::exception and implement what(), or inherit from std::runtime_error (or another from <stdexcept>) and just pass the message to the base class.

In tutorial projects and most application cases it's more convenient to inherit from std::runtime_error. This gives you ready-made message storage and a correct what() out of the box, and you add only what you need: context fields, extra accessors.

Inheriting from std::exception is fine too, but there you must store the string yourself and guarantee that what() returns a pointer to memory that lives long enough. Otherwise you'll get the famous mistake: “what() returned a pointer to a string that no longer exists”. That's, by the way, almost like “returning a reference to a local variable”, only in the world of exceptions — the pain will arrive suddenly and at the worst possible moment.

4. Exception message: what to put in what()

It's tempting to write a message like "Error" or "Bad input". Such messages look like “I know it's bad, but I won't tell you why”. Later you sit at night and wonder: “why is it bad?” — and C++ stays silent because you asked it to.

An exception message should answer at least three questions: what was being done, why it failed, which key parameter/fragment is to blame. Ideally it's short but specific. Imagine this message will be seen by someone in the logs who doesn't have access to your brain (yes, even you two weeks later are already “a stranger”).

You can keep a simple template in mind:

<category>: <operation> failed: <reason> (context)

For example:

  • ParseError: command failed: missing amount
  • ParseError: command failed: amount is not a number: '12x'
  • CommandError: remove failed: transaction id not found: 10

Note: we don't write “the user is dumb”. We write facts. The compiler also doesn't write “you are weird”, it writes expected ';' — and that's already offended enough.

5. Context: when fields are needed, not only text

Sometimes messages are enough. But sometimes you want not only to print the error, but, for example, highlight the position in the string (if we had a UI), or separately show the field name, or pass up the id of the record that wasn't found. If you hide everything in a string, later you won't be able to “get” it back without parsing the string (and we agreed not to do that).

Here comes the idea: an exception is a normal object. It can have fields. You can store there, for example, field, token, position, id. The message remains for humans, and the fields are for code.

Let's draw a small diagram of how an error typically evolves in an application:

flowchart TD
    A[User entered a command string] --> B[Parser tokenizes the input]
    B -->|format error| E[throw ParseError]
    B --> C[Built command/data]
    C --> D[Business logic executes the command]
    D -->|semantic error| F[throw CommandError]
    E --> G[main catches and prints]
    F --> G[main catches and prints]

The main idea: the parser throws one thing, the domain logic another, and the top level (main) decides what to show the user.

6. Practical example: mini-app “Expense tracking”

From now on we'll develop a single example. Let's have simple commands:

  • add <amount> <title>
  • remove <id>
  • list

We won't deal with files, JSON and other joys of the future. Everything lives in memory so we don't get distracted from exceptions.

Model and store

Let's start with the model and the store (minimal):

#include <string>

struct Expense {
    int id{};
    int amount{};
    std::string title{};
};

Now let's make the “ledger”:

#include <vector>
#include <string>

class Ledger {
public:
    void add(int amount, std::string title) {
        expenses_.push_back(Expense{next_id_++, amount, std::move(title)});
    }

    const std::vector<Expense>& all() const { return expenses_; }

private:
    int next_id_{1};
    std::vector<Expense> expenses_{};
};

There are no errors yet. But they will appear as soon as we implement remove(id) or start validating amount. We'll return to that later; for now focus on parsing and format errors.

7. ParseError: message and context field

Let's make our own type ParseError. We want it to be catchable as std::exception, to have a clear message, and to have a field input_ or field_ — for example, what exactly we tried to read.

Inherit from std::runtime_error:

#include <stdexcept>
#include <string>
#include <utility>

class ParseError : public std::runtime_error {
public:
    ParseError(std::string input, std::string message)
        : std::runtime_error("ParseError: " + message + " | input='" + input + "'")
        , input_(std::move(input)) {}

    const std::string& input() const { return input_; }

private:
    std::string input_;
};

There are a few important points here. We compose a human-readable message once in the constructor, and it will be available via what(). At the same time the original string input_ is stored separately so it can be used independently from the error text if needed.

What counts as “context”

Context is not “everything you know about the world”. It's a small set of data that helps understand the error or make a decision. For example, for command parsing it's often useful to have command_name, field, token, position. For domain logic it's more useful to have id, amount, limit, state.

Bad context is a huge 2MB object or a reference to an object that will soon die. Exceptions should be relatively lightweight. Remember: an exception is not a shipping container for your entire database, but an “envelope” with the reason for failure.

If in doubt, put into the exception only what is easy to output or show to the user: short strings, numbers, small structures.

8. Parsing and error normalization

Parsing the first word

Let's make a function that reads the first “command” from a string. We won't use std::stringstream yet (that will be in another lecture), so we'll do a simple parse using find.

#include <string>

std::string first_word(const std::string& s) {
    auto pos = s.find(' ');
    if (pos == std::string::npos) return s;
    return s.substr(0, pos);
}

parse_amount_or_throw and translating exceptions into ParseError

Now write a function parse_add_amount that expects the command add <amount> <title>. We'll deliberately make it a bit strict: if the format is wrong — throw ParseError.

#include <string>
#include <stdexcept>

int parse_amount_or_throw(const std::string& token, const std::string& input) {
    if (token.empty()) {
        throw ParseError{input, "missing amount"};
    }
    try {
        return std::stoi(token);
    } catch (const std::exception&) {
        throw ParseError{input, "amount is not a number: '" + token + "'"};
    }
}

It's important that we catch “some standard thing” and turn it into our error so higher-level code sees specifically ParseError. We're not discussing the nuances of what stoi throws — the idea is “caught and normalized”.

Note that inside the catch we throw a new exception. This is a normal technique when you want to translate a low-level error into your application's category.

Enrichment and rethrowing

In earlier lectures you've seen throw; for rethrowing. Remember the difference: throw; rethrows the same exception, preserving its object. This behavior is linked to the fact that the exception object is available via the handler, and the standard explicitly discusses observing the exception object via the handler declaration.

But today the practical scenario is: we catch a “raw” error and throw a “normalized” one — with our type and message. Then higher on the stack it's easier to build UX: we have a clear category and predictable text.

A common strategy looks like this: low level throws standard exceptions or its own small ones, the middle layer converts them into more “meaningful” ones, and the top layer prints. The main thing — do not print at every level, or you'll get three identical messages and the feeling that the program is stuck.

9. Domain error and handling in main

CommandError for logic, not format

Now create a second exception, this one about the action, not parsing. For example, removal by id.

#include <stdexcept>
#include <string>

class CommandError : public std::runtime_error {
public:
    explicit CommandError(std::string message)
        : std::runtime_error("CommandError: " + std::move(message)) {}
};

Ledger::remove throws CommandError if id not found

Let's add remove to Ledger. Let it throw CommandError if the id is not found.

#include <algorithm>
#include <vector>

class Ledger {
public:
    void add(int amount, std::string title) {
        expenses_.push_back(Expense{next_id_++, amount, std::move(title)});
    }

    void remove(int id) {
        auto it = std::find_if(expenses_.begin(), expenses_.end(),
                               [id](const Expense& e){ return e.id == id; });
        if (it == expenses_.end()) {
            throw CommandError{"expense id not found: " + std::to_string(id)};
        }
        expenses_.erase(it);
    }

    const std::vector<Expense>& all() const { return expenses_; }

private:
    int next_id_{1};
    std::vector<Expense> expenses_{};
};

Note the important thing: the error type reflects meaning. ParseError says “I can't understand the command”, while CommandError says “I understood the command, but I can't execute it”.

Handling point: catch in main and print what()

Now let's assemble a mini read loop. We'll read lines with std::getline, so commands can contain spaces in the title.

#include <iostream>
#include <string>

int main() {
    Ledger ledger;

    std::cout << "Expense CLI. Type 'exit'.\n";
    for (std::string line; std::getline(std::cin, line);) {
        if (line == "exit") break;

        try {
            // command handling will be here later
            std::cout << "got: " << line << '\n';
        } catch (const ParseError& e) {
            std::cout << e.what() << '\n';
        } catch (const CommandError& e) {
            std::cout << e.what() << '\n';
        } catch (const std::exception& e) {
            std::cout << "Unexpected error: " << e.what() << '\n';
        }
    }
}

Why do we catch ParseError and CommandError separately? Because tomorrow (or even in an hour) you'll want to print them differently: for ParseError show a format hint, and for CommandError — a simple “not found”.

And yes: catch by const&. This is standard practice because you don't copy the exception object and you preserve polymorphism.

UX hints and what(): don't turn the message into a novel

It's very tempting to add everything to the message: the full input, a hint, an example, and a motivational quote. But what() is a diagnostic message; it should be short enough. If you want to print “how to do it correctly” to the user, do that in the handler code, not bake it forever into what().

For example, for ParseError you can print what(), and then on a separate line print a hint:

catch (const ParseError& e) {
    std::cout << e.what() << '\n';
    std::cout << "Hint: add <amount> <title> | remove <id> | list\n";
}

This way you separate “diagnosis” and “UX”. The exception reports the fact of the error, and the UI layer decides how to help the user.

10. Common mistakes

Mistake #1: throwing std::string or int, then catching “something”.
Technically C++ allows throwing almost any type, but then you lose a common compatibility point (std::exception) and proper diagnostics via what(). As a result handlers proliferate across the code, and main gets a zoo of catch (std::string), catch (int) and “just in case catch (...)”.

Mistake #2: making one exception type for everything (“AppError”) without meaningful categories.
If all errors are one type, the type stops being useful. You again distinguish reasons by text, and text is for humans, not logic. Usually it's better to have several small categories: at least ParseError and CommandError, and extend only when really needed.

Mistake #3: forming what() from a temporary string and returning a dangling pointer.
If you inherit from std::exception and implement what() manually, don't return c_str() of a temporary. The message should be stored in a field (for example, std::string msg_), so the memory lives as long as the exception object.

Mistake #4: overloading the exception with excessive context and turning it into a “truck”.
An exception shouldn't drag along half the program state. Put there small, cheap and useful data: id, field name, position, short token. Everything else is better logged or handled separately.

Mistake #5: printing the error at every level and getting an “echo” of three identical messages.
Low-level function throws, middle layer catches and prints, top layer catches and prints again — and the user sees the same problem multiple times. Much cleaner: lower levels either rethrow or translate the type, and printing is done at a single chosen point (usually closer to main).

1
Task
C++ SELF, level 53, lesson 3
Locked
Age Validation
Age Validation
1
Task
C++ SELF, level 53, lesson 3
Locked
Parsing token
Parsing token
1
Task
C++ SELF, level 53, lesson 3
Locked
Two errors
Two errors
1
Task
C++ SELF, level 53, lesson 3
Locked
Service configuration
Service configuration
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION