C++ Exception Handling: Typed Errors, Stack Unwinding and Safe Recovery

Build a small C++ parser that reports machine-readable failures, recovers only from a malformed optional field, and preserves saved state when a required field fails.

KnowledgeGate Team

Exam prep & CS education

Updated 8 Sep 20266 min read

Learning try, throw, and catch is only the starting point. The harder API-design problem is creating a failure another function can use safely. In the parser below, a malformed optional discount produces a typed error. The caller inspects stable fields, applies a deliberate fallback, and commits saved state only after validation.

C++ exception handling is a non-local control-flow contract

Code in a try block calls functions normally until a throw creates an exception object and stops that path. The runtime searches outward for the nearest compatible catch, called the handler. Moving the failure through callers is propagation. Destroying locals in the crossed stack frames is stack unwinding. If no handler matches, the program terminates.

That mechanism does not decide policy. A low-level parser can detect that oops is not an unsigned integer, but it should not silently replace it with zero. It reports a typed failure. The caller that understands whether discount is optional decides to recover, retry, translate, or rethrow. This separation matters across the wider Coding & DSA path: an error mechanism carries facts, while the owning layer chooses what those facts mean for the operation.

Design a typed ParseError that carries stable facts

Derive the domain exception from std::runtime_error, following the type relationship in Object Oriented Technology Explained. Keep exactly four immutable public facts: field, offset, token, and expected. Do not throw an integer, C string, or bare std::runtime_error, because none gives this caller the fields it needs.

struct ParseError final : std::runtime_error {
    const std::string field;
    const std::size_t offset;
    const std::string token;
    const std::string expected;

    ParseError(std::string f, std::size_t o, std::string t, std::string x)
        : std::runtime_error("cannot parse field '" + f + "' at byte " +
              std::to_string(o) + ": expected " + x + ", got '" + t + "'"),
          field(std::move(f)), offset(o), token(std::move(t)), expected(std::move(x)) {}
};

unsigned parseUnsigned(std::string field, std::string_view token, std::size_t offset) {
    unsigned value{};
    const char* end = token.data() + token.size();
    auto [ptr, ec] = std::from_chars(token.data(), end, value);
    if (ec == std::errc{} && ptr == end) return value;
    throw ParseError(std::move(field), offset, std::string(token), "unsigned integer");
}

For the worked failure, the payload is "discount", 16, "oops", and "unsigned integer". Its what() text is exactly cannot parse field 'discount' at byte 16: expected unsigned integer, got 'oops'. The message is for diagnostics. Callers inspect fields, never parse that sentence. Requiring ptr == end also rejects "12x" instead of accepting the partial value 12.

Worked parser: recover from one malformed optional field

Use the plain ASCII input qty=12;discount=oops;total=48. Offsets are zero-based bytes. They also look like character positions here, but the API deliberately promises bytes.

Field

Token

Value starts at byte

qty

12

4

discount

oops

16

total

48

27

The caller parses required qty first, handles only the optional-field failure it owns, and commits once at the end:

unsigned qty = parseUnsigned("qty", "12", 4);       // returns 12
unsigned discount = 0;
try {
    discount = parseUnsigned("discount", "oops", 16);
} catch (const ParseError& e) {
    if (e.field != "discount") throw;
    std::cerr << e.what() << "; using discount=0\n";
}
unsigned total = parseUnsigned("total", "48", 27);  // returns 48
Order candidate{1042, qty, discount, total};
savedOrders.push_back(candidate);

The diagnostic is cannot parse field 'discount' at byte 16: expected unsigned integer, got 'oops'; using discount=0. The committed record is Order{id=1042, qty=12, discount=0, total=48}. Zero is the caller's documented fallback for this optional field, not a number invented by parseUnsigned.

Now change the input to qty=12x;discount=5;total=48. The call parseUnsigned("qty", "12x", 4) throws because from_chars stops before the trailing x. Since qty is required, parseOrder rethrows the failure and no Order{1042,...} is appended.

A four-panel flow parsing qty=12;discount=oops;total=48, throwing ParseError on discount, then committing an Order with discount=0.

Stack unwinding and RAII clean up the failed path

For qty=12x;discount=5;total=48, follow runBatch -> importFile("order-1042.txt") -> parseOrder -> parseUnsigned("qty", "12x", 4). importFile constructs std::ifstream input first and std::vector<char> scratch(4096) second. parseOrder has local parsing state, but no Order candidate has been constructed or committed.

The exception is ParseError{field="qty", offset=4, token="12x", expected="unsigned integer"}. As it reaches runBatch, parseOrder locals are destroyed first. Next scratch releases its 4096-character buffer, then input closes the file while importFile unwinds. runBatch catches the error, rejects record 1042, and can continue with record 1043.

Acquire resources with RAII streams, containers, and smart pointers, which clean up in reverse construction order. Do not duplicate manual close() calls across catch branches, and do not let a cleanup destructor throw while unwinding.

Four stacked call frames with a qty ParseError unwinding upward, freeing locals and closing the file before runBatch rejects the order.

Exception safety: build a candidate, then commit once

Exception safety describes what remains true when an operation fails:

Guarantee

Meaning

In this parser

Basic

Invariants hold, although visible state may change

savedOrders must remain valid even after failure

Strong

Success, or no visible change

A required-field failure leaves savedOrders unchanged

No-throw

The operation promises not to emit an exception

Suitable only for operations that genuinely cannot fail and safe cleanup paths

Before the call, let savedOrders = [Order{id=1041, qty=3, discount=5, total=60}]. Parse into local values and build candidate before savedOrders.push_back(candidate). With malformed required qty=12x, parsing fails before that commit point, so the vector remains exactly [Order{id=1041, qty=3, discount=5, total=60}].

With discount=oops, the caller chooses 0, finishes the candidate, and commits it once. The vector then contains records 1041 and 1042 exactly once each. Catching an exception did not create the strong guarantee. Delaying shared-state mutation until validation and recovery were complete created it.

Exceptions, returned errors, and assertions solve different problems

Failure handling is an API choice. The Programming Languages Survey shows how languages express such contracts differently.

Mechanism

Best fit

Applied to this parser

Exception

A function cannot produce its promised value, and failure may cross several call layers

Throw ParseError when required qty="12x" prevents a valid Order

Returned status or typed result

Expected branching whose outcome every caller should inspect

Return an error for optional discount="oops" if malformed optional input is routine

Assertion

A programmer invariant, not user-input validation or recovery

assert(offset <= line.size()) can check internal indexing during development

assert(token == "12") is wrong for either input case because external data can be malformed. Exceptions are not automatically superior to returned errors, and neither should hide useful facts. Whatever mechanism the API selects, provide machine-readable fields and make the mutation boundary obvious.

C++ exception traps and question forms that reveal them

Five corrections prevent common bugs:

  1. Catch const ParseError& to avoid copying the exception object. Catching a non-final exception hierarchy by value can also slice it.

  2. Put catch (const ParseError&) before catch (const std::exception&). With that order, the first handler receives the worked exception. If the broader handler comes first, it handles the object and the specific branch is unreachable for that throw.

  3. Use bare throw; to preserve the current exception.

  4. Do not use exceptions as a loop's normal success path.

  5. Never let a destructor emit a second exception during unwinding.

A precise trace question might ask for cleanup after qty="12x": parseUnsigned and parseOrder locals, then scratch, then input. A state-guarantee question should leave the vector exactly [Order{id=1041, qty=3, discount=5, total=60}] after the required-field failure.

The short version and a deliberate next step

Keep four rules together: throw a typed object, catch at the layer that owns recovery, rely on RAII while frames unwind, and mutate shared state only after validation. That produces Order{id=1042, qty=12, discount=0, total=48} for the recoverable input and rejects required qty="12x". Change discount=oops first to discount=7, then to discount=7x, and predict the return or exception before running it. For structured C++ and placement-oriented coding, continue with Coding For Placements.