#064 – Handling optional values safely with std::optional

For most of C++’s history, the language had no first-class way to say “this function might not have a value to return.” Programmers improvised: a sentinel like string::npos or -1 for indices, a null pointer for objects, a bool return paired with an output parameter, or an exception for the absence of a result. Each of these works, and each fails differently — sentinels collide with real values, output parameters bloat signatures, and exceptions are the wrong tool for the routine case of “didn’t find it.” C++17’s std::optional<T> collapses all of these into a single type that the compiler understands.

Three things to take away:

  • std::optional<T> holds either a T or nothing, with the empty state encoded in the type instead of a sentinel value.
  • Always check before reading: opt and opt.value() are undefined / throwing on an empty optional. Use value_or for defaults and has_value() (or if (opt)) for explicit checks.
  • Chain optionals with and_thentransform, and or_else to avoid the nested if (opt) { ... } pyramid.

The pre-optional mess

Three idioms dominated pre-C++17 code. None of them is good:

// 1. Magic sentinel value
std::size_t pos = haystack.find("needle");
if (pos == std::string::npos) { /* not found */ }

// 2. Bool return + output parameter
bool tryLookup(int key, Value& out);

// 3. Exception for the routine case
Value lookup(int key);   // throws NotFound — even when "not found" is normal

The sentinel works only when the return type has a value to spare. find can use npos because std::string::size_type is unsigned and -1 (which npos is, by definition) is unreachable as a real position. A function returning a signed int, or a Color, or any struct, has no such spare value — and inventing one (returning -1 for a function whose result is “always non-negative except on failure”) quietly bakes a partial function into the type system.

The output-parameter form moves the value out of the return slot, which doubles the function’s surface area and forces every caller to declare a default-constructed temporary. The exception form is correct only when the absence of a value is genuinely exceptional — looking up a key that might not exist is not.

What std::optional is

std::optional<T> is a value type that holds either a T or the empty state, called disengaged. The empty state is represented at the API level by std::nullopt:

#include <optional>
#include <string>

std::optional<int> parsePort(std::string_view s){
    int port{};
    auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), port);
    if (ec != std::errc{} || port < 1 || port > 65535)
        return std::nullopt;
    return port;
}

The function’s signature now tells the whole story: it either returns a port number or it doesn’t. The caller cannot mistake a sentinel for a real value, because there is no sentinel. The empty state is a separate inhabitant of the type, not a magic member of the value space.

Construction is direct. Returning a T engages the optional; returning std::nullopt (or a default-constructed optional<T>{}) disengages it. There is no separate flag to keep in sync.

Reading an optional safely

std::optional provides several ways to get the value out, and choosing the right one is the difference between safe and crashing code:

std::optional<int> port = parsePort(input);

// 1. Explicit check, then dereference
if (port.has_value()) {
    use(*port);
}

// 2. The contextual bool conversion (more idiomatic)
if (port) {
    use(*port);
}

// 3. Default if disengaged
int p = port.value_or(8080);

// 4. Throwing accessor — only when you've already validated
int p = port.value();   // throws std::bad_optional_access if empty

The two unsafe operations are *opt and opt.value()*opt on a disengaged optional is undefined behaviour — same category as dereferencing a null pointer. opt.value() throws std::bad_optional_access, which is safer but still wrong in any context where the empty case is part of normal control flow.

The right idiom for “use the value if we have one, otherwise a default” is value_or. The right idiom for “we definitely have one because we just checked” is *opt. The wrong idiom — calling value() without a check, or *opt without a check — is the shape of bug optional is supposed to prevent, not enable.

Chaining: avoiding the ifpyramid

When a sequence of operations might each fail, the explicit-check form turns into a staircase:

std::optional<int> port = parsePort(input);
if (port) {
    auto socket = openSocket(*port);
    if (socket) {
        auto reply = handshake(*socket);
        if (reply) {
            use(*reply);
        }
    }
}

C++23 added monadic operations that flatten this:

parsePort(input)
    .and_then(openSocket)
    .and_then(handshake)
    .transform(use);

The three operations have distinct shapes. and_then takes a function returning optional<U> and propagates the empty state — this is for steps that themselves can fail. transform takes a function returning a plain U and lifts it back into an optional<U> — this is for steps that always succeed when given a value. or_else takes a function returning optional<T> and runs only when the source is empty — useful for fallback lookups.

The chain is short-circuit by construction: any disengaged step skips the rest. There is no place for an unchecked dereference to hide.

When not to use optional

std::optional is the right tool when “no value” is a meaningful outcome of the operation itself. It is the wrong tool in two common cases:

  1. Function parameters with a sensible default. A parameter of type std::optional<int> adds a layer of unwrapping where a default argument (int timeout = 30) communicates the same thing more directly.
  2. Error handling that needs to convey why. optional tells you a value is absent; it cannot tell you because the port was out of range versus because the input wasn’t a number. When the caller needs to react to the reason, reach for std::expected<T, E> (C++23) or an enum-tagged result type, not optional.

optional is a yes/no answer. If the question genuinely is yes/no, it’s the right tool. If the “no” needs a reason, it isn’t.

Takeaway

std::optional<T> replaces sentinels, output parameters, and exception-driven absence with a single type that says “a T, or nothing” at the signature level. Use value_or for defaults, the contextual bool check followed by *opt for the already-validated path, and the monadic chain (and_then/transform/or_else) when several optional operations compose. Reach for std::expected instead when the absence needs a reason. The rule is simple: if a function might return no value, say so in the type — and never read the value without first asking whether one is there.