std::expected Over Exceptions:
Error Handling in a Safety-Critical C++17 Codebase


------------------------------------------------------------------------------
Abhilash K S - Blog
------------------------------------------------------------------------------
Five years of building real-time embedded systems taught me that the best
error handling is the kind you can't accidentally skip.


I'm an ML Systems Engineer. For the past five years, I've been building and
maintaining a real-time data acquisition and processing system that runs on
embedded hardware. The system reads from sensors, processes signals on a GPU,
streams data over a message bus, and persists results to disk - all on a small
ARM board with no one around to restart it when something goes wrong.

Early on, we made a decision that shaped the entire codebase: no exceptions
in our own code.
Instead, every operation that can fail returns
std::expected<T, E> - a type that holds either a value or an
error, and forces the caller to deal with both.

Five years later, I can say it was the right call. Here's what I learned.

Due to the proprietary nature of my work, I'm unable to share actual code from
our codebase. So to keep things concrete, I'll illustrate every pattern with a
system most people can picture: a smart home hub - a small device that
talks to temperature sensors, controls lights, pushes data to the cloud, and
exposes a REST API for a mobile app. It's simpler than what I actually work on,
but the error-handling problems are identical. All code examples in this post
are AI-generated to mimic the patterns and idioms from our internal
implementations - the structure and design decisions are real, only the domain
has been changed.

------------------------------------------------------------------------------
What Is std::expected?

Introduced in C++23, std::expected<T, E> is a vocabulary type
that represents either a successful value of type T or an error of
type E. If your compiler doesn't support C++23 yet, the
tl::expected
library provides the same interface for C++17.

std::expected<SensorReading, SensorError> readTemperature(int sensorId);

This function signature tells you everything: it might give you a
SensorReading, or it might give you a SensorError.
There's no ambiguity, no hidden control flow, and no way to accidentally ignore
the failure case without writing code that explicitly does so.

Compare that to:

SensorReading readTemperature(int sensorId); // can this fail? who knows

------------------------------------------------------------------------------
Why Not Exceptions?

I'm not here to tell you exceptions are bad. They're a perfectly valid
error-handling mechanism, and they're the right choice in many codebases. But
in the kind of system I work on, they have three specific problems.

Invisible Control Flow

Consider a function that initializes your smart home hub:

void startHub(const Config& config) {
    auto db = openDatabase(config.dbPath);
    auto mqtt = connectBroker(config.brokerUrl);
    auto sensors = discoverSensors(mqtt);
    schedulePolling(sensors, db);
}

Which of these functions can fail? All of them? Some of them? What kinds of
errors can each one produce? The signature doesn't tell you. You have to read
the implementation - and the implementation of everything it calls - to find
out.

With std::expected, the failure modes are part of the contract:

std::expected<void, HubError> startHub(const Config& config) {
    auto db = openDatabase(config.dbPath);       // returns expected<Database, StorageError>
    auto mqtt = connectBroker(config.brokerUrl);  // returns expected<MqttClient, NetworkError>
    auto sensors = discoverSensors(mqtt);         // returns expected<vector<Sensor>, NetworkError>
    // ...
}

Now the function's shape tells you exactly where things can go wrong and what
kind of wrong they can go.

Cost in Hot Paths

Exception unwinding involves walking the stack, consulting unwind tables, and
running destructors - work that has unpredictable latency. In a system polling
sensors and streaming data at fixed intervals, a surprise 50-microsecond detour
through the unwinder can cause you to miss a read cycle.
std::expected is just a value on the stack. Checking it is a
branch instruction.

Binary Size on Embedded Targets

Exception handling tables add to binary size. On a beefy server, nobody cares.
On an embedded board with limited flash storage, the tables generated by
-fexceptions can add meaningful overhead. Some embedded toolchains
disable exceptions entirely with -fno-exceptions, making
std::expected not just a preference but a necessity.

The Caveat

None of this means we don't use try/catch at all. Third-party
libraries throw - JSON parsers throw on malformed input, standard library
functions like std::stoi throw on bad conversions. We catch those
at the boundary and convert them into expected errors. The rule is
simple: third-party code can throw; our code returns errors as values.

------------------------------------------------------------------------------
Designing Your Error Types

The first design decision is: what does E look like in
expected<T, E>?

One Enum Per Subsystem

Our smart home hub has distinct subsystems - sensors, storage, networking,
scheduling - and each one has its own error enum:

enum class SensorErr {
    DeviceNotFound,
    ReadTimeout,
    CalibrationInvalid,
    PermissionDenied
};

enum class StorageErr {
    DiskFull,
    CorruptedFile,
    InvalidPath,
    AlreadyOpen
};

enum class NetworkErr {
    ConnectionRefused,
    Timeout,
    AuthFailed,
    BrokerUnavailable
};

Each enum is small and domain-specific. SensorErr doesn't need to
know about disk space; StorageErr doesn't need to know about
connection timeouts. This keeps each subsystem's error vocabulary focused.

A Generic Error Wrapper

Bare enums lack context. SensorErr::ReadTimeout tells you
what went wrong, but not which sensor or why. We wrap
every enum in a generic Error<T> template that pairs the code
with a human-readable message:

template<typename T>
class Error {
    T code_;
    std::string message_;

public:
    Error(T code, std::string message = {})
        : code_(code), message_(std::move(message)) {}

    [[nodiscard]] T code() const noexcept { return code_; }
    [[nodiscard]] const std::string& message() const noexcept { return message_; }
};

using SensorError  = Error<SensorErr>;
using StorageError = Error<StorageErr>;
using NetworkError = Error<NetworkErr>;

Now an error carries both a matchable code (for control flow) and a descriptive
message (for logging and API responses):

return std::unexpected(SensorError{
    SensorErr::ReadTimeout,
    "Sensor 'living-room-temp' did not respond within 500ms"
});

When Strings Are Fine

Not every subsystem needs a taxonomy. For simple config parsing or input
validation, std::expected<T, std::string> is perfectly
adequate:

std::expected<ScheduleConfig, std::string> parseSchedule(const json& j) {
    if (!j.contains("intervalSec"))
        return std::unexpected("missing required field 'intervalSec'");

    int interval = j["intervalSec"].get<int>();
    if (interval < 1 || interval > 3600)
        return std::unexpected("intervalSec must be between 1 and 3600");

    return ScheduleConfig{interval};
}

Don't over-engineer error types for code that doesn't need to branch on error
categories.

------------------------------------------------------------------------------
Propagation Patterns

This is the section I wish existed when I started. The design of
std::expected gets plenty of blog coverage. The day-to-day
propagation
- how you thread errors through layers of code - doesn't.
Here's what actually works.

Pattern 1: Check-and-Return

The simplest and most common pattern. You call a function, check the result,
return early on error:

std::expected<void, SensorError> calibrateSensor(int sensorId) {
    auto reading = readRawValue(sensorId);
    if (!reading) return std::unexpected(reading.error());

    auto baseline = computeBaseline(*reading);
    if (!baseline) return std::unexpected(baseline.error());

    return applyCalibration(sensorId, *baseline);
}

Yes, it's verbose. You'll write
if (!result) return std::unexpected(result.error()); hundreds of
times. But there's an upside to that verbosity: every failure point is
visible in the function body.
A new engineer reading this code can see
exactly where things can go wrong without consulting any other file.

Pattern 2: Sequential Chaining

Real operations often involve multiple steps that must happen in order, where a
failure at any step should abort the whole sequence:

std::expected<void, SensorError> restartSensor(Sensor& sensor) {
    // Must stop before reconfiguring
    auto ret = sensor.stop();
    if (!ret) return std::unexpected(ret.error());

    ret = sensor.applyConfig(sensor.pendingConfig());
    if (!ret) return std::unexpected(ret.error());

    ret = sensor.start();
    if (!ret) return std::unexpected(ret.error());

    return {};  // success
}

Compare this to the exception-based version, where you'd need
try/catch with cleanup in the catch block to ensure the sensor
isn't left in a half-started state. With expected, the linear flow
makes the stop → configure → start sequence obvious.

Pattern 3: Error Wrapping Across Boundaries

When an error crosses from one subsystem to another, you often need to re-wrap
it with a different error type and additional context:

std::expected<void, HubError> startMonitoring(const Config& cfg) {
    auto sensorResult = initSensors(cfg.sensorConfig);
    if (!sensorResult) {
        // Wrap SensorError into HubError, preserving the original message
        return std::unexpected(HubError{
            HubErr::InitFailed,
            "Sensor initialization failed: " + sensorResult.error().message()
        });
    }

    auto storageResult = openStorage(cfg.storagePath);
    if (!storageResult) {
        return std::unexpected(HubError{
            HubErr::InitFailed,
            "Storage initialization failed: " + storageResult.error().message()
        });
    }

    return {};
}

This is the expected equivalent of Java's
throw new HigherLevelException("context", cause). The outer layer
gets a HubError it can reason about, and the original error
message is preserved for diagnostics.

Pattern 4: Enriching Errors with Context

When validating a list of items, a bare error like "invalid polling interval"
is useless - which item in the list was bad? Add positional context:

std::expected<std::vector<SensorConfig>, std::string>
parseSensorList(const json& arr) {
    std::vector<SensorConfig> result;

    for (size_t i = 0; i < arr.size(); ++i) {
        auto cfg = parseSingleSensor(arr[i]);
        if (!cfg) {
            return std::unexpected(
                cfg.error() + " (sensor index " + std::to_string(i) + ")"
            );
        }
        result.push_back(*cfg);
    }
    return result;
}

Now the error message reads:
"polling interval must be positive (sensor index 3)" - immediately
actionable without a stack trace.

What About Monadic Chaining?

std::expected in C++23 supports .and_then(),
.transform(), and .or_else(). The sequential
chaining example above could be written as:

std::expected<void, SensorError> restartSensor(Sensor& sensor) {
    return sensor.stop()
        .and_then([&]() { return sensor.applyConfig(sensor.pendingConfig()); })
        .and_then([&]() { return sensor.start(); });
}

Cleaner? Arguably. We don't use this style much - the team found the explicit
if (!ret) pattern easier to read and debug. But if you're starting
fresh, monadic chaining is worth considering for linear sequences.

------------------------------------------------------------------------------
The Boundary: Where expected Meets the Outside World

Errors eventually need to leave your system - as HTTP responses, log entries,
or MQTT messages. This is where the structured error types pay off.

Mapping Error Codes to HTTP Status

At the REST API layer, the error enum gives you a clean decision point:

void handleGetTemperature(const Request& req, Response& res) {
    auto reading = sensorManager.readTemperature(req.sensorId);

    if (!reading) {
        const auto& err = reading.error();
        switch (err.code()) {
            case SensorErr::DeviceNotFound:     res.status = 404; break;
            case SensorErr::ReadTimeout:        res.status = 504; break;
            case SensorErr::CalibrationInvalid: res.status = 500; break;
            case SensorErr::PermissionDenied:   res.status = 403; break;
        }
        json body;
        body["error"] = err.message();
        res.set_content(body.dump(), "application/json");
        return;
    }

    res.status = 200;
    res.set_content(reading->toJson().dump(), "application/json");
}

This wouldn't work with std::string errors - you'd be
pattern-matching on substrings. Enum codes give you exhaustive,
compiler-checked mapping
from domain errors to HTTP semantics.

As the number of handlers grows, factor out the mapping:

int httpStatusFromSensorError(const SensorError& err) {
    switch (err.code()) {
        case SensorErr::DeviceNotFound:     return 404;
        case SensorErr::ReadTimeout:        return 504;
        case SensorErr::CalibrationInvalid: return 500;
        case SensorErr::PermissionDenied:   return 403;
    }
    return 500;
}

The Exception Safety Net

Even in an expected-based codebase, your HTTP handlers should
still have a top-level try/catch. Third-party libraries can throw,
and a bug in your own code might trigger a standard library exception you
didn't anticipate:

void handleGetTemperature(const Request& req, Response& res) {
    try {
        // ... expected-based logic from above ...
    } catch (const json::exception& e) {
        res.status = 400;
        res.set_content(R"({"error":"Invalid request format"})", "application/json");
    } catch (const std::exception& e) {
        spdlog::error("Unexpected exception in GET /temperature: {}", e.what());
        res.status = 500;
        res.set_content(R"({"error":"Internal server error"})", "application/json");
    }
}

This isn't a contradiction - it's defense in depth. expected
handles the errors you anticipate. The try/catch catches the ones
you didn't.

The Logging Rule

One pattern that served us well: deep library code doesn't log; it
returns.
Only the boundary layer (HTTP handlers, main loop, startup code)
writes to the log. This prevents duplicate log entries and keeps the library
layer free of logging dependencies.

[Library]  →  returns expected<T, E>  →  no logging
[Handler]  →  checks expected, logs error, sends HTTP response

------------------------------------------------------------------------------
What I'd Do Differently After Five Years

Add a TRY Macro

The biggest complaint about expected is the boilerplate. Every
call requires three lines:

auto result = doSomething();
if (!result) return std::unexpected(result.error());
auto value = *result;

Rust solves this with the ? operator. C++ has no equivalent, but a
simple macro gets close:

#define TRY(expr)                                         \
    ({                                                    \
        auto _result = (expr);                            \
        if (!_result) return std::unexpected(_result.error()); \
        std::move(*_result);                              \
    })

Now the three lines become one:

auto value = TRY(doSomething());

I avoided macros for years out of principle. I was wrong. This one macro
eliminates half the visual noise in the codebase.

Note: The statement-expression syntax ({...}) is a
GCC/Clang extension, not standard C++. If portability to MSVC matters, you'll
need a different approach - such as a TRY that only propagates the
error without yielding the value.

Use Monadic Operations for Linear Chains

For config parsing, where you're doing parse → validate → transform →
return
, the .and_then() chain is genuinely cleaner than five
check-and-return blocks. I'd adopt it selectively - not everywhere, but for
the cases where it reads well.

Move to std::expected When You Can

If your compiler supports C++23, use the standard version. Same interface, no
dependency, better compiler diagnostics. tl::expected was the
right bridge - now cross it.

Be Consistent About String vs. Enum Errors

We ended up with some subsystems using Error<SomeEnum> and
others using std::string. It works, but it creates a mental
overhead - "which style does this module use again?" If I started over, I'd use
lightweight enums everywhere, even for simple modules. The cost of defining a
four-value enum is low; the consistency payoff is high.

------------------------------------------------------------------------------
A Decision Framework

Not every project needs std::expected. Here's a rough heuristic:

| Condition                                     | Lean toward expected | Lean toward exceptions |
|-----------------------------------------------|----------------------|------------------------|
| Errors are *expected* (invalid input, ...)    | Yes                  |                        |
| Errors are *exceptional* (OOM, logic bugs)    |                      | Yes                    |
| You need to branch on error type at call site | Yes                  |                        |
| Errors propagate many layers before handled   |                      | Yes                    |
| Hot path, latency-sensitive                   | Yes                  |                        |
| Embedded, -fno-exceptions possible            | Yes                  |                        |
| Codebase is heavily RAII                      |                      | Yes                    |

In practice, most real systems are a mix. Use expected for your
own code's anticipated failure modes. Let exceptions handle the truly
unexpected. Catch third-party exceptions at the boundary and convert them.

------------------------------------------------------------------------------
Closing

std::expected isn't about dogmatically avoiding exceptions. It's
about making a deliberate choice: if a function can fail, the caller should
know about it from the signature, and it should be difficult to ignore.


In a system running on a small board in a remote location - no one around to
read a crash log, no one to hit restart - "difficult to ignore" is exactly the
property you want from your error handling. Every if (!result)
check is a line of code that says: I thought about what happens when this
goes wrong.


That's worth the verbosity.

------------------------------------------------------------------------------
std::expected was standardized in C++23. For C++17/20
codebases, the tl::expected single-header library provides an equivalent implementation. The patterns in
this post work with both.


------------------------------------------------------------------------------
Home             LinkedIn             GitLab             GitHub            
------------------------------------------------------------------------------

This design is copyrighted. Obviously! "Don't steal it"