C++ std::optional Complete Guide | nullopt· Monadic Ops
이 글의 핵심
std::optional vs nullptr and exceptions: value_or, and_then, transform, or_else, performance, and production error-handling patterns (C++17–C++23).
Introduction: representing absence, nullptr, and sentinels
In C++, “there is no value” is a design problem you hit constantly: a lookup misses, a key is missing, parsing fails, or a column is NULL. Before std::optional (C++17), codebases used several imperfect conventions, and some of them remain appropriate in narrow contexts. The key distinction is: optional models optional values; pointers model indirection, identity, and often ownership—do not conflate the two in API design.
Why absence is a modeling problem
Absence is not a single idea. It may be an expected branch (optional profile field not supplied), a soft outcome (key not in cache), or a hard error (invariant broken). std::optional carries no reason for being empty. If callers need to distinguish “not found” from “parse error” or “permission denied,” use std::expected (C++23), a sum type, or a parallel error channel. Use optional when “nothing to use” is enough information. Document what std::nullopt means for each public function (see When I reach for optional).
Raw pointers to heap objects can represent absence with nullptr, but you inherit lifetime issues, delete / new pairing, and exception-safety gotchas. Non-owning T* is still a strong idiom for “maybe point at an object that outlives this call” when ownership is not in question. std::optional does not replace every nullable T*. It replaces embeddable value-or-absence in the type.
Sentinel values (for example -1 for “no index” or INT_MAX for “not set”) collide with valid domain data, hide bugs, and are not self-documenting in the type system. They remain tolerable in hot numeric loops where you have proved the domain excludes the sentinel—after profiling—or where ABI forces a C-style int.
Concrete example beats a matrix, at least for how I think about it. I reach for std::optional<T> when the story is maybe there is a T, value-semantics, no pointer lifetime: tryParseInt returning “nothing” is clearer than a magic return value. A non-owning T* is still the lightest “maybe this object” when the question is identity and the lifetime is someone else’s problem—interop and callbacks still look like pointers. I use std::unique_ptr<T> for owned heap objects, not for “optional int.” Sentinels (like -1 for “no index”) only survive a review if the domain can never collide with a valid value, and I still try optional first because the type stops the ambiguity at compile time. If the next question is why it failed, not just that it is missing, I stop with optional and use std::expected<T,E> or a small result type—optional is intentionally dumb that way, and that is a feature.
Our REST API used nulls everywhere. DTOs were a mix of “missing JSON field,” “explicit JSON null,” and “C++ nullptr because we were still bridging old structs”—all spelled the same at the call site. Migrating the value-like pieces to std::optional was tedious, but it taught us to separate absence from error: nullopt could mean “not in the request” once we named it, while permission failures moved to a richer type on the few endpoints that needed them. The compiler started complaining in the right places, and code review stopped being a séance about what a null pointer meant.
Exceptions are appropriate for truly exceptional failures, not for the ordinary “not found” path that happens on every other request, unless your codebase standardizes on exceptions for all failures and you have measured. std::pair<bool, T> works but is verbose, easy to misuse, and the bool and T are not one semantic unit in the type system.
std::optional<T> models “either a T or nothing” with value semantics: no null pointer to chase, no sentinel confusion, and the emptiness is part of the type. For optional object presence of small/medium value types, prefer std::optional<T>. When you need absence and unique heap ownership, use std::optional<std::unique_ptr<T>> (or return unique_ptr alone with nullptr). For APIs that are intrinsically pointer-based and lifetime is already external, a raw T* or not_null<T*>-style type may read cleaner than optional<reference_wrapper<T>>.
SQL NULL vs C++ (mental model)
In SQL, NULL participates in three-valued logic; in C++, two empty std::optional values compare == when both are disengaged. When you map a nullable column to std::optional, you encode row-level absence; replicating SQL’s UNKNOWN in C++ is a different layer (your query builder or DTO validation). See Database results.
This guide requires C++17 for std::optional. Monadic member functions and_then, transform, and or_else require C++23 (or equivalent library support on your standard library implementation).
Toolchain notes. Use -std=c++17 / /std:c++17 at minimum. For monadic operations, pass -std=c++23 or MSVC /std:c++latest after confirming your libstdc++ / libc++ / MSVC version implements P2505R5-style optional monadic operations. If you must stay on C++17 or C++20, use the manual if chains and small helper templates shown in this article; behavior of your program is the same—only verbosity changes.
What you will learn
- How to create, check, and read
std::optionalsafely. value_or,emplace,reset,swap, and comparison semantics.- C++23 monadic chaining and how it replaces nested
ifnoise. - Performance (storage, alignment) and
bad_optional_access. - Optional as a return type, versus
std::expected, exceptions, and sentinels. - Composition, STL algorithms, JSON-shaped config, and SQL
NULLpatterns. - When I reach for optional, common mistakes, gotchas I have actually seen in production, and two complete worked examples (JSON-style config and repository/DTO with SQL
NULL).
Typographic note: the article uses the standard spelling std::nullopt in prose and in code. Some older texts wrote std::null_opt; the type is std::nullopt_t and the value is std::nullopt.
Table of contents
You can treat the list below as a map, not a syllabus: jump to what you need, or read straight through from basic usage to the two full examples at the end. I keep the same anchors the site has always used so old links do not rot.
- Basic usage: creation,
has_value, and accessors - Initialization, copies, and moves
- The
ifstatement with initializer value_or: defaults without exceptions- C++23 monadic operations:
and_then,or_else,transform - Comparisons:
==, ordering, andnullopt - Three-way comparison and
std::strong_ordering(C++20) emplace: in-place constructionreset: clearing a contained valueswap: exchanging two optionals- Optional “references” and practical alternatives
- Performance: storage, alignment, and hot paths
- Exceptions:
std::bad_optional_access - Function return patterns
- Error handling:
optionalvsResult/std::expected - Composition: chaining and readability
- STL integration: algorithms and iterators
- JSON parsing: optional object fields
- Database results: mapping
NULL - When I reach for optional
- Common mistakes
- Gotchas I’ve hit
- Real example: end-to-end program
- Real example: repository + DTO (SQL
NULL) - Summary and further reading
If the story in the intro is enough context for you, jump straight in; otherwise, what follows is the day-to-day API surface: how to construct and read the type, what C++23 adds, then error-shaped territory (JSON, SQL) and a couple of end-to-end programs I would actually paste into a service.
1. Basic usage: creation, has_value, and accessors
Include <optional>. An empty std::optional<T> is default-constructed or assigned std::nullopt (a constant of type std::nullopt_t used to represent “no value”).
#include <optional>
#include <string>
std::optional<int> a; // no value
std::optional<int> b = std::nullopt; // no value
std::optional<int> c{42}; // contains 42
auto d = std::make_optional(3.14); // optional<double> with 3.14
Checking presence. Use has_value() or the contextual conversion to bool (if (opt) { ... }). Both are explicit and read well at call sites.
Reading the value. You have three main families of operations:
value()— returns a reference to the stored object; if empty, throwsstd::bad_optional_access.operator*andoperator->— narrow contracts: if the optional is empty, use is undefined behavior, same spirit as dereferencing a bad pointer. Only use after a check (or when you know it is engaged, for example right afteremplace).value_or(U&& default)— never throws for emptiness; see the next section.
#include <optional>
#include <iostream>
int main() {
std::optional<int> o = 7;
if (o.has_value()) {
std::cout << o.value() << "\n";
}
if (o) {
std::cout << *o << "\n";
}
std::optional<std::string> s = "hi";
if (s) {
std::cout << s->size() << "\n";
}
return 0;
}
Design takeaway: In defensive or boundary code, prefer value_or or a guarded if before *. Treat bare *o on a possibly empty optional as a code smell unless the invariant is local and obvious.
std::make_optional and in-place construction
std::make_optional<T>(args...) is a convenient factory: it direct-initializes the contained T without spelling std::optional<T> twice. make_optional with no arguments creates an optional with a value-initialized T, not an empty optional—do not mix that with “empty by default” unless T’s value-initialized state is your domain’s “unset” (usually it is not).
For explicit empty-or-emplace initialization, use default construction, = std::nullopt, or std::in_place in the optional constructor if you are constructing a T that does not have a move path you like:
#include <optional>
#include <string>
#include <utility>
struct X { int a; explicit X(int v) : a(v) {} };
std::optional<std::string> f() {
return std::make_optional<std::string>(10, 'y'); // "yyyyyyyyyy"
}
std::optional<X> g() { return std::make_optional<X>(42); }
Move semantics: consuming an optional
When you move from std::optional<T>, the optional may still be “engaged” but its value can be in a moved-from state (for example an empty std::string inside an engaged optional). If you will not reuse the optional, reset() or assign std::nullopt after consuming the value, or return by move from a function that relinquishes ownership in one step. In generic code, treat std::move(opt).value() as consuming the T inside when present.
2. Initialization, copies, and moves
std::optional is a vocabulary type: copy, move, and assignment are defined in terms of the contained T when engaged. Copying a disengaged optional yields disengaged; copying an engaged optional copies the T (or invokes T’s copy constructor). The exception safety goal is the same as for a T alone: a failed T copy during assignment may leave the optional in a valid state; consult your standard library’s noexcept guarantees for your T.
#include <optional>
#include <string>
void copies() {
std::optional<int> a = 1;
std::optional<int> b = a; // b holds 1
std::optional<int> c;
c = a; // c holds 1
c = std::nullopt; // c empty
std::optional<std::string> s = "hi";
std::optional<std::string> t = std::move(s); // t holds "hi", s may be empty string (moved)
(void)b; (void)t;
}
In-place and aggregate construction often pair with std::in_place or emplace (see Emplace). std::make_optional(Args...) is convenient, but make_optional<T>() without arguments creates an engaged value-initialized T, not nullopt—a frequent bug when authors assume “empty by default.”
Assigning a T to an empty optional engages the storage and copy/move-assigns. Assigning std::nullopt clears without needing a T default. Assigning another optional is a full optional-to-optional copy or move, including the empty cases.
Constness and const optional<T&> (non-standard)
std::optional is for value-like T and move-only types. For const optional of a small T, const std::optional<int> is fine; the inner int is modifiable only through a non-const lvalue, same as a const value object pattern.
3. The if statement with initializer (C++17)
C++17 allows an init-statement before a condition. With if (std::optional<T> x = f()) { ... }, the body runs only if x is engaged, and the optional is named in the outer scope of the if-statement. Inside the if block, *x (or a reference into *x) is the idiom; there is no separate “.value or crash” in this pattern unless you call .value().
#include <optional>
#include <string>
#include <iostream>
int main() {
if (std::optional<int> n = 42) {
std::cout << *n; // safe: n is engaged
}
return 0;
}
When the result is a non-trivial T, the next pattern is typical:
if (std::optional<std::string> tok = getToken()) {
process(std::move(*tok));
}
For rvalue optionals, if (auto o = factory()) works; for rvalue and move out, a single-use std::move(*o) inside the block is common. C++17 if (auto r = f()) where f() returns optional<T> is the standard unwrapping idiom. C++23 monadic and_then / transform reduce nested if and named locals (see Monadic operations).
4. value_or: default value without try/catch
value_or is the ergonomic way to turn “maybe empty” into “definitely T” by supplying a fallback. The fallback is not stored inside the optional; it is only used when the optional is disengaged.
#include <optional>
#include <string>
std::optional<int> readPort(); // might return nullopt
void demo() {
int port = readPort().value_or(8080);
std::optional<std::string> nick;
std::string display = nick.value_or("anonymous");
(void)port;
(void)display;
}
value_or is ideal for configuration defaults, UI placeholder strings, and numeric fallbacks where the “missing” case is not an error but a product decision. It does not replace error reporting when “missing” means “caller must react”—for that, keep the std::optional and handle nullopt with logging or a different channel.
Const lvalue and rvalue value_or overloads. The standard provides value_or for the usual value categories. When the optional is a temporary (an rvalue) and the contained T is movable, the implementation can move the value out; when you only have a const lvalue, you get a copy (or a copy of the fallback). Read cppreference value_or and your vendor’s noexcept / complexity clauses.
No lazy default in the standard (C++20). The fallback argument to value_or is evaluated even when the optional is engaged—because value_or is a function call, not a macro. If the default is expensive, use a short-circuit form:
std::string pick(std::optional<std::string> o) {
if (o) { return *o; }
return loadExpensiveDefaultFromDisk(); // not called when o is engaged
}
C++20 ranges-style or project-specific value_or_invocable(optional, F) helpers can supply lazy defaults without evaluating F when unnecessary.
Move and cost. For std::optional<std::string>, value_or may move from the optional when the optional holds a value (depending on overloads and value category). For hot code with large strings, read the standard library specifications for your implementation or measure.
5. C++23 monadic operations: and_then, or_else, transform
C++23 adds monadic operations on std::optional (and other types). They reduce nested conditionals and make data-flow explicit.
transform (map a value, stay “inside” optional)
If empty, the result is empty. If engaged, the callable is invoked with the value and the result is returned as a new std::optional (or std::optional of the function’s return type, depending on the exact signature—see your standard library docs for edge cases with references).
#include <optional>
#include <string>
std::optional<std::string> f(std::optional<int> o) {
return o.transform([](int x) { return std::to_string(x * 2); });
}
and_then (flatMap: function returns optional)
Use when the next step itself can fail. and_then avoids optional<optional<T>> when you chain lookups.
#include <optional>
#include <string>
std::optional<int> parseUserId(const std::string& name);
std::optional<std::string> emailForUser(int id);
std::optional<std::string> emailForName(const std::string& name) {
return parseUserId(name).and_then(
[](int id) { return emailForUser(id); });
}
or_else (compute alternative source)
If the optional is empty, or_else runs a function that must return a std::optional<T> (or compatible). Use for “try cache, else database” style composition.
#include <optional>
std::optional<int> fromCache(int);
std::optional<int> fromDb(int);
std::optional<int> resolved(int key) {
return fromCache(key).or_else([key]() { return fromDb(key); });
}
The callable is not invoked when the optional already holds a value—so expensive work (network, disk) belongs inside or_else, not before it, if you want short-circuit behavior. If you need to run a side effect no matter what, do that outside the optional chain.
Side effects in transform. A transform that logs or mutates global state is easy to test badly: on empty, it does not run. Unit tests should cover both branches. Do not capture non-const state by reference in a transform unless the lifetime is obvious—prefer pure lambdas and explicit if for imperative steps.
constexpr. In C++23, the monadic members are constexpr when the function object and T allow constexpr evaluation. This enables optional inside compile-time parsers or configuration fragments on supporting standard libraries. Check static_assert with constexpr lambdas in your target configuration.
C++17 fallback for or_else-style logic:
std::optional<int> resolved17(int key) {
if (auto v = fromCache(key)) { return v; }
return fromDb(key);
}
C++17 fallback. Before C++23, use hand-written if chains or a small map_optional / and_then helper template in your own namespace. Many codebases backport the same idea from functional programming under names like flatMap.
I usually pause after monadic style because the next thing people trip on in code review is comparison semantics: a lexicographic order on the wrapper is not always the same as “if both are engaged, compare the inner T values,” and when that mismatch matters, I spell the intent in plain if rather than lean on operator<.
6. Comparisons: ==, ordering, and nullopt
std::optional participates in relational and equality comparisons. The rules (roughly) are:
- Two optionals are equal if both are empty, or both have values and the values compare equal.
- A value compares to an
optionalby treating the value as a non-empty optional containing that value. std::nulloptcompares as “less than” any engaged optional in ordering comparisons (i.e. empty sorts before non-empty in<for typicalT—see the standard for exact wording).
For operator< on two std::optional<T>, the standard imposes a lexicographic order: empty compares less than any non-empty; if both are non-empty, *lhs < *rhs is used. This makes std::optional<T> orderable when T is, which allows storing optional keys or values in std::set / map if your domain actually needs that—most application code only needs == and != against nullopt or a concrete T.
#include <optional>
#include <iostream>
int main() {
std::optional<int> a = 1;
std::optional<int> b;
std::cout << (a == 1) << "\n"; // 1
std::cout << (b == std::nullopt) << "\n"; // 1
std::cout << (a < b) << "\n"; // 0 (1 is not < empty in all contexts—verify with your use case; prefer explicit checks in code reviews)
return 0;
}
Practical note: Relying on optional-with-value vs nullopt ordering is easy to get subtle-wrong in reviews. If the intent is not obvious, prefer if (a && b && *a < *b) for clarity. Use comparisons when they make sorting and set/map use natural.
7. Three-way comparison and std::strong_ordering (C++20)
If T defines operator<=> (three-way comparison), then std::optional<T> gets rewritten comparison operators in C++20. Equality == and != use the optional rules (both empty, or both engaged and values equal). Ordering (<, <=, >, >=) follow lexicographic rules: an empty optional is less than any engaged optional; if both are engaged, *a <=> *b determines the result.
#include <optional>
#include <compare>
int main() {
std::optional<int> a = 1;
std::optional<int> b = 2;
auto c = (a <=> b); // std::strong_ordering::less (1 < 2)
(void)c;
std::optional<int> e; // empty
bool t = (e < a); // true: empty < engaged
(void)t;
return 0;
}
For custom T, if you only provide operator== and operator< without <=>, optional still compares using the synthesized legacy relations. Prefer explicit if (a && b && *a < *b) in application code when the comparison intent is “compare inner values only if both exist”—the default optional ordering may not match domain rules (for example, “unknown” might need to sort last, not first).
std::nullopt and <=>: mixed comparisons with nullopt use the same lexicographic idea as operator< on two optionals. If you need a total order for keys in std::map, ensure your T’s order is a strict weak ordering; then optional<T>’s ordering is suitable for map keys in most toolchains.
8. emplace: in-place construction
emplace constructs the contained T in the optional’s storage (when T is not a reference). It is the right tool when:
Tis not cheap to default-construct and assign.- You want to forward constructor arguments without creating a temporary
Tfirst. - You rebuild the value several times in the same
optionallifetime.
#include <optional>
#include <string>
int main() {
std::optional<std::string> s;
s.emplace(10, 'x'); // string of ten 'x' characters
s.emplace("hello");
s.reset();
s.emplace(5, 'z');
return 0;
}
You can also use std::in_place in constructors when you want direct construction at initialization time without an extra emplace call. Pick one style and stay consistent in a given module.
Exception safety. emplace destroys the old contained object (if any) and constructs the new one. If the constructor of T throws, the optional is left empty (C++17 optional invariants). That keeps optional a valid object but may surprise callers who expected the old value to remain—another reason to keep T’s constructors simple at hot call sites or to build a temporary T and assign in two steps for complex recovery.
9. reset: clearing a contained value
reset() destroys the contained value (if any) and leaves the optional empty. It is the inverse of emplace in spirit: deallocate/destroy, do not reassign. Assignment from std::nullopt is equivalent in effect for the empty state; use whichever reads clearer.
#include <optional>
void clear(std::optional<int>& o) {
o.reset();
// o == std::nullopt
}
Use reset() when the caller must explicitly “drop” cached or computed state, or when you implement RAII-style optional members that sometimes hold a value and sometimes do not.
Assignment vs reset. o = std::nullopt; and o.reset(); are effectively equivalent in observable state for a single o. reset() is explicit about destruction; = nullopt matches assignment-heavy style. Assigning a new T replaces the value, possibly via move or copy; use reset() first if a domain rule says you must not assign into an already-engaged slot (uncommon but documented in a few state machines).
10. swap: exchanging two optionals
swap exchanges the states of two std::optional<T>. If both are engaged, it is analogous to swapping two T. If one is empty, the other’s value moves across as appropriate (subject to T’s move semantics). For expensive T, this can be cheaper than copy-assign patterns.
swap is typically noexcept when T is noexcept swappable, which is important for vector::swap and generic algorithms that reseat optional contents.
#include <optional>
#include <string>
#include <utility>
int main() {
std::optional<std::string> a = "one";
std::optional<std::string> b = "two";
std::swap(a, b);
a.swap(b); // member swap: equivalent when both are same optional<T>
return 0;
}
Idiom: swap two optional caches in a service when you use double-buffering (fill one optional in the background, then swap to publish). The empty state participates naturally in the exchange.
11. Optional “references” and practical alternatives
There is no std::optional<T&> in the standard for historical and semantic reasons (rebinding vs assignment ambiguity). If you need “maybe refer to an object”:
- A
T*(nullable) with documented lifetime is the simplest idiom. std::optional<std::reference_wrapper<T>>works but is awkward in general interfaces.- Return or store a
std::optional<T>when the referred-to object is small and copyable, or the reference is to immutable shared state and you can store astd::shared_ptrinstead of a non-owning pointer.
#include <optional>
#include <functional>
void by_pointer(int* p) { (void)p; }
void by_refwrap(std::optional<std::reference_wrapper<int>> r) { (void)r; }
Guideline: at API boundaries, prefer pointers for optional non-owning reference semantics, and optional<T> for value presence. Do not contort the type system to fake references unless a template constraint leaves no alternative.
12. Performance: storage, alignment, and hot paths
std::optional<T> must store a T and an engaged flag (implementation-defined layout). In practice, for small T like int, the size is often sizeof(T) plus padding to align the object plus a bool-like member—commonly 8 bytes for optional<int> on 64-bit platforms, but measure with sizeof.
Alignment. std::optional<T> respects alignof(T). For over-aligned types, the optional is also suitably aligned. Very large T can bloat the returned optional<BigObject> in registers/stack traffic, so for hot APIs sometimes an optional of a small handle (ID, file descriptor, index) is cheaper than an optional<BigObject> when you cannot rely on RVO or a cheap move in all paths.
When not to use optional in a tight loop if a sentinel integer or a bool + T pair is provably faster and the absence semantics are stable—after profiling. The standard library type’s clarity has a small but real abstraction cost; do not “optimize” without data.
Benchmarks comparing optional to raw pointers are misleading if the pointer version allocates. Compare apples to apples (optional value vs unique_ptr vs sentinel) on your own compiler and flags.
#include <optional>
#include <iostream>
struct A { int x; };
struct B { int data[1000]; };
int main() {
std::cout << sizeof(int) << " " << sizeof(std::optional<int>) << "\n";
std::cout << sizeof(A) << " " << sizeof(std::optional<A>) << "\n";
std::cout << sizeof(B) << " " << sizeof(std::optional<B>) << "\n";
return 0;
}
13. Exceptions: std::bad_optional_access
optional::value() throws std::bad_optional_access (since C++17) if the optional is disengaged. Inherits from std::exception with a typical what() message. std::expected and other types have their own error channel; do not conflate them.
When to use value(). In debug assertions, test code, or when you prove the optional is engaged (for example, immediately after a check) and you want a hard failure. In library boundaries where exceptions are not allowed, avoid value() and use *opt only under contract or return error codes from a wrapper.
#include <optional>
#include <stdexcept>
void demo() {
std::optional<int> e;
try {
(void)e.value();
} catch (const std::bad_optional_access& ex) {
(void)ex.what();
}
}
14. Function return patterns
A function that may produce a T should return std::optional<T>. This encodes the contract in the type: callers must handle absence. Contrast with:
T*to heap — ownership and deletion rules.- Output parameter
bool& ok— easy to forget checkingok. - Exceptions — for true errors, not routine misses.
Naming helps. Names like tryFind, maybeParse, parseIf, lookup signal optional returns.
#include <optional>
#include <string>
std::optional<int> tryParseInt(const std::string& s);
std::optional<std::string> findConfig(const std::string& key);
Chaining at the call site: with C++17, use nested if or local variables; with C++23, use and_then / transform as above.
Optional parameters are less common. Sometimes std::optional<T> arg = std::nullopt means “use default” for configuration. That is valid but can confuse overload resolution; an enum class DefaultTag or a dedicated Config type can be clearer for complex APIs.
C++20 coroutines. A coroutine that co_returns std::optional<T> is a natural “maybe produce T” without exceptions. The caller can if (auto v = co_await f()) when your promise type yields optional (custom promise types and co_await rules are nontrivial; this is a directional hint, not a drop-in pattern). Simpler: use std::optional as the return type of a normal function in most app code; reserve coroutine machinery for I/O or suspension-heavy flows.
Output parameters — bool tryParse(std::string_view, int& out) is still defensible in performance-critical C APIs; in modern C++, a return of std::optional<int> (or a small struct for multiple out-values) is usually clearer. Mixing out-parameters with std::optional (fill out and return a bool) duplicates state and invites drift.
15. Error handling: optional vs Result / std::expected
std::optional<T> carries no error reason—only “nothing there.” If callers need why it failed, consider:
std::expected<T, E>(C++23) —Ton success,Eon failure, type-safe, no out-of-band channel.std::variant<T, Error>or a small customResult/Outcometype in pre-C++23 codebases.- Error codes at system boundaries (POSIX errno-style) with optional not used for transport.
When optional is enough: cache miss, “user not in map”, “optional JSON field absent”. When it is not: file open failure (permission vs not found), network timeout vs DNS failure—something richer than a bit.
// Conceptual: prefer std::expected for rich errors when you standardize on C++23.
// std::expected<Row, QueryError> query(const std::string& sql);
Exceptions remain for non-local or truly exceptional conditions where every caller would otherwise boilerplate the same unwrapping. Do not use exceptions for every parse failure in a high-throughput parser without measurement.
16. Composition: chaining and readability
Composition means building one result from many steps, each of which can fail. Patterns:
- C++23:
a.and_then(f).transform(g).or_else(h)as a linear read. - C++17: early
return std::nulloptin helpers, or small named functions that each returnoptional. - Optional of structs: aggregate partial results, then at the end check all fields; useful for form parsing.
Depth limit. If a chain is longer than three or four monadic steps, split into a function with a normal control flow. Readability beats clever one-liners in production.
#include <optional>
#include <string>
std::optional<int> step1();
std::optional<int> addOne(int);
std::optional<std::string> toStr(int);
std::optional<std::string> pipeline() {
return step1()
.and_then([](int x) { return addOne(x); })
.transform([](int y) { return toStr(y).value(); }); // prefer flat and_then if toStr returns optional
}
The last line illustrates a design tension: if toStr returns optional, do not call .value() inside transform without proof—use and_then instead. Keeping layers honest avoids bad_optional_access at runtime.
17. STL integration: algorithms and iterators
std::optional is a Regular-like type: assignable, comparable (when T is), movable. It works in containers: std::vector<std::optional<T>> for sparse columns or per-cell presence.
Algorithms. Use std::transform to map optional<T> to optional<U> with your own functor, or C++23 monadic members for clarity.
#include <optional>
#include <vector>
#include <algorithm>
#include <iterator>
int main() {
std::vector<std::optional<int>> v{{1}, std::nullopt, {3}};
std::vector<std::optional<int>> out;
out.reserve(v.size());
std::transform(v.begin(), v.end(), std::back_inserter(out),
[](std::optional<int> o) {
return o.transform([](int x) { return x * 2; });
});
return 0;
}
std::optional<std::size_t> is a reasonable return type for “index or not found” if the domain does not use 0 for both “first element” and “not found”. Often std::optional is still clearer than a naked static_cast<std::size_t>(-1) sentinel.
Ranges (C++20). You can project optionals in range pipelines with views and filters, but the idiomatic C++20 story for “filter empty” is a small helper or a custom view; there is no single standard “compact” in the way some functional languages have.
Optional in map / unordered_map values
Using std::optional<V> as the mapped type is less common than using optional as a return type, but it can model “key exists but value intentionally empty” versus “key missing” when you need both distinctions. Often a single std::map<K, V> where absence means “no key” is enough; add optional<V> only when product requirements demand “key present, value absent” as a real third state (for example, cleared optional profile field kept in the document).
Filter and collect (C++17 style)
To collect only engaged values into std::vector<T>, loop with an if (o) check, or use std::copy_if with a lambda that copies *o into the output when o is engaged. A small template helper collect_values keeps call sites readable and centralizes the “empty means skip” rule.
#include <optional>
#include <vector>
#include <algorithm>
template <class T>
std::vector<T> collect_values(const std::vector<std::optional<T>>& in) {
std::vector<T> out;
for (const auto& o : in) {
if (o) { out.push_back(*o); }
}
return out;
}
std::find and optionals in a vector: to locate the first engaged optional that satisfies a predicate on T, use a loop or std::find_if with a lambda that checks o && pred(*o):
#include <optional>
#include <vector>
#include <algorithm>
std::optional<int> firstPositive(const std::vector<std::optional<int>>& v) {
auto it = std::find_if(v.begin(), v.end(), [](const std::optional<int>& o) {
return o && *o > 0;
});
if (it == v.end()) { return std::nullopt; }
return *it;
}
std::min / std::max: comparing two std::optional<T> uses optional ordering; for “minimum of the inner T values, ignoring empties” you need a custom reduction, not a naked min on the vector of optionals.
At real boundaries, most of the optional-shaped data I map is JSON. The C++ optional field is the easy part; the bug farm is missing key versus explicit null and whether those mean the same thing in your API contract, which is why the next section keeps the contains / is_null dance explicit.
18. JSON parsing: optional object fields
JSON objects often have omitted keys. Parse into std::optional fields: absent key yields std::nullopt. With nlohmann/json, idiomatic access uses value with default or contains:
#include <nlohmann/json.hpp>
#include <optional>
#include <string>
struct Profile {
std::string name;
std::optional<std::string> website;
std::optional<int> age;
};
Profile parse(const nlohmann::json& j) {
Profile p;
p.name = j.at("name").get<std::string>();
if (j.contains("website") && !j["website"].is_null()) {
p.website = j["website"].get<std::string>();
}
if (j.contains("age") && j["age"].is_number()) {
p.age = j["age"].get<int>();
}
return p;
}
Custom minimal parser (illustrative): a getString that returns optional keeps higher layers free of map lookups and existence checks in one place (see the complete example below).
Validation is a separate concern: optional means “not in the document” or “explicit null” depending on your policy. Encode that distinction in your parse function’s documentation and tests.
Arrays of optionals (for example, an object with a list of optional sub-objects) are usually modeled as std::vector<std::optional<Sub>> or a vector of std::shared_ptr<Sub> when ownership is shared. For schema-less JSON, you may not know keys at compile time: still keep one getOptional* helper in your JsonView so every caller uses the same rules for null vs missing.
nlohmann::json and std::optional (C++17+). The library can convert to and from std::optional when adl_serializer or to_json/from_json is defined, or for automatic conversions in some versions—check the version you use. The explicit contains + is_null style above stays portable and obvious in code review.
ADL from_json / to_json sketch for a type with std::optional members (nlohmann pattern):
// nlohmann/json.hpp included in translation unit
#include <nlohmann/json.hpp>
#include <optional>
#include <string>
struct Dto {
std::string name;
std::optional<std::string> opt;
};
void to_json(nlohmann::json& j, const Dto& d) {
j = nlohmann::json{ {"name", d.name} };
if (d.opt) { j["opt"] = *d.opt; } // omit or null — pick one product rule
}
void from_json(const nlohmann::json& j, Dto& d) {
d.name = j.at("name").get<std::string>();
d.opt = j.contains("opt") && !j["opt"].is_null()
? std::make_optional(j["opt"].get<std::string>()) : std::nullopt;
}
Omitting keys on write and mapping missing keys to std::nullopt on read is the most common “optional field” design for public JSON APIs.
19. Database results: mapping NULL
SQL NULL maps naturally to std::optional<T> in application code: a column that may be NULL becomes std::optional<int>, std::optional<std::string>, etc. ORMs and query libraries often do this for you. Three-valued logic warning: in SQL, NULL = NULL is unknown, not true—in C++, two empty optionals == as equal; keep mental models separate when translating queries.
Example sketch (pseudo-API):
#include <optional>
#include <string>
struct Row { std::optional<int> id; std::optional<std::string> note; };
// Row fetchRow(const char* query); // your DB layer binds NULL -> nullopt
Indexes and outer joins produce NULL columns frequently—optional is the right representation until you normalize into a stricter in-memory model after validation.
Prepared statements and binding. When you bind a TEXT or INTEGER that may be NULL, your binding API may take std::optional<T>. On write, map “application absent” to SQL NULL; on read, map SQL NULL to std::nullopt. This keeps a single, consistent path through your repository layer. If the driver only exposes C-style NULL pointers, wrap that in a small function that returns std::optional so the rest of the application never manually tracks sentinel pointers.
Aggregates and reporting. A report that prints “N/A” for missing numeric cells is a natural fit: iterate rows, and for each std::optional<double> column, either format *v with precision or print a placeholder when !v. This avoids a parallel std::vector<bool> columnIsNull that can drift from the data.
Transactions. Optional fields do not change how you use transactions; they only change how you read each column into your domain type. If you use optimistic locking with a version column, the version is rarely optional; optional applies to “nullable business data” (middle name, optional end date) rather than protocol fields.
20. When I reach for optional
I do not keep a policy doc open while I type; I run a small mental checklist. A cache lookup or a config key that might be absent? I return std::optional and usually pair misses with a clear name like tryFind so nobody assumes a value is guaranteed. If the contract is that a value always exists for valid input, I return plain T and treat “empty” as a bug in the caller, not a third state. If the client needs a reason—timeout versus not found, parse error versus end of stream—I reach for std::expected or a domain-specific error type, because optional will not carry that story honestly.
For “maybe refer to an object I do not own,” I still like a T* with documented lifetime more than contorting optional into a reference substitute. In a hot loop, if profiling shows optional is actually in the way, I will consider a sentinel or a bool + T pair, but I want numbers first; clarity is the default. Tri-state bool (true / false / unknown) is one of the few times std::optional<bool> feels right instead of a bigger enum.
Name APIs to reflect optional results (tryFindX, maybeY). Document what nullopt means (not found vs error vs not applicable). Avoid std::optional<std::optional<T>> unless the domain is truly nested (rare). Refactor to a struct or a sum type.
21. Common mistakes
Dereferencing with *opt or -> on an empty optional is undefined behavior—same class of bug as a bad pointer. I check first, or use value_or, or let a C++23 monadic chain short-circuit. value() on a maybe-empty path without a catch or a proof of engagement only ends one way. Optional is not a pointer: if (opt != nullptr) is not the syntax; I use if (opt) or has_value().
There is still no std::optional<T&> in the standard, so faking a “maybe reference” is pointer or reference_wrapper territory. I also see teams treat “missing” as a default in one module and a hard error in another; that needs words in the API contract, not just a type. JSON null versus a missing key is the other chronic mix-up: you can map both to optional if you mean to, but the bugs come from not deciding.
// Bad: *o without check
// Good:
if (o) { use(*o); }
// Good:
use(o.value_or(fallback));
22. Gotchas I’ve hit
I have tripped the obvious one: value() on an empty optional in a path I thought was already guarded—std::bad_optional_access in a release build is a special kind of humility. I have also seen “works in debug, dies in release” with *o on empty; that is undefined behavior, so I lean on UBSan/ASan in CI when we touch a lot of optional-heavy refactors. Chains of and_then that looked elegant on the whiteboard become unreadable by the third hop; I break them into named steps and move on. If transform / and_then will not compile, I check the lambda’s return type first, then the standard: monadic optional members want C++23 on a toolchain that actually implements them.
value_or is not lazy—the default is evaluated every time. I learned that the hard way when the default was not a constant but a disk read. Huge sizeof(std::optional<BigThing>) is not a bug; it is the type telling you you are lugging a whole T around, and sometimes a handle or unique_ptr is the right move. I have had generic code produce optional<optional<T>>; a tiny flatten helper or an explicit if fixes it, but it is never fun to find at three in the morning. optional<T&> is not a thing; when I need that shape I use a pointer and document lifetime, or a value, or reference_wrapper in a pinch.
Move-from is another sneaky one: after std::move(*o), treating the optional as if the inner T is still full is how you get “valid but unspecified” state bugs. And I once returned a reference into *opt from a function where opt was local—use-after-return. Optional does not make lifetimes free.
Logging: when returning std::nullopt from a public API, I pick whether the caller or the callee logs (callee avoids duplicates; caller adds request id). I avoid logging in tight inner loops; service boundaries are loud enough.
23. Real example: end-to-end program
The program below ties together parsing (string to optional<int>), configuration-style value_or, monadic (C++23) composition, and a data record with optional fields. Build with C++23 for monadic members; for C++17, replace the monadic chain with if as commented.
What to compile: g++ -std=c++23 -O2 (or clang++ with an appropriate -stdlib), or MSVC with /std:c++latest. Link only the C++ standard library. If your compiler does not support std::from_chars for integers on your platform, replace tryParseInt with a verified stoi wrapper that returns nullopt on failure—same optional interface, slightly different cost model.
Extending the example: add a getDouble using from_chars for floating-point where available, or an adapter to your real JSON library. The important pattern is one layer of getString / getInt / getBool that always returns optional, so the rest of the app never touches raw map lookups for optional keys.
Tests worth writing: (1) missing key, (2) present valid value, (3) present invalid value for getInt, (4) literal "null" string if you treat it as absent, (5) default value_or path for loadConfig when keys are missing. Golden outputs for print make regressions obvious when someone changes value_or defaults.
#include <optional>
#include <string>
#include <string_view>
#include <map>
#include <iostream>
#include <charconv> // from_chars, optional: avoid stoi exceptions in hot path
// --- Small helpers: optional-first API ---
static std::optional<int> tryParseInt(std::string_view sv) {
int value = 0;
const char* const first = sv.data();
const char* const last = first + static_cast<ptrdiff_t>(sv.size());
const auto [ptr, ec] = std::from_chars(first, last, value);
if (ec != std::errc() || ptr != last) {
return std::nullopt;
}
return value;
}
// --- Fake JSON-ish map: string keys, stringly values; optional fields for demo ---
class Jsonish {
public:
void set(std::string key, std::string value) { data_[std::move(key)] = std::move(value); }
std::optional<std::string> getString(const std::string& key) const {
auto it = data_.find(key);
if (it == data_.end()) { return std::nullopt; }
if (it->second == "null") { return std::nullopt; } // treat literal "null" as absent
return it->second;
}
std::optional<int> getInt(const std::string& key) const {
return getString(key).and_then(
[](const std::string& s) -> std::optional<int> {
return tryParseInt(s);
});
}
std::optional<bool> getBool(const std::string& key) const {
return getString(key).transform(
[](const std::string& s) { return (s == "true" || s == "1"); });
}
private:
std::map<std::string, std::string> data_{};
};
struct ServerConfig {
int port{};
std::string host;
std::optional<int> maxConnections; // absent == not specified
bool debug{false};
};
static ServerConfig loadConfig(const Jsonish& j) {
ServerConfig c;
c.port = j.getInt("port").value_or(3000);
c.host = j.getString("host").value_or("127.0.0.1");
c.maxConnections = j.getInt("maxConnections"); // may be nullopt
c.debug = j.getBool("debug").value_or(false);
return c;
}
static void print(const ServerConfig& c) {
std::cout << "port=" << c.port << " host=" << c.host
<< " debug=" << c.debug;
if (c.maxConnections) {
std::cout << " maxConnections=" << *c.maxConnections;
} else {
std::cout << " maxConnections=<default>";
}
std::cout << "\n";
}
int main() {
Jsonish j;
j.set("port", "9000");
j.set("host", "0.0.0.0");
j.set("debug", "true");
// maxConnections omitted on purpose
const ServerConfig loaded = loadConfig(j);
print(loaded);
return 0;
}
C++17 monadic replacement for getInt / getBool above:
std::optional<int> getIntCxx17(const Jsonish& j, const std::string& key) {
if (auto s = j.getString(key)) { return tryParseInt(*s); }
return std::nullopt;
}
This keeps the same semantics without requiring C++23 in the project.
24. Real example: repository + DTO (SQL NULL)
The second worked example is in-memory but mirrors a typical repository mapping: a row type uses std::optional for nullable columns; the “database” is a std::vector of rows. A real service would use your driver’s NULL → std::nullopt mapping; the shape of the C++ model stays the same.
#include <optional>
#include <string>
#include <string_view>
#include <vector>
// --- Domain DTO: nullable email and middle name, required id and display name ---
struct UserRow {
long id{};
std::string displayName;
std::optional<std::string> email; // NULL in DB
std::optional<std::string> middleName; // NULL in DB
};
// In-memory "table" (replace with actual SQL: SELECT ...; bind NULL to optional)
class UserRepository {
public:
void add(UserRow u) { rows_.push_back(std::move(u)); }
std::optional<UserRow> findById(long id) const {
for (const auto& r : rows_) {
if (r.id == id) { return r; }
}
return std::nullopt; // not found — same *type* as "no row"
}
private:
std::vector<UserRow> rows_{};
};
// Format a label: use middle name only if present (no sentinel strings)
static std::string makeLabel(const UserRow& u) {
std::string s = u.displayName;
if (u.middleName) {
s += " (" + *u.middleName + ')';
}
return s;
}
// C++23 one-liner alternative for the same append (returns a new string, not in-place)
// return u.middleName.transform([&](const std::string& m) {
// return u.displayName + " (" + m + ')';
// }).value_or(u.displayName);
// Optional email line: default when column was NULL
static std::string emailLine(const UserRow& u) {
if (u.email) {
return "email: " + *u.email + '\n';
}
return "email: (not on file)\n";
}
// Main-style demo: build one row with NULLs by leaving optionals disengaged
int main() {
UserRepository repo;
UserRow a{};
a.id = 1;
a.displayName = "Ada";
a.email = "[email protected]";
// middleName left empty optional
UserRow b{};
b.id = 2;
b.displayName = "Bob";
b.middleName = "Q";
// email missing
repo.add(std::move(a));
repo.add(std::move(b));
if (const auto u = repo.findById(2)) {
(void)makeLabel(*u);
(void)emailLine(*u);
}
return 0;
}
Design notes. (1) findById returns std::optional<UserRow>: absence means “no row” for that ID. This is a different semantic from “row exists with NULL columns”—both are expressible: missing row = empty optional; present row = engaged optional with optional fields inside. (2) On write, map std::nullopt to SQL NULL in INSERT/UPDATE. (3) Outer joins often yield NULLs in non-key columns: model those columns as std::optional even when the “parent” key is always present. (4) If you need to distinguish “column not selected” from “value NULL at runtime” in a single struct, you may need a richer type than a plain DTO; optional still covers the common “NULLable column” case.
25. Summary and further reading
std::optional is the standard way to make absence a first-class part of the type for value types. Use value_or, emplace / reset / swap, and (on C++23) transform / and_then / or_else to express control flow as data. Pair optional with clear API names and documented nullopt meaning. For errors with reasons, move to std::expected, variant, or domain-specific result types. Validate SQL NULL, JSON null vs missing, and optional<bool> tri-state requirements explicitly.
Before I ship a public function that returns std::optional<T>, I make sure the docstring (or a one-line comment) says which flavor of nullopt I mean—not found, parse error, not applicable—and if failure needs a log line or a metric, I decide whether that belongs here or in std::expected. If T is large or non-movable, I say whether the return is a temporary the caller can only read once or an lvalue they may stash. In template-heavy code I keep monadic chains short on purpose: compiler errors from a bad lambda return type are bad enough without three levels of nesting. I always have at least one test for “empty,” one for “one value,” and one where a missing key and a default path collide—because that is where the merge bugs live.
Sanitizers: Run UndefinedBehaviorSanitizer and AddressSanitizer in CI for builds that use *optional in performance-critical or template-heavy code. They catch more than optional misuse, but bad dereferences and lifetime bugs often show up when refactoring pointer-based code to optional. Static analysis (Clang-Tidy, PVS-Studio, MSVC /analyze) also flags some unchecked accesses when data flow is local.
Related reading on this site: see the links in the sections above and the lists below. Standard references: cppreference std::optional, the C++17 and C++23 standards. Books: Nicolai Josuttis, C++17 - The Complete Guide; Scott Meyers, Effective Modern C++ (general habits that pair well with optional).
flowchart TB
subgraph in["Inputs"]
I1["config / JSON"]
I2["DB row"]
I3["parse / find"]
end
in --> O["std::optional"]
O -->|engaged| U["use T"]
O -->|empty| D["default or alternate path"]
U --> R["return / log / store"]
D --> R
FAQ (expanded)
When is std::optional the wrong tool? When every failure needs a code or message, or when you must force the caller to handle an error (and expected is available). Is optional<bool> odd? It is the right way to model true / false / unknown; plain bool is binary only. Pass by value or const&? Small optional<T> by value; for large T, const std::optional<T>& can reduce copy cost at the cost of indirection—profile if it matters. Exception-free environments? Avoid value(); use operator* only under local proof, or out-parameters / expected.
Thread safety? std::optional itself is not synchronized. Two threads writing the same optional without a mutex is a data race, same as for a raw T. If you share a cached optional result, protect it with a mutex, use a std::atomic where applicable (not for arbitrary T), or compute per-thread. Const thread-safe reads of a race-free published optional still require that publication happens after the writer is done (memory ordering, mutex, or once_flag pattern).
Interaction with constexpr? Many optional operations are constexpr in C++20 and later; you can use optional in compile-time constant evaluation when the underlying T and operations allow. Check constexpr on your standard library for the exact member list on your version.
Good articles to read (internal)
- C++
std::variant— “type-safe union” guide - C++
std::optional·std::variant— complete guide - C++ exception handling — try / catch / throw
- C++ exception basics — error codes vs exceptions
- C++ composition, polymorphism, and
std::variant
Keywords (search)
C++, std::optional, C++17, C++23, nullopt, value_or, monadic, and_then, transform, or_else, bad_optional_access, nullable, error handling, STL, JSON, SQL NULL.
Related articles
같이 보면 좋은 글 (내부 링크)
- C++ optional·variant·any
- C++
std::optional·std::variant완벽 가이드 - C++ 제약된 환경: Exception·RTTI 없이
이 글에서 다루는 키워드 (관련 검색어)
C++, optional, C++17, C++23, nullable, monadic, value_or, nullopt