This guide explains why expected is useful, what its structure looks like, and how you can implement it yourself. We will also look at the main challenges of building such types, and I will walk you through how each step works in the provided implementation.
Table of Contents
- Why Use
expected
? - General Structure of
expected
andunexpected
- Main Challenges in Writing These Types
- Detailed Explanation of Each Part
- Conclusion
- References
Why Use expected
?
expected
is a type that can hold either a successful result or an error. This lets you return errors in a safer way instead of throwing exceptions or using error codes. When a function returns expected<T, E>
, you can check if it has a valid value (T
) or an error (E
) before using it.
This approach makes error handling more explicit. You won’t forget to check for errors because you must query the object to get the result. It also helps avoid hidden control flow surprises that often come with exceptions.
General Structure of expected
and unexpected
In this implementation, expected<T, E>
is built on top of std::variant
. The variant
holds either the success value of type T
or an unexpected<E>
containing the error. This gives you a single object that clearly shows which state it’s in.
The unexpected
type itself is simple: it only wraps the error value. It acts as a tagged container to signal that there was a failure. You can access the error directly when needed.
template<class E>
class unexpected;
template<class T, class E>
class expected {
using storage_t = std::variant<T, unexpected<E>>;
storage_t storage_;
...
};
Main Challenges in Writing These Types
One challenge is correctly managing the two states—either success or error—and ensuring that only the right functions are accessible in each state. For example, you don’t want to call value()
if the object contains an error. This is enforced with runtime checks (MR_ASSERT
).
#ifndef MR_ASSERT
#define MR_ASSERT(cond) if (!(cond)) { throw std::logic_error("assertion failure"); }
#endif
Another challenge is making the interface easy to use. The class must provide operators like *
, ->
, and functions like value_or()
so users can get the contained value safely. Supporting all combinations of copy, move, and in-place construction also requires careful design.
Detailed Explanation of Each Part
Below, I’ll go step by step through the important pieces of the code and explain how they work.
1. Tag Types (unexpect_t
and in_place_t
)
These small types (unexpect_t
and in_place_t
) are used as tags to tell the constructor which state you want to create. For example, unexpect
signals that you want to construct the object in the error state. in_place
is used to build the success value directly.
Using tags avoids confusion in overloaded constructors and makes intent very clear to the caller.
struct unexpect_t { explicit unexpect_t() = default; };
struct in_place_t { explicit in_place_t() = default; };
inline constexpr unexpect_t unexpect{};
inline constexpr in_place_t in_place{};
2. The unexpected<E>
Class
This type holds only the error. It provides constructors to initialize the error from a copy or move. The error()
function lets you access the error safely. There is also a make_unexpected()
helper to create an unexpected
easily.
This class is simple on purpose: it exists only to store and convey the error.
template<class E>
class unexpected {
public:
unexpected() = delete;
unexpected(const E& e) : err_(e) {}
unexpected(E&& e) : err_(std::move(e)) {}
// defaults for copy/move/destruct:
...
const E& error() const & { return err_; }
E& error() & { return err_; }
private:
E err_;
};
3. expected<T, E>
Constructor Logic
The expected
class has many constructors to cover all scenarios. For example, you can default-construct it (if T
is default-constructible), pass a T
value to create a success, or use unexpect
and an E
to create an error.
Internally, it uses std::variant<T, unexpected<E>>
to store one of the two possibilities. The constructors call std::in_place_index_t<N>
to tell the variant which alternative to create.
template <typename U = T,
typename = std::enable_if_t<std::is_default_constructible_v<U>>>
expected() : storage_(std::in_place_index_t<0>{}) {}
expected(const T& v) : storage_(std::in_place_index_t<0>{}, v) {}
expected(T&& v) : storage_(std::in_place_index_t<0>{}, std::move(v)) {}
expected(unexpect_t, const E& e)
: storage_(std::in_place_index_t<1>{}, e) {}
expected(unexpect_t, E&& e)
: storage_(std::in_place_index_t<1>{}, std::move(e)) {}
expected(const unexpected<E>& u)
: storage_(std::in_place_index_t<1>{}, u) {}
expected(unexpected<E>&& u)
: storage_(std::in_place_index_t<1>{}, std::move(u)) {}
4. State Query (has_value()
)
The has_value()
function checks whether the stored index is 0
, which means the object holds a T
. This check is used everywhere to ensure that operations on the value are safe.
When you write if (exp)
, it calls the operator bool()
, which uses has_value()
. This makes it easy to check the success state in your code.
explicit operator bool() const noexcept { return has_value(); }
bool has_value() const noexcept { return storage_.index() == 0; }
5. Accessing the Value
Operators like *
and ->
let you get the stored T
just like you would with a pointer or optional. The value()
function returns a reference to the stored value.
Before accessing, these functions assert that the object really has a value. If not, MR_ASSERT
throws an exception. This prevents misuse.
// value access
T& operator*() & {
MR_ASSERT(has_value());
return *std::get_if<0>(&storage_);
}
T&& operator*() && {
MR_ASSERT(has_value());
return std::get<0>(std::move(storage_));
}
T& value() & {
MR_ASSERT(has_value());
return *std::get_if<0>(&storage_);
}
T&& value() && {
MR_ASSERT(has_value());
return std::get<0>(std::move(storage_));
}
6. Accessing the Error
If the object is in the error state, you can call error()
to get the stored error reference. Just like value access, these functions assert that the object is actually in the error state.
This clear separation makes it impossible to confuse success and failure at runtime if you always check properly.
E& error() & {
MR_ASSERT(!has_value());
return std::get<1>(storage_).error();
}
7. Emplacing a New Value
The emplace()
functions let you destroy the current contents and build a new value of T
in place. This is useful when you want to reuse an expected
object without creating a new one.
These functions forward arguments to the std::variant
emplace
, which reconstructs the value efficiently.
template<class... Args>
void emplace(Args&&... args) {
storage_.template emplace<0>(std::forward<Args>(args)...);
}
template<class U, class... Args>
void emplace(std::initializer_list<U> il, Args&&... args) {
storage_.template emplace<0>(il, std::forward<Args>(args)...);
}
8. Specialization for void
If T
is void
, you don’t need to store any value, only the error state. In this case, the implementation uses std::variant<std::monostate, unexpected<E>>
. The API still lets you check success and get the error when needed.
The value()
function for void
simply checks the state but does nothing because there is no data to return.
template<class E>
class expected<void, E> {
using storage_t = std::variant<std::monostate, unexpected<E>>;
storage_t storage_{std::in_place_index_t<0>{}};
...
};
Conclusion
This implementation gives you a clear and reliable way to return success or error without exceptions. The design uses modern C++ features like std::variant
and in-place construction to make the code clean and safe. By understanding each part, you can adapt it to fit your own projects.
If you want to use expected
in your code, start by returning it instead of throwing, and always check has_value()
before accessing the result.