Errors as Values and std::expected

Learn how to handle errors as values in C++23 using the std::expected type

Ryan McCombe
Published

Throughout this chapter, we've been learning about exceptions. Exceptions allow a function to signal an error and immediately transfer control to an error handler, potentially far up the call stack.

However, exceptions come with their own set of trade-offs and, in some projects, are disabled entirely. This means it's important to be aware of some alternative approaches that we may want to use instead.

In C++23, the standard library introduced std::expected, a type specifically designed to formalize the "errors-as-values" pattern. This lets a function return either a successful result or an error, making the possibility of failure an explicit and unignorable part of the function's contract.

In this lesson, we'll explore why you might choose this errors-as-values pattern over exceptions, and how std::expected implements it.

Problems with Exceptions

Exceptions are a cornerstone of error handling in C++, but they have notable drawbacks:

Performance Overhead

Throwing and catching exceptions can be computationally expensive, particularly in performance-critical applications.

While modern C++ implementations minimize overhead when exceptions are not thrown, the cost is significant when they are, potentially impacting performance in tight loops or real-time systems

Complex Control Flow

Exceptions introduce non-local control flow, making it harder to predict where an error might be caught. This can complicate debugging and maintenance, as the code path jumps unexpectedly.

Restricted Environments

Some environments, such as embedded systems or real-time applications, prohibit exceptions due to resource constraints or the need for predictable execution. This necessitates alternative error-handling mechanisms.

Unclear Error Sources

Without explicit documentation, it's often unclear which exceptions a function might throw, leading to potential oversight in error handling.

Errors as Values

The main alternative to exceptions is a design pattern commonly called errors-as-values. Here, we just treat represent errors like we would any other value, such as an int. This method makes error handling explicit and local, which can keep things simple.

Returning Errors

Commonly, the errors-as-values approach involves returning error information as part of a function's return value. We've already seen a notable example of this: our main() function returns an exit code explaining why our program ended.

Returning 0 signals that our program exited normally, whilst any other returned value represents an error:

int main() {
  // ...
  
  // This is a value representing an error
  return 1;
}

We can replicate this same pattern in any function we might define:

// Returns 0 if successful, or an error code otherwise
int PerformTask() {
  return 0;
}

Then, if we document this behaviour such that anyone using our function is aware what this return value represents, they can check for errors and react as appropriate:

if (PerformTask() != 0) {
  // Something went wrong...
}

A natural extension of this pattern is to attach further semantic meaning to which integer is returned, thereby allowing us to report different types of errors:

// Returns 0 if successful, or an error code otherwise
// Error Codes:
// 1: Network Error
// 2: Memory Error
int PerformTask() {
  if (NetworkUnavailable) return 1;
  if (OutOfMemory) return 2;
  // Do work...
  return 0;
}

However, using our return value for error-reporting has an obvious problem: what if also want the function to return a result when it succeeds in its task?

In some cases, we can work around this. For example, if our function would normally return a pointer, we can return a nullptr to indicate failure. If it's expected to return a positive number, returning 0 or a negative value can indicate an error.

However, there will be many other cases where there is no obvious return value that doesn't conflict with what could be a valid result. For example, a division function can return any number, so there's no direct way to signal an error using this technique:

int Divide(int a, int b) {
  if (b == 0) return 0;
  return a / b;
}
// This is valid and will return 0
Divide(0, 5);

// This is invalid but will also return 0
Divide(5, 0);

Returning Complex Types

The main way we solve this is by returning a more complex type that can contain both the result of the operation, and whether or not that action was successful.

For example, we might introduce a DivisionResult type, or just use something like a std::pair:

struct DivisionResult {
  int Result;
  bool wasSuccessful;
};

DivisionResult Divide(int a, int b) {
  if (b == 0) return {0, false};
  return {a / b, true};
}

// Alternatively:
std::pair<int, bool> Divide(int a, int b) {
  if (b == 0) return {0, false};
  return {a / b, true};
}
auto[Result, wasSuccessful]{Divide(0, 5)};

In C++23, std::expected was added to the standard library, which is a template type designed for this exact purpose.

Using std::expected

C++23 introduces std::expected, a new standard type for error handling, inspired by functional programming and other languages. This has two main advantages over a custom type:

  1. std::expected has already implemented functions and operators that are useful in this error-handling context. We'll cover them throughout this lesson
  2. As a standard library utility, many developers will already know how it works. They don't need to examine some custom type (like DivisionResult) to understand how to use our function.

std::expected is a template with two template parameters:

  1. The type of value that is used in the expected scenario (ie, when no error occurs)
  2. The type we want to use to represent an error

So, a std::expected<T, E> always contains exactly one of two possibilities: the expected result of type T or an unexpected error of type E.

Let's update our Divide() function to return a std::expected<int, std::string>.

#include <expected> 
#include <string>

using DivisionResult =
  std::expected<int, std::string>;
  
DivisionResult Divide(int a, int b) {
  // We'll implement this in the next section
}

Conceptually, this means that Divide() will return an integer if it was successful but, if there was an error, it will return a string explaining why.

Handling Success

To handle the successful scenario, we can just return an integer in the normal way:

#include <expected>
#include <string>

using DivisionResult =
  std::expected<int, std::string>;
  
DivisionResult Divide(int a, int b) {
  if (b == 0) {
    // We'll handle the error state in
    // the next section
  }
  return a / b;
}

This works is because std::expected<T, E> has a constructor that accepts a T, making the happy path very simple to set up.

Handling Errors using std::unexpected

When something goes wrong, we need to return a std::expected in the error state. To do this, we can use the std::unexpected<ErrorType>() template.

In our Divide() example, the error type is a std::string, so we return a std::expected<std::string>:

#include <expected>
#include <string>

using DivisionResult =
  std::expected<int, std::string>;
  
DivisionResult Divide(int a, int b) {
  if (b == 0) {
    return std::unexpected<std::string>{     
      "Cannot divide by zero" 
    };                      
  }
  return a / b;
}

In most cases, we can use class-template argument deduction (CTAD) to simplify this:

#include <expected>
#include <string>

using DivisionResult =
  std::expected<int, std::string>;
  
DivisionResult Divide(int a, int b) {
  if (b == 0) {
    return std::unexpected<std::string>{
    return std::unexpected{
      "Cannot divide by zero"
    };
  }
  return a / b;
}

Checking for Success or Error

A std::expected provides multiple ways to check whether it contains a value or an error.

Using has_value()

The has_value() method returns true if the std::expected holds a T (i.e., the operation succeeded), or false if it holds an E ((i.e., the operation failed):

#include <expected>
#include <iostream>
#include <string>

using DivisionResult =
  std::expected<int, std::string>;
  
DivisionResult Divide(int, int) {/*...*/} int main() { DivisionResult ResultA{Divide(0, 5)}; if (ResultA.has_value()) { std::cout << "0 / 5 Succeeded\n"; } DivisionResult ResultB{Divide(5, 0)}; if (ResultB.has_value()) { // Do stuff... } else { std::cout << "5 / 0 Failed\n"; } }
0 / 5 Succeeded
5 / 0 Failed

Using operator bool

There is a convenient conversion to bool, which is true when a value is present and false when it's an error. This allows a more succinct check:

#include <expected>
#include <iostream>
#include <string>

using DivisionResult =
  std::expected<int, std::string>;
  
DivisionResult Divide(int, int) {/*...*/} int main() { DivisionResult ResultA{Divide(0, 5)}; if (ResultA) { std::cout << "0 / 5 Succeeded\n"; } else { std::cout << "5 / 0 Failed\n"; } }
0 / 5 Succeeded

Accessing the Value

After confirming our std::expected has a value using the has_value() or bool conversion, there are a few ways we can access that value:

value()

The value() method returns the value stored in the std::expected.

In almost all scenarios, we should only use this method if we're sure the std::expected contains a value. Therefore, it will typically be used within a conditional block:

#include <expected>
#include <iostream>
#include <string>

using DivisionResult =
  std::expected<int, std::string>;

DivisionResult Divide(int, int) {/*...*/} int main() { DivisionResult Result{Divide(10, 2)}; if (Result) { std::cout << "10 / 2 Succeeded: " << Result.value(); } }
10 / 2 Succeeded: 5

The value() method throws an exception if the std::expected was in the error state. We cover this exception in a later section.

value_or()

In many cases when working with std::expected, the value_or() method gives us an elegant way to implement our logic, removing the need for conditional checks.

If a std::expected contains a value, value_or() will return that value. Otherwise, it will return the value we passed as an argument.

Below, Result will use the value from our Divide() function if the division was successful, or the value 1 otherwise:

#include <expected>
#include <iostream>
#include <string>

using DivisionResult =
  std::expected<int, std::string>;

DivisionResult Divide(int, int) {/*...*/} int main() { int Result{Divide(10, 0).value_or(1)}; std::cout << "Using Value: " << Result; }

operator*

The * operator is a succinct alternative to the value() method:

#include <expected>
#include <iostream>
#include <string>

using DivisionResult =
  std::expected<int, std::string>;

DivisionResult Divide(int, int) {/*...*/} int main() { DivisionResult Result{Divide(10, 2)}; if (Result) { std::cout << "10 / 2 Succeeded: " << *Result; } }

Unlike value(), the * operator's behaviour is undefined if our std::expected does not contain a value. Therefore, we should only use the * operator when we're sure it will work.

operator->

If the std::expected contains a more complex object, we can combine the value() method (or the * operator) with the member access operator . using ->:

#include <expected>
#include <iostream>
#include <string>

struct Player {
  std::string Name{"Anna"};
};

std::expected<Player, int> GetPlayer() {
  return Player{};
}

int main() {
  // Using class-template argument deduction
  // Full type: std::expected<Player, int>
  std::expected PlayerOne{GetPlayer()};

  if (PlayerOne) {
    std::cout << "Hello " << PlayerOne->Name;
  }
}
Hello Anna

The -> operator's behavior is also undefined if the std::expected does not contain a value.

Accessing the Error Using error()

We can access an error value held by a std::expected using the error() function. We should only use this if we're sure the std::expected is holding an error, so this is generally going to be used within a conditional block:

#include <expected>
#include <iostream>
#include <string>

using DivisionResult =
  std::expected<int, std::string>;

DivisionResult Divide(int, int) {/*...*/} int main() { DivisionResult Result{Divide(10, 0)}; if (Result) { // ... } else { std::cout << "10 / 0 Failed: " << Result.error(); } }
10 / 0 Failed: Cannot divide by zero

The error_or() Function

The value_or() function's less useful sibling error_or() is also available. If the std::expected is in the error state, it will return the error value. Otherwise, it will return the value we passed as an argument to error_or():

#include <expected>
#include <iostream>
#include <string>

using DivisionResult =
  std::expected<int, std::string>;

DivisionResult Divide(int, int) {/*...*/} int main() { DivisionResult ResultA{Divide(10, 2)}; std::cout << "Division Outcome: " << ResultA.error_or("Success"); DivisionResult ResultB{Divide(10, 0)}; std::cout << "\nDivision Outcome: " << ResultB.error_or("Success"); }
Division Outcome: Success
Division Outcome: Cannot divide by zero

Equality Comparisons

The std::expected type also implements the basic equality operator, ==, in several different forms.

The most useful form of this involves comparing a std::expected to an expected value - ie, comparing a std::expected<T, E> to a T:

#include <expected>
#include <iostream>
#include <string>

using DivisionResult =
  std::expected<int, std::string>;

DivisionResult Divide(int, int) {/*...*/} int main() { DivisionResult ResultA{Divide(10, 2)}; if (ResultA == 5) { std::cout << "That's a 5\n"; } }
That's a 5

We can use the same technique to directly check for specific errors by constructing a std::unexpected:

#include <expected>
#include <iostream>

enum ErrorType { NetworkDown, OutOfMemory };

std::expected<int, ErrorType> PerformTask() {/*...*/} int main() { std::expected Result{PerformTask()}; if (Result == std::unexpected{NetworkDown}) { std::cout << "Network error!"; } }
Network error!

We can also use the != operator in the expected way:

#include <expected>
#include <iostream>

enum ErrorType { NetworkDown, OutOfMemory };

std::expected<int, ErrorType> PerformTask() {
  bool NetworkAvailable{false};
  bool MemoryAvailable{true};
  if (!NetworkAvailable) {
    return std::unexpected{NetworkDown};
  }
  if (!MemoryAvailable) {
    return std::unexpected{OutOfMemory};
  }

  // Do work...

  return 42;
}

int main() {
  std::expected Result{PerformTask()};
  if (Result != 42) {
    std::cout << "It's not 42\n";
  }

  if (Result != std::unexpected{OutOfMemory}) {
    std::cout << "Memory seems fine";
  }
}
It's not 42
Memory seems fine

Monadic Operations

The std::expected type comes with a set of monadic utility functions that allow chaining operations in a style that is popular from functional programming languages.

For example, we'll commonly want to perform some follow up action if our operation was successful - that is, if our std::expected is in the successful state.

Rather than implementing a verbose if check, we could use the and_then() method, providing the function we want to use as an argument.

Using and_then()

If the std::expected is in an error state, and_then() will simply return it. However, if it contains an expected value, our function will be invoked with the std::expected as an argument.

The function we provide should have two specific properties:

  • Our function should not modify the argument - as such, it can either receive it by value (ie, a copy) or by const reference
  • Monadic operations are designed to be "chainable" (which we'll see soon) so it should also return the std::expected for follow-up operations

The following program logs our division result if it was successful:

#include <iostream>
#include <expected>

using DivisionResult =
  std::expected<int, std::string>;

DivisionResult Divide(int, int) {/*...*/} DivisionResult Log(const DivisionResult& x) { std::cout << "Value: " << x.value() << '\n'; return x; } int main() { // This will succeed and log: Divide(10, 5).and_then(Log); // This will not: Divide(10, 0).and_then(Log); }
Value: 2

Using or_else()

The or_else() function is essentially the opposite of and_then(). We provide it with a function to be invoked if our std::expected is in the error state. Our function will be called with the error object, which is a std::string in our DivisionResult example.

Below, we add an and_then() invocation to our chain to set a fallback value if our division failed:

#include <iostream>
#include <expected>

using DivisionResult =
  std::expected<int, std::string>;

DivisionResult Divide(int, int) {/*...*/}
DivisionResult Log(DivisionResult) {/*...*/} DivisionResult GetDefaultValue( const std::string& E ) { std::cout << E << " - Using Default Value\n"; // Our return type is DivisionResult, so this is // equivalent to return DivisionResult{1} return 1; } int main() { Divide(2, 0) .or_else(GetDefaultValue) .and_then(Log); }
Cannot divide by zero - Using Default Value
Value: 1

Using transform()

The transform() function can be used to modify the expected value, if the std::expected is in the successful state. It will call the function we provided, passing the current value as an argument, and updates the std::expected with the value we return.

Below, we use this to increment the value returned by our Divide() function, or the value returned by GetDefaultValue() if division failed:

#include <expected>
#include <iostream>

using DivisionResult =
  std::expected<int, std::string>;

DivisionResult Divide(int, int) {/*...*/}
DivisionResult Log(DivisionResult) {/*...*/}
DivisionResult GetDefaultValue(std::string) {/*...*/} int Increment(int x) { std::cout << "Incrementing\n"; return ++x; } int main() { Divide(2, 0) .or_else(GetDefaultValue) .and_then(Log) .transform(Increment) .and_then(Log); }
Cannot divide by zero - Using Default Value
Value: 1
Incrementing
Value: 2

Using transform_error()

Finally, we have transform_error(). If our std::expected is in the error state, the function we provide will be invoked with that error as an argument. The return value will be used to update the error within the std::expected.

Below, we use this to modify our error string with an "Error: " prefix and a line break:

#include <iostream>
#include <expected>

using DivisionResult =
  std::expected<int, std::string>;

DivisionResult Divide(int, int) {/*...*/}
DivisionResult Log(DivisionResult) {/*...*/}
DivisionResult GetDefaultValue(std::string) {/*...*/}
int Increment(int x) {/*...*/} std::string FormatErrorMessage( const std::string& E ) { return "Division Failed: " + E + '\n'; } int main() { Divide(2, 0) .transform_error(FormatErrorMessage) .or_else(GetDefaultValue) .and_then(Log) .transform(Increment) .and_then(Log); }
Division Failed: Cannot divide by zero
 - Using Default Value
Value: 1
Incrementing
Value: 2

Type Changes using transform() and transform_error()

Our previous examples are maintaining the expected type and error type throughout our pipeline, but that's not required.

The transform() and transform_error() functions can return a different type from their input, allowing us to build more complex data transformation pipelines.

Below, we add a Stringify() transformation, which converts our division result from a std::expected<int, std::string> to a std::expected<std::string, std::string>:

#include <expected>
#include <iostream>
#include <string>

using DivisionResult =
  std::expected<int, std::string>;

DivisionResult Divide(int, int) {/*...*/}
DivisionResult GetDefaultValue(std::string) {/*...*/} std::string Stringify(int x) { return std::to_string(x); } int main() { std::string Result{ Divide(2, 0) .or_else(GetDefaultValue) .transform(Stringify) // We're now dealing with a // std::expected<std::string, std::string> .value() }; std::cout << "Result: " << Result; }
Cannot divide by zero - Using Default Value
Result: 1

Let's finish with a more complex example. Below, our pipeline starts with a std::expected<int, int> but ends with a std::expected<std::string, ErrorType>

#include <expected>
#include <iostream>
#include <string>

std::expected<int, int> DoWork() {
  return std::unexpected{1};
}

enum class ErrorType { Unknown, NetworkError };

ErrorType ImproveError(int ErrorCode) {
  if (ErrorCode == 1) {
    return ErrorType::NetworkError;
  }
  return ErrorType::Unknown;
}

std::expected<int, ErrorType> HandleError(
  ErrorType E
) {
  if (E == ErrorType::NetworkError) {
    // Reconnecting...
  }

  return 42;
}

std::string Stringify(int x) {
  return std::to_string(x);
}

int main() {
  std::string Result{DoWork()
    // We have a expected<int, int>
    .transform_error(ImproveError)
    // Now we have expected<int, ErrorType>
    .or_else(HandleError)
    .transform(Stringify)
    // Now we have expected<string, ErrorType>
    .value()
  };

  std::cout << "Result: " << Result;
}

Using std::expected with void Functions

For functions that perform an action without returning a value, we can still use std::expected for error reporting if we wish. We'd pass void as the first template parameter, as in std::expected<void, E>.

We can continue to use the bool conversion or has_value() to check if the operation was successful, and examine the error() value if it wasn't:

#include <expected>
#include <iostream>
#include <string>

std::expected<void, std::string> DoThing() {
  bool Problems{true};
  if (Problems) {
    return std::unexpected{"Oh no"};
  }

  // Do work...
}

// Alternatively:
std::expected<void, std::string> DoStuff() {
  bool Problems{true};
  if (!Problems) {
    // Do work...
    return {};
  }

  return std::unexpected{"Oh no"};
}

int main() {
  std::expected Result{DoThing()};
  if (!Result) { std::cout << Result.error(); }
}
Oh no

The std::bad_expected_access Exception

If we're using std::unexpected in a program that is also using exceptions, we can call value() without necessarily checking if the std::expected has a value.

If it doesn't, a std::bad_expected_access exception is thrown, which can be caught and handled like any other exception.

This is a template type where the template parameter corresponds to the error type of the associated std::expected.

So, for example, if we were calling value() on a std::expected<int, std::string>, then the exception that will be thrown is a std::bad_expected_access<std::string>.

We can access the error object - the std::string in this case - using the error() method on the exception:

#include <expected>
#include <iostream>
#include <string>

std::expected<int, std::string> DoThing() {
  return std::unexpected{"Oh no"};
}

int main() {
  try {
    DoThing().value();
  } catch (
    std::bad_expected_access<std::string>& E
  ) {
    std::cout << E.what() << ": " << E.error();
  }
}
Bad expected access: Oh no

Summary

In this lesson, we explored std::expected, a C++23 utility for returning either an expected value or an unexpected error. This "errors-as-values" approach provides an explicit, local, and often more performant alternative to traditional exception handling.

We saw how to create, check, and access the contents of a std::expected, and how to use its monadic operations to chain functions cleanly.

Key Takeaways:

  • std::expected<T, E> holds either a value of type T or an error of type E.
  • Return a successful value directly, such as return 42;, and an error using std::unexpected, such as return std::unexpected{"error"};.
  • Check for a value using a bool conversion like if (result) or the result.has_value() method.
  • Access the value with value(), *, or ->. Access the error with error().
  • Use value_or() to provide a fallback value if an error occurred.
  • Chain operations on std::expected objects using monadic functions like and_then(), or_else(), and transform().
Next Lesson
Lesson 43 of 128

Static Arrays using std::array

An introduction to static arrays using std::array - an object that can store a collection of other objects

Have a question about this lesson?
Answers are generated by AI models and may not have been reviewed for accuracy