Making std::expected Work for You

Table of contents
- Motivation: Different Libraries, Different Errors
- Defining a Common Error Type
- Building a Small but Sharp Ok
- In-Place Construction
- Making Error Construction Clean with Err
- std::monostate for Status-Only Results
- as_result and as_status: Conversions and Pass-Through
- Normalizing Conversions with as_result / as_status
- Takeaway

Code is available on GitHub.
std::expected
(and its counterpart std::unexpected
) were introduced in C++23 (_cpp_lib_expected >= 202202L
, ISO/IEC 14882:2024). If you’re using a modern compiler and not yet using std::expected
, it’s worth reconsidering — it provides a clean, standard way to handle errors while still returning values on success.
This article assumes that you either want to use std::expected
or are already using it.
Motivation: Different Libraries, Different Errors
Now imagine pulling in third-party libraries — each with their own error conventions:
Vulkan (with exceptions disabled): returns
vk::Result
orvk::ResultValue
. Similar tostd::expected
, but less flexible and not interchangeable.Math/linear algebra library: maybe old-school C, using integer error codes.
Suddenly you’re juggling three different error-handling idioms. That’s where a common interface becomes invaluable.
Another issue: errors often need to “bubble up.” A math routine deep inside your stack may fail, and that failure must propagate through multiple layers to reach logging or error-handling code. But if multiple systems use overlapping integer/enum codes, you’ll quickly run into collisions. The natural fix: add facility information to indicate error origin.
Defining a Common Error Type
Since we need to return both the library-specific error code and the library (facility) where the error originated, the most obvious approach is returning a struct with that information combined. std::int32_t
is a fixed-width integer and works well for error codes.
#include <cstdint>
struct ErrorInfo {
std::int32_t facility{0}; // origin of error
std::int32_t error_code{0};
};
Tie it together with std::expected
via a type alias:
#include <expected>
template <typename T>
using Result = std::expected<T, ErrorInfo>;
Usage is straightforward:
auto ok = Result<int>(42);
assert(ok.has_value());
auto err = (Result<int>) std::unexpected(ErrorInfo{0,0});
assert(!err.has_value());
This works, but specifying types explicitly, writing casts, and converting enums with static_cast<std::int32_t>(std::to_underlying(enum_value))
gets verbose. Let’s make it more elegant.
Building a Small but Sharp Ok
Starting simple
When building a Result
, we don’t want to specify the type explicitly every time. Let’s write a helper that takes a value and wraps it. Start simple, then refine:
template <typename T>
auto Ok(T&& value) {
return Result<T>(std::forward<T>(value));
}
With forwarding references and std::forward
, we can cover both lvalues and rvalues in one template. This saves users from needing std::move
at every call site.
Decaying our types
If we return Result<T>
directly, an lvalue like std::string&
produces Result<std::string&>
. That’s rarely what you want inside a Result
. Reference payloads are fragile and can outlive their source. Enter std::decay_t<T>
:
template <typename T>
auto Ok(T&& value) {
using T0 = std::decay_t<T>;
return Result<T0>(std::forward<T>(value));
}
Here T0
dictates storage/ownership inside Result
. std::forward<T>(value)
still uses the original type T
to preserve the caller’s value category.
Corner cases:
Lvalue passed:
std::string s; Ok(s);
Without decay:
Result<std::string&>
(bad)With decay:
Result<std::string>
(good)
Const lvalue:
const Widget w; Ok(w);
Without decay:
Result<const Widget&>
With decay:
Result<Widget>
Array arguments/string literals:
char buf[16]; Ok(buf);
orOk("hello");
- Decay turns arrays into pointers, consistent with normal C++ semantics.
Could we use
std::remove_cvref_t<T>
instead? Yes, if you don’t want array/function decay. But forResult
, value semantics usually make more sense.
Explicit return type
Explicit return types help both readers and compilers:
template <typename T>
auto Ok(T&& value) -> Result<std::decay_t<T>> {
using T0 = std::decay_t<T>;
return Result<T0>(std::forward<T>(value));
}
Constexpr, inline, and [[nodiscard]]
constexpr
enables compile-time use when possible.inline
avoids multiple-definition issues in headers.[[nodiscard]]
warns if aResult
is ignored.
template <typename T>
[[nodiscard]] constexpr inline auto Ok(T&& value) -> Result<std::decay_t<T>> {
using T0 = std::decay_t<T>;
return Result<T0>(std::forward<T>(value));
}
Conditional noexcept
If Result<T0>(value)
is noexcept
, Ok(...)
should be too:
template <typename T>
[[nodiscard]] constexpr inline auto Ok(T&& value) noexcept(
noexcept(Result<std::decay_t<T>>(std::forward<T>(value)))
) -> Result<std::decay_t<T>>
{
using T0 = std::decay_t<T>;
return Result<T0>(std::forward<T>(value));
}
Guardrails: avoiding nested Result
We don’t want Result<Result<T>>
. Add a constraint:
template <typename> struct is_result : std::false_type {};
template <typename T> struct is_result<Result<T>> : std::true_type {};
template <typename X>
inline constexpr bool is_result_v = is_result<std::remove_cvref_t<X>>::value;
// Constraint
template <typename T>
requires (!is_result_v<std::remove_cvref_t<T>>)
[[nodiscard]] constexpr inline auto Ok(T&& value) noexcept(
noexcept(Result<std::decay_t<T>>(std::forward<T>(value)))
) -> Result<std::decay_t<T>>
{
using T0 = std::decay_t<T>;
return Result<T0>(std::forward<T>(value));
}
With this, passing a Result
into Ok()
fails at compile time.
In-Place Construction
Sometimes we want to build the payload directly in place:
template <typename T, typename... Args>
[[nodiscard]] constexpr inline Result<T> Ok(Args&&... args) noexcept(
std::is_nothrow_constructible_v<T, Args&&...>) {
return Result<T>(std::in_place, std::forward<Args>(args)...);
}
Here, T
is explicitly provided by the caller, so no decay is applied. It’s the caller’s responsibility to ensure T
is a suitable storage type.
Making Error Construction Clean with Err
Complement Ok()
with Err()
. First, a helper to normalize enums and integrals:
template <typename X>
[[nodiscard]] constexpr inline std::int32_t to_i32(X v) noexcept {
if constexpr (std::is_enum_v<X>) {
return static_cast<std::int32_t>(std::to_underlying(v));
} else {
static_assert(std::is_integral_v<X>, "to_i32 expects enum or integral type");
return static_cast<std::int32_t>(v);
}
}
Now define Err
:
template <typename T, typename CodeT = std::int32_t, typename FacilityT = std::int32_t>
requires(!is_result_v<std::remove_cvref_t<T>> &&
(std::is_enum_v<FacilityT> || std::is_integral_v<FacilityT>) &&
(std::is_enum_v<CodeT> || std::is_integral_v<CodeT>))
[[nodiscard]] constexpr inline auto Err(CodeT code, FacilityT facility = FacilityT{0}) noexcept
-> Result<std::decay_t<T>> {
return std::unexpected(ErrorInfo{to_i32(facility), to_i32(code)});
}
This allows clean construction of errors from enums or integers, with optional facility.
std::monostate
for Status-Only Results
Sometimes nothing meaningful is returned — just success or failure. std::monostate
provides a neat solution:
using Status = Result<std::monostate>;
[[nodiscard]] constexpr inline Status Ok() noexcept { return std::monostate{}; }
template <typename CodeT = std::int32_t, typename FacilityT = std::int32_t>
requires((std::is_enum_v<FacilityT> || std::is_integral_v<FacilityT>) &&
(std::is_enum_v<CodeT> || std::is_integral_v<CodeT>))
[[nodiscard]] constexpr inline Status Err(CodeT code, FacilityT facility = FacilityT{0}) noexcept {
return Err<std::monostate>(code, facility);
}
For consistency, add a trait to detect Status
:
template <typename T>
inline constexpr bool is_status_v = std::is_same_v<std::remove_cvref_t<T>, Status>;
Update constraints in Ok
and Err
to include && !is_status_v<std::remove_cvref_t<T>>
.
as_result and as_status: Conversions and Pass-Through
The final piece is making interoperability clean. We don’t just want helpers to build Result
and Status
; we also need a standard way to accept them or to convert from third-party types. That’s where as_result
and as_status
come in.
Goals
Pass-through: if we already have a
Result<T>
orStatus
, forward it safely as-is.Conversions: allow users to define their own
as_result
/as_status
overloads for third-party error types.Consistency: provide a single interface point for “give me a
Result
” or “give me aStatus
.”
Pass-through helpers
template <typename R>
requires is_result_v<R>
[[nodiscard]] constexpr auto as_result(R&& r) noexcept(
noexcept(std::remove_cvref_t<R>(std::forward<R>(r))))
-> std::remove_cvref_t<R> {
return std::forward<R>(r);
}
template <typename S>
requires is_status_v<S>
[[nodiscard]] constexpr auto as_status(S&& s) noexcept(
noexcept(std::remove_cvref_t<S>(std::forward<S>(s))))
-> std::remove_cvref_t<S> {
return std::forward<S>(s);
}
These functions do two things:
Perfect forwarding: if you pass an lvalue
Result<int>
, it copies; if you pass an rvalue, it moves. Same forStatus
.Return type normalization: by returning
std::remove_cvref_t<R>
, we ensure the return type is a cleanResult<T>
rather than a reference-qualified one.
Why noexcept(noexcept(...))
?
Notice the double noexcept
:
noexcept(noexcept(std::remove_cvref_t<R>(std::forward<R>(r))))
The inner
noexcept(expr)
checks at compile time whether constructingstd::remove_cvref_t<R>
fromr
is noexcept.The outer
noexcept(...)
declares that our function isnoexcept
iff that construction is noexcept.
This mirrors exactly what happens under the hood: if forwarding and constructing the return type is noexcept
, then so is as_result
. Otherwise, it isn’t.
Defining your own conversions
Users can provide their own overloads of as_result
or as_status
for third-party APIs. For example, integrating with Vulkan:
enum class VkResult : int { eSuccess = 0, eError = -1 };
[[nodiscard]] constexpr Status as_status(VkResult r) noexcept {
return (r == VkResult::eSuccess) ? Ok() : Err(-1, 1001);
}
template <class T>
[[nodiscard]] constexpr Result<T> as_result(/* some API type */) noexcept {
// Map third-party success/error into your Result<T>
}
The pattern is consistent: define an as_result
that returns a Result<T>
or an as_status
that returns a Status
. The rest of your code can call as_result(x)
and not worry about what x
originally was.
Normalizing Conversions with as_result
/ as_status
Goal: provide a single entry-point to convert anything that looks like a success/error provider into our Result<T>
/Status
:
If we already have a
Result<T>
orStatus
, just pass it through (preserving copy/move semantics).If it’s a foreign type (e.g.,
vk::Result
,vk::ResultValue<T>
), define an overload that maps it to our types.
This keeps call sites clean:
// works uniformly regardless of the source type
auto r = as_result(get_from_lib());
auto st = as_status(poll_status());
Pass-through overloads (identity with guardrails)
We start with two constrained identity helpers. They are intentionally minimal and only participate when the argument already is a Result<...>
or Status
.
template <typename R>
requires is_result_v<R>
[[nodiscard]] constexpr auto as_result(R&& r)
noexcept(noexcept(std::remove_cvref_t<R>(std::forward<R>(r))))
-> std::remove_cvref_t<R>
{
return std::forward<R>(r);
}
template <typename S>
requires is_status_v<S>
[[nodiscard]] constexpr auto as_status(S&& s)
noexcept(noexcept(std::remove_cvref_t<S>(std::forward<S>(s))))
-> std::remove_cvref_t<S>
{
return std::forward<S>(s);
}
Let’s take a better look at noexcept
here — the outer noexcept(condition)
is the function’s exception specification. The inner noexcept(expr)
is a compile-time query that yields true
if expr
is non-throwing, false
otherwise.
Concretely:
noexcept( noexcept(std::remove_cvref_t<R>(std::forward<R>(r))) )
Inner expression:
std::remove_cvref_t<R>(std::forward<R>(r))
models the exact construction we perform when returning: copy-construct for lvalues, move-construct for rvalues.Inner
noexcept
: becomestrue
iff that construction is declarednoexcept
.Outer
noexcept
: we propagate that boolean to our function’s signature, so the function isnoexcept
iff the underlying copy/move isnoexcept
.
This keeps exception specifications truthful for both:
as_result(r)
wherer
is a const lvalue → uses copy ctor; function isnoexcept
only if copyingResult<T>
isnoexcept
.as_result(std::move(r))
wherer
is an rvalue → uses move ctor; function isnoexcept
only if movingResult<T>
isnoexcept
.
Same reasoning applies to as_status
.
Tip: If you want these to be always
noexcept
, make sure yourResult<T>
andStatus
exposenoexcept
copy/move constructors (when the payload allows it). Otherwise, these helpers correctly reflect that copying/moving may throw.
How users add conversions for foreign types
The pass-through overloads above are just the base case. To integrate with third-party APIs, define additional overloads of as_result(...)
/ as_status(...)
in your library namespace, taking the foreign types as parameters. Example with a Vulkan-like API:
// A facility tag for error origin (arbitrary example)
inline constexpr std::int32_t FACILITY_VK = 1001;
// Map a foreign status enum to our Status
[[nodiscard]] inline constexpr Status as_status(vk::Result r) noexcept {
return (r == vk::Result::eSuccess || r == vk::Result::eSuboptimal)
? Ok() // success → Status
: Err(r, FACILITY_VK); // error → Status with facility
}
// Map a foreign "result value" to our Result<T> (copying value)
template <class T>
[[nodiscard]] inline constexpr Result<T>
as_result(const vk::ResultValue<T>& rv)
noexcept(noexcept(Result<T>(rv.value)))
{
return (rv.result == vk::Result::eSuccess || rv.result == vk::Result::eSuboptimal)
? Ok(rv.value) // copy path
: Err<T>(rv.result, FACILITY_VK); // error path
}
// Move-optimized overload for rvalues
template <class T>
[[nodiscard]] inline constexpr Result<T>
as_result(vk::ResultValue<T>&& rv)
noexcept(noexcept(Result<T>(std::move(rv.value))))
{
return (rv.result == vk::Result::eSuccess || rv.result == vk::Result::eSuboptimal)
? Ok(std::move(rv.value)) // move path
: Err<T>(rv.result, FACILITY_VK);
}
Key points:
Overload, don’t specialize. You can’t partially specialize function templates; provide overloads matching the foreign types.
Provide both lvalue and rvalue overloads when payload moves cheaply.
Mirror the correct
noexcept
vianoexcept(noexcept(...))
so your conversion’s signature reflects the chosen copy/move path.Reuse
Ok(...)
/Err(...)
to keep semantics consistent (copy vs move, facility tags, etc.).
Usage examples
// Pass-throughs
Result<int> r1 = Ok(42);
auto r2 = as_result(r1); // lvalue → copy Result<int>
auto r3 = as_result(std::move(r1)); // rvalue → move Result<int>
Status s1 = Ok();
auto s2 = as_status(s1); // copy
auto s3 = as_status(std::move(s1)); // move
// Foreign sources (assuming overloads above exist)
auto s_vk = as_status(vk::Result::eSuccess); // → Status
vk::ResultValue<std::string> rv{"hello", vk::Result::eSuccess};
auto r_vk = as_result(std::move(rv)); // → Result<std::string>
Design checklist & pitfalls
Keep the identity overloads constrained to
Result
/Status
so they don’t hijack conversions from foreign types.Put your custom overloads in the namespace where
as_result
andas_status
were originally declared so that they’re found normally; you don’t need to touch third-party namespaces.Prefer move-aware overloads for better performance on rvalues.
Let
noexcept(noexcept(...))
do the work—avoid hard-codingnoexcept
.
Takeaway
With Ok
, Err
, and the as_result
/as_status
family, your API gains a single, composable surface:
Build results succinctly.
Convert from foreign success/error carriers with targeted overloads.
Pass through existing
Result
/Status
without surprises, mirroring copy/move andnoexcept
exactly.
Code is available on GitHub.
Subscribe to my newsletter
Read articles from Daniil Shuraev directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
