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 aTor nothing, with the empty state encoded in the type instead of a sentinel value.- Always check before reading:
optandopt.value()are undefined / throwing on an empty optional. Usevalue_orfor defaults andhas_value()(orif (opt)) for explicit checks. - Chain optionals with
and_then,transform, andor_elseto avoid the nestedif (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:
- 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. - Error handling that needs to convey why.
optionaltells 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 forstd::expected<T, E>(C++23) or an enum-tagged result type, notoptional.
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.