Modern C++ Programming
Learn the fundamentals and advanced features of Modern C++ to build efficient, maintainable, and high-performance applications.
Illustration of a simple physics simulation
Chapter 6. Chapter 6 — Designing with Errors, Exceptions, and Contracts

Error handling is one of the most important—and most frequently misunderstood—parts of C++. Beginners often overlook it, intermediate developers may apply it inconsistently, and experienced professionals recognize it for what it really is: a fundamental architectural concern that shapes the reliability of the entire system.

Modern C++ gives you a rich set of tools for expressing failure, enforcing correctness, and protecting invariants—but those tools only help when you choose them deliberately. A well-designed error strategy tells callers what can go wrong, makes recovery paths visible, and prevents small failures from turning into mysterious bugs. In this chapter, you’ll learn how to build error-handling strategies that are clear, predictable, robust, and easy to reason about.

Design for Errors

Every function has two responsibilities:

  1. Fulfill the promise it makes
  2. Account for possible failures

Modern C++ encourages you to plan for errors before you start coding:

Think of error handling as part of the API’s promise, not as emergency logic bolted on later. The moment you design a function’s successful result, you should also decide how that function will describe failure and what its caller is expected to do next.

Three Error Handling Strategies

Modern C++ primarily uses three strategies:

  1. Return Value Based Errors

    This approach is simple, explicit, and predictable.

  2. Exceptions

    Use exceptions for exceptional failures that must be handled somewhere else.

  3. Contracts (Assertions)

    Use contracts for programmer errors and violations of invariants.

Each strategy has a distinct purpose, and the differences matter. Return values keep expected failure visible, exceptions move exceptional failure to a layer that can handle it, and contracts expose broken assumptions in the program itself. Good C++ design begins by putting each kind of failure in the right category.

Return-Value Based Errors

Return values work best when you are dealing with:

Here is an example:

bool LoadTexture(const std::string& path, Texture& outTexture);
The caller now remains in control. If the texture cannot be loaded, it can log the problem, choose a fallback, retry with another path, or stop the current operation—all without losing sight of the normal control flow.

Using std::optional

std::optional<Texture> LoadTexture(const std::string& path);

If loading fails, you can handle it like this:

auto tex = LoadTexture("wood.png");
if (!tex)
{
    // handle failure
}

std::optional clearly communicates that the operation may fail.

Using std::expected (C++23)

When std::expected is available, you can write:

std::expected<Texture, ErrorCode> LoadTexture(const std::string& path);

This gives you a modern and expressive way to return either a valid result or meaningful error information. Instead of forcing the caller to guess why loading failed, the type itself makes both possible outcomes explicit.

Exceptions

Exceptions are best suited to:

Here is an example:

Texture::Texture(const std::string& path)
{
    if (!Load(path))
        throw std::runtime_error("Failed to load texture");
}

When to use exceptions

Use exceptions when:

When not to use exceptions

Avoid exceptions when:

Exceptions are powerful because they can carry a failure across several layers without forcing every intermediate function to return and forward an error code. That same power demands restraint: when exceptions are used casually, control flow becomes harder to follow and recovery becomes less predictable. Use them deliberately, at boundaries where their ability to separate normal execution from exceptional recovery provides a real benefit.

noexcept and Strong Guarantees

Modern C++ encourages you to mark functions that do not throw as noexcept. This small keyword makes a strong public promise: callers, containers, and the compiler can all rely on the function completing without allowing an exception to escape.

void Update() noexcept;

The main benefits are:

Use noexcept when:

Contracts: Preconditions, Postconditions, and Invariants

Contracts state what must be true:

Preconditions

These are the conditions that must be true before a function is called.

assert(index < v.size());

Postconditions

These are the conditions that must be true after a function returns.

Invariants

These are the conditions that must always hold for a type.

For example, consider this class:

class Timer
{
public:
    void Update()
    {
        assert(delta >= 0.0f);
    }

private:
    float delta;
};

Contracts help you expose programmer errors early, close to the point where an assumption is broken. Instead of allowing an invalid state to travel silently through the program, a contract turns that hidden defect into an immediate, visible signal that the code’s rules have been violated.

Assertions (assert)

Assertions are especially useful for:

For example, you can write:

assert(ptr != nullptr);

Because standard assertions are removed from release builds, they do not add runtime overhead there. That makes them excellent development-time guardrails—but it also means they must never be the only protection against a failure that can legitimately occur in production.

Error Handling in Real Systems

Resource Loading

auto tex = LoadTexture("wood.png");
if (!tex)
{
    LogError("Failed to load texture");
    return;
}

Initialization

Window window(1280, 720); // may throw

Engine Loop

try
{
    Engine.Run();
}
catch (const std::exception& e)
{
    LogFatal(e.what());
}

Contracts

assert(entity != nullptr);

In a real system, modern C++ typically combines all three strategies. Resource loading may return an empty result because a missing asset is recoverable; initialization may throw because the application cannot continue without a valid window; and assertions may defend assumptions inside the engine. The result is not a random mixture, but a layered design in which each failure mechanism answers a different question.

Designing Error Handling Policies

An effective error-handling policy should:

For example, your policy might say:

A policy like this keeps the architecture predictable. Developers can approach an unfamiliar function and quickly understand how failure will appear, where it should be handled, and which guarantees they can trust. That consistency reduces guesswork, avoids duplicated recovery logic, and makes the codebase easier to maintain as it grows.

Avoiding Common Error Handling Mistakes

Common mistakes in error handling include:

These mistakes often look harmless in isolation, but across a large codebase they create uncertainty: callers stop knowing which failures are recoverable, exceptions surface in surprising places, and ignored results quietly corrupt program state. A clear policy keeps modern C++ code away from these traps and makes failure behavior consistent rather than accidental.

Summary

In this chapter, you learned:

With these principles in place, you can design systems that do more than succeed when conditions are perfect—they can also fail clearly, recover deliberately, and protect their own assumptions. That foundation prepares you for the next chapter, where we turn from runtime behavior to the structure of a C++ program through translation units, headers, and build systems.