Making std::expected Work for You

Daniil ShuraevDaniil Shuraev
10 min read

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 or vk::ResultValue. Similar to std::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); or Ok("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 for Result, 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 a Result 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> or Status, 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 a Status.”

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:

  1. Perfect forwarding: if you pass an lvalue Result<int>, it copies; if you pass an rvalue, it moves. Same for Status.

  2. Return type normalization: by returning std::remove_cvref_t<R>, we ensure the return type is a clean Result<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 constructing std::remove_cvref_t<R> from r is noexcept.

  • The outer noexcept(...) declares that our function is noexcept 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> or Status, 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: becomes true iff that construction is declared noexcept.

  • Outer noexcept: we propagate that boolean to our function’s signature, so the function is noexcept iff the underlying copy/move is noexcept.

This keeps exception specifications truthful for both:

  • as_result(r) where r is a const lvalue → uses copy ctor; function is noexcept only if copying Result<T> is noexcept.

  • as_result(std::move(r)) where r is an rvalue → uses move ctor; function is noexcept only if moving Result<T> is noexcept.

Same reasoning applies to as_status.

Tip: If you want these to be always noexcept, make sure your Result<T> and Status expose noexcept 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 via noexcept(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 and as_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-coding noexcept.


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 and noexcept exactly.

Code is available on GitHub.

0
Subscribe to my newsletter

Read articles from Daniil Shuraev directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Daniil Shuraev
Daniil Shuraev