Error reporting in C++

20 August 2024

a headline image with a pipe and boxes saying error on them

In this tutorial, we will be talking about errors that occur during the execution of a program and how to report them in C++. We won’t be talking much about program logic errors, but rather “environmental” errors, such as file not found, out of memory, no network etc. Reporting such errors becomes “trickier” when they take place in a function that normally returns a value. What should it return instead?

So, what is the big deal with error reporting?
Recovering from errors is not always possible. Modern programming languages provide a variety of ways to convey that something went wrong and effective error reporting increases the correctness of software and makes it more maintainable.

The goals of error reporting are simple:

  • Make correct code easy to write, and incorrect code hard to write.
  • Fail quickly if you must, report unignorable and unambiguous errors.

Before we examine these concepts in more detail, this tutorial also exists:

  • As a FREE course on Udemy which includes interactive exercises for each error reporting technique.
  • As a Youtube playlist where each error reporting technique is explained in a video.

Table of contents

Unignorable errors 👾

If an error has occurred, then ignoring it is typically a bad idea. What should we do about it? Are we in a valid state? Should we escalate the error to the caller or a user interface?
From a language syntax perspective, C++17 introduced the [[nodiscard]] attribute. nodiscard induces a compilation error if the return value of a function is ignored. You should absolutely use [[nodiscard]] for functions that may communicate an error state through their return value. That being said, it is not a silver bullet as we will see soon. In fact, in all of the examples we will be looking at in this tutorial, we will assume that [[nodiscard]] is used. I have not included nodiscard in the code snippets to make them shorter and easier to read.

To demonstrate the different error reporting techniques, we will be looking at a relatively simple function that reads a file and returns its contents:

std::string readFile(std::filesystem::path p);

Based on this description, what can go wrong when reading a file?
Well, even in such a simple case, there are multiple things that can go wrong:

  • The file may not exist or we may not have the permissions to read it.
  • The file could be locked by another process.
  • The file could be too big for our data structures or memory, etc.

So how do we communicate these errors?
How do we communicate these errors OR the contents of the file?
How do we stop the caller from accessing the contents of the file if an error has occurred?
In the rest of this tutorial we will be looking at different ways to report errors while addressing these questions. We will evaluate each technique based on its simplicity, expressiveness and robustness.

Fail quickly: Exceptions 💥

Exceptions allow us to immediately exit a scope and unwind the stack until someone catches the exception. This allows us to keep the API very simple and focus on the “happy path” of the function. If something goes wrong when reading a file, an exception is thrown and we will not return the contents of the file.

struct FileNotFoundException : public std::runtime_error {
  FileNotFoundException(const std::string& msg) : std::runtime_error{msg} {}
};

std::string readFile(std::filesystem::path p) {
  if (noFile) throw FileNotFoundException{"File does not exist"};
}

On the other hand, handling exceptions may be error prone. This is partly because exceptions are not part of the function signature. In other words, unless very clearly documented, we cannot know which exact exceptions a function may throw, just by looking at its signature.
To make things even trickier, exceptions may be thrown by functions that the function we are calling invokes. This means that if we want to be granular about the exceptions we want to handle, we need to potentially catch a large number of them. That being said, exceptions aside, you should be using patterns like RAII to make sure that resources are always released properly.
Exceptions may also come with a performance penalty if they are thrown frequently. It’s not uncommon for C++ code to be compiled with exceptions disabled.

Let’s look at an example of how we can use exceptions to report errors. We can see that readFile throws an FileNotFoundException if an error is encountered. Then, in the caller, we use a try-catch block to handle the exception.

std::string readFile(std::filesystem::path p) {
  if (noFile) throw FileNotFoundException{"File does not exist"};
}

If an exception is thrown, the content variable is never returned and we end up in the catch block. As a result, in the try block we can focus on the happy path and not worry about error handling. Whenever we access the content we know it is valid.

try {
  const auto content = readFile("file.txt");
  // Use content
} catch (const FileNotFoundException& e) {
  // Handle the error
}

Let’s look at another example. Exceptions may make sense when you have deep call stacks and you want to avoid checking for unrecoverable errors in multiple places and then only propagating them up the stack.

error_t do_subtask() {
  if (auto error = do_subsubtask()) { return error; }
  // More code
}

error_t do_task() {
  if (auto error = do_subtask()) { return error; }
  // More code
}

int main() { return do_task(); }

Your code becomes unnecessarily complex. If you can’t do anything about an error, then do consider throwing an exception instead of bloating your code by propagating errors, just to exit “gracefully”.

There are many best practices when it comes to exceptions in the C++ core guidelines:

  • E.1: Develop an error-handling strategy early in a design
  • E.2: Throw an exception to signal a function can’t perform its task
  • E.3: Use exceptions for error handling only
  • E.14: Use purpose-designed user-defined types as exceptions
  • E.17: Don’t try to catch every exception in every function
  • E.18: Minimize the use of explicit try/catch
  • E.28: Avoid error handling based on global state (e.g. errno)
  • Modern C++ best practices for exceptions and error handling

In my opinion, there are three things to keep in mind:

  1. First, develop a strategy when it comes to error-handling and exceptions early in the project. One of the worst things you can do is to mix techniques and make it unclear when exceptions may be thrown.
  2. Second, use exceptions to report exceptional errors. An exception should signal that something “horrible” and unexpected has happened. It should not be used to report errors that are somewhat “common”. For example, exceptions may be thrown for a constructor not being able to construct an object, due to failure to allocate memory. Another example could be a function that tries to get an out-of-bounds element from a container. In these cases you’d want the exception to be thrown and this error to be “escalated”. Throwing exceptions for exceptional errors also means that you should not be handling exceptions in most places in your code either. Catch exceptions only when and if you can do something about them.
  3. Third, create your own exception types. They are more readable and allow your exceptions to not be confused with others.

So, what happens if we may not or cannot throw exceptions? A value needs to be returned. But what should it be to make sure that wrong code is hard to write? What should the type be to make the error unignorable and unambiguous?

An “empty” value 🪹

This strategy is rather simple, if an error occurs, we return an empty or a default value. If no error occurs, we return the actual value.

std::string readFile(std::filesystem::path p) {
  if (error) return "";
}

The advantage of this approach is that it is very simple to implement. Nothing needs to change in the API to support this error reporting technique. On the other hand, there are two issues with it:

  1. First, there’s no way to distinguish between different errors. If more than one kind of error can occur, there’s no way to represent them.
  2. Second, the default or empty value could actually be a valid value. For example, if we are reading a file, a file could be empty. In that case there would not be a way to tell apart an empty file from a missing file or a file that we do not have permissions to read.
std::string readFile(std::filesystem::path p) {
  // ...
  if (error) return "";
}

Handling errors using empty values is very simple. We check whether the returned value is empty and if so, we handle the error. As long as there’s only one kind of error and we can guarantee that a default value is not a valid value for the payload, this approach works fine. Otherwise, we need to look into other techniques.

const auto content = readFile("file.txt");
if (content.empty()) {
  // Some kind of error or... an empty file
}

An “invalid” value 🪑

In this occasion, if an error occurs, we return an invalid value. If everything goes well, we return the actual value.

std::string readFile(std::filesystem::path p) {
  if (noFile) return "MissingFile";
}

Invalid values are values that you would not expect to be returned by the function. For example, if we are measuring a distance, a negative value could be an invalid value. If we are measuring the temperature of a room, a value of 1000 degrees would hopefully be an invalid value. In the case of reading a file, if the file is missing we are returning a string with a MissingFile value.

This approach is rather similar to the previous one. To begin with, it’s very simple to implement and understand. The API does not need to change. Then, unlike the previous approach, we can distinguish between different errors.

On the other hand, it shares a disadvantage with the previous approach: What if MissingFile is actually the contents of the file? As long as we can guarantee that the values we return, when errors occur, can never be valid, then this approach could work. Another issue with this approach is that since the same type is used for both the contents and the errors, we leave room for programming mistakes. For example, there’s nothing stopping a typo in the equality comparison, resulting in MissingFile being treated as a valid value. This can be quite a nasty bug to spot.

Here’s an example of how we can use invalid values to report errors. When no file is found, we return a string with the value “MissingFile”. Then, in the caller, we check if the returned value is equal to “MissingFile” and if so, we handle the error. The approach is very simple and as we discussed, allows us to distinguish between different errors.

std::string readFile(std::filesystem::path p) {
  if (noFile) return "MissingFile";
}

However it is error prone since the same type is used for both the valid values and the errors. If we are not careful, we could end up treating an error as a valid value. For example, if we skipped an “s” in the right-hand side of the first comparison, we would end up treating MissingFile as a valid value and be in trouble.

const auto content = readFile("file.txt");
if (content == "MissingFile") {
  // Handle the error
} else {
  // Use content
}

std::optional 🐱

Let’s look at std::optional as the means to report errors.
std::optional is a type that was introduced in C++17. It is a type that can either hold a value or not. If we try to access the value of an optional that does not hold a value, an exception is thrown. For example, an optional int can either hold an integer or not. An optional string can either be a string or “nothing”.
Here’s the cool part: An optional string may hold an empty string. This allows us to distinguish between an empty string and a missing string. In our readFile example, we return an empty optional if an error occurs, otherwise we return the contents of the file.

std::optional<std::string> readFile(std::filesystem::path p) {
  if (error) { return std::nullopt; }
}

The main advantage of this approach is while it is still rather simple, it protects the caller from accidentally accessing the payload if an error has occurred. In other words, if the caller tries to access the contents of the file without checking for an error first and an error has occurred, an exception will be thrown. Wrong code becomes harder to get away with. Furthermore, we can now distinguish between an empty file and an error, which was an issue in one of the previous approaches.
On the other hand, we cannot distinguish between different errors. We can only express whether an error has occurred or not, but not what kind of error. This may be OK, if we do not care about the exact error or if there’s only one kind of error.

Here’s a more elaborate example of how we can use std::optional to report errors.

std::optional<std::string> readFile(std::filesystem::path p) {
  // ...
  if (error) { return std::nullopt };
  else { return "<your file contents>" };
}

The most interesting part is how a user of the readFile function can access the contents of the file. First, they need to check if the optional holds a value. Then, if it does, they may access the value using the value() function. The use of std::optional “forces” the caller to check for an error before accessing the contents of the file. If the caller tries to access the contents of the file when an error has occurred, an exception will be thrown. Remember, the goal is to make wrong code hard to get away with and std::optional helps us achieve just that.

const auto content = readFile("file.txt");
if (content) {
  // Use content.value()
} else {
  // Handle the error, but what kind of error?
  // content.value() will throw
}

bool 🤔

In this part we will be looking at using booleans to report errors. A boolean is a built-in type that can either be true or false. It is very often used to determine whether something has succeeded or not. In this example we return true if the file was read successfully and false otherwise.

bool readFile(std::filesystem::path p, std::string& content) {
  if (error) return false;
}

The main advantage of this approach is that it is very simple to implement and understand. It’s also supported in all C++ standards. The drawback is that we cannot distinguish between different errors. This of course may be OK under certain circumstances, if for example there’s only one kind of error. Moreover, we need to pass the contents of the file as an out parameter. As a result, the caller cannot handle it as a constant.
To make matters worse, there’s nothing stopping the caller from accidentally accessing the contents of the file if an error has occurred. The content variable is not “protected” in any way.
Here’s a more complete example of the use case, as well as the issues that arise.

bool readFile(std::filesystem::path p, std::string& content) {
  // ...
  if (error) return false;
}

content needs to be passed as a parameter and it cannot be a constant. At the same time, it can be accessed even if an error has occurred, which may lead to bugs. Finally, we cannot distinguish between different errors.

std::string content;
if (readFile("file.txt", content)) {
  // Use content, but not const :(
} else {
  // Handle the error, but what kind of error?
  // May still use content by mistake
}

Error codes 🚦

Next, let’s review using error codes to report errors. Error codes are typically integers with values that represent different errors. You will find them frequently used in C and C++ code.

enum class ReadFileError { NoError, NoFile };

ReadFileError readFile(std::filesystem::path p, std::string& content) {
  if (noFile) return ReadFileError::NoFile;
}

They are readily available, very flexible and can be even used to communicate with different processes, languages and systems. You can use them to distinguish between different errors and they can even be combined with bitwise operations.
On the other hand, whatever “content” your function returns. needs to be passed as a parameter. This is not very neat and you cannot handle the payload as a constant. Moreover, there’s nothing stopping the caller from accidentally accessing the contents of the file even if an error has occurred.
In this example we can see both the usage of error codes as well as the issues that arise. The readFile function returns the NoFile error code if the file is missing.

enum class ReadFileError { NoError, NoFile };
ReadFileError readFile(std::filesystem::path p, std::string& content) {
  if (noFile) return ReadFileError::NoFile;
}

Then, on the caller side, we need to pass the contents of the file as a parameter and use a switch statement to handle the different error codes. As we discussed, the content variable is not “protected” in any way, and can be accessed even if an error has occurred. It is also not a constant, even if its value will probably not change.

std::string content;
const auto error = readFile("file.txt", content);
switch (error) {
  case ReadFileError::NoError:
    // Use content
    break;
  case ReadFileError::NoFile:
    // Handle the error
    break;
}

std::pair<bool, std::string> 🔗

Let’s discuss about returning a pair, of a boolean value and the contents of the file. The boolean value indicates whether an error has occurred or not.

std::pair<bool, std::string> readFile(std::filesystem::path p) {
  if (error) return {false, ""};
}

This is similar to a previous approach where we used a boolean to indicate the error. The difference is that we now also return the contents of the file as part of the return value. As a result, we do not need to pass them as a parameter and we can handle the payload as a constant.
On the other hand, the other drawbacks remain. We cannot distinguish between different errors and there’s nothing stopping the caller from erroneously accessing the contents of the file.
Here’s an example of how to use pairs. If there’s an error, we return a pair with false as the first element. The second element should not matter and we can return an empty string.

std::pair<bool, std::string> readFile(std::filesystem::path p) {
  // ...
  if (error) return {false, ""};
}

On the caller side, we check if the first element of the pair is false and if so, handle the error. As you can see, nothing stops us from accidentally accessing the contents of the file even if an error has occurred.

const auto [success, content] = readFile("file.txt");
if (success) {
  // Use content
} else {
  // Handle the error, but what kind of error?
  // May still use content by mistake
}

std::pair<ErrorCode, std::string> 🔗

In this section we will discuss about returning a pair of an error code and the contents of the file as the means to report errors.

std::pair<ReadFileError, std::string> readFile(std::filesystem::path p) {
  if (noFile) return {ReadFileError::NoFile, ""};
}

By following this approach, we can both distinguish between different errors and the caller can handle the contents of the file as a constant. On the downside, the caller can still access the contents of the file even if an error has occurred.
This is how we can use this technique. If there’s an error, we return a pair with the error code as the first element. If there’s no error, we return the “no error” code and the contents of the file as the second element.

std::pair<ReadFileError, std::string> readFile(std::filesystem::path p) {
  // ...
  if (noFile) return {ReadFileError::NoFile, ""};
}

The caller may use a switch statement to handle the different error codes. As we can see, the caller may still access the contents by mistake, even if an error has occurred.

const auto [error, content] = readFile("file.txt");
switch (error) {
  case ReadFileError::NoError:
    // Use content
    break;
  case ReadFileError::NoFile:
    // Handle the error
    break;
}

Custom classes with bool and the payload 🔗

Let’s take a quick look at an alternative to the pair approach. In this case, we will be using a custom class that holds a boolean value and the contents of the file. ReadFileResult has two data members, a boolean value and a string. The boolean value indicates whether an error has occurred or not.

struct ReadFileResult {
  bool success{};
  std::string content{};
};

ReadFileResult readFile(std::filesystem::path p) {
  if (error) return {false, ""};
}

This approach is very similar to the pair we discussed earlier, with the added benefit of being more expressive. Instead of referring to the first and second element of the pair, we can refer to the success and content data members of the ReadFileResult class.
The drawbacks are the same, namely we cannot distinguish between different errors and there’s nothing stopping the caller from erroneously accessing the contents of the file.

Here’s an example of how to use custom classes to report errors. It looks very similar to the pair approach but now we can refer to the success and content data members.

struct ReadFileResult {
  bool success{};
  std::string content{};
};

ReadFileResult readFile(std::filesystem::path p) {
  if (error) return {false, ""};
}

Fun fact, if the class you return has two data members, you can use structured bindings to access them just like you would with a pair. So from the caller’s perspective, getting a pair or a class with two public data members can be treated exactly the same as of C++17.

const auto result = readFile("file.txt"); // Or use structured bindings
if (result.success) {
  // Use result.content
} else {
  // Handle the error, but what kind of error?
  // May still use result.content by mistake
}

Custom class with error_code and the payload 🔗

Do you know what’s missing? Yet another alternative to the pair approach. I promise this is the last one. This time we are returning a custom class that holds an error code and the contents of the file.

struct ReadFileResult {
  ReadFileError error{};
  std::string content{};
};

ReadFileResult readFile(std::filesystem::path p) {
  if (noFile) return {ReadFileError::FileDoesNotExist, ""};
}

Compared to the equivalent pair approach, this is more expressive. Other than that, it shares the same advantages and drawbacks.

struct ReadFileResult {
  ReadFileError error{};
  std::string content{};
};

ReadFileResult readFile(std::filesystem::path p) {
  if (noFile) return {FileError::FileDoesNotExist, ""};
}

We can now distinguish between different errors and the caller can handle the contents of the file as a constant. However, there’s still room for misuse as the caller can access the contents of the file even if an error has occurred.

const auto result = readFile("file.txt");
if (result.error == ReadFileError::NoError) {
  // Use result.content
} else {
  // May still use result.content by mistake
}

std::variant (type-safe union) ⚖️

Enough fooling around, let’s get into… the more interesting stuff. std::variant is a C++17 type-safe union. What does that mean? It means that it may be one and only one of the types it holds. For example, if there’s a variant of a string and an error code, then the variant may hold either a string OR an error code. We will explain more about variants soon, but first let’s take a look at how we can use them to report errors.

std::variant<std::string, ReadFileResult> readFile(std::filesystem::path p) {
  if (noFile) return ReadFileResult::FileDoesNotExist;
}

By returning a variant of an error code and the contents of the file, we can now both distinguish between different errors AND the contents of the file are now protected against accidental access. This is because the result can either be an error or the contents of the file. If the caller tries to get the contents of the file when an error has occurred, an exception will be thrown.
The main drawback of this approach is that the usage of variants may seem “exotic” to some, but as soon as you get used to them, they are very powerful and effective.

Let’s go over the basic usage of variants. Assume a variant that can store an integer, a string or a double. First, we initialize it with an integer. Then when we ask to get an integer from it and get the corresponding value. Instead of the type, we may also provide the index of the type we want to get.

std::variant<int, std::string, double> var{10};
std::cout << std::get<int>(var) << "\n"; // 10
std::cout << std::get<0>(var) << "\n"; // 10
var = "Hello";
std::cout << std::get<std::string>(var) << "\n"; // Hello
std::cout << std::holds_alternative<std::string>(var) << "\n"; // true
std::cout << std::holds_alternative<int>(var) << "\n"; // false
std::cout << std::get<double>(var) << "\n"; // 💥 std::bad_variant_access

Similarly, if we set the variant to be a string and ask to get a string, we will now get the value of the string we set. Calling holds_alternative with a type, or an index, returns whether the variant holds the specific type or not. Finally, if we try to get a type that the variant does not hold, an exception will be thrown.
Now, let’s look further into how we can use variants to report errors. From the perspective of readFile things look pretty simple. If there’s an error, we return the error code. If there’s no error, we return the contents of the file.

std::variant<std::string, ReadFileResult> readFile(std::filesystem::path p) {
  if (noFile) {
    return ReadFileResult::FileDoesNotExist;
  }
  return "(╯°□°)╯︵ ┻━┻"; // File contents
}

On the caller side, we can use holds_alternative to check if the variant contains an error or not. If it is an error, we go ahead and handle the error. Remember that if we try to access the contents of the file when an error has occurred, an exception will be thrown. Then, if the variant does indeed hold the contents of the file, we can access them using get.

const auto result = readFile("file.txt");
if (std::holds_alternative<std::string>(result)) {
    const auto fileContents = std::get<std::string>(result);
    // Use fileContents
}
else {
    const auto error = std::get<ReadFileResult>(result);
    // Handle error
}

std::expected 🎁

Now let’s get serious and take a stub at std::expected, a type that was added to the standard library as of C++23. std::expected is essentially a specialization of a std::variant. Unlike a variant which can hold many types, std::expected only holds two. The first type is the “expected” type, in other words the type we expect to get if no error has occurred. The second type is the “unexpected” type. We get this type if something went wrong.

std::expected<std::string, ReadFileResult> readFile(std::filesystem::path p) {
  if (noFile) return std::unexpected(ReadFileResult::FileDoesNotExist);
}

std::expected attempts to address the readability issue of variants, since it’s specifically designed to be used for error reporting. We can both distinguish between different errors and the contents of the file are protected against accidental access. It also has some cool functional features that can make it a very powerful tool. On the downside, as of the beginning of 2024, the compiler support for std::expected is rather poor.

std::expected, as a specialization of a variant, exhibits a similar behavior but with some sprinkles on top. Here we have an std::expected where the expected type is an integer and the unexpected type is a string.
If we set the value of 10 to it, then we have set the expected value. This means that has_value will return true and value will return the value we set. Instead of has_value, we may also use it directly in an expression as a boolean. Moreover, we can dereference it to get the value.
Next, if we set an unexpected value to it, then has_value will return false. Note how we use the unexpected utility class to set the unexpected value. To get the unexpected value, we can use the error() member function. Trying to access the expected value, when an error has been set, results in an exception being thrown.

std::expected<int /* expected_t */, std::string /* error_t */> result{};
result = 10;
std::cout << result.has_value() << "\n";// true
std::cout << result.value() << "\n";    // 10
std::cout << *result << "\n";           // 10
std::cout << result.error() << "\n";    // Undefined behavior

result = std::unexpected{"(ง ͠° ͟ل͜ ͡°)ง"};
std::cout << result.has_value() << "\n";// false
std::cout << result.error() << "\n";    // (ง ͠° ͟ل͜ ͡°)ง
std::cout << *result << "\n";           // Undefined behavior
std::cout << result.value() << "\n";    // 💥 std::bad_expected_access

As we can see, std::expected has some “protection” mechanisms against accidental access. Let’s list them:

  • First, accessing the expected value via value when an error has occurred, results in an exception being thrown.
  • Accessing the unexpected value via error when no error has occurred, results in undefined behavior.
  • Dereferencing, when an error has occurred, also results in undefined behavior.

std::expected has some cool functional features. Let’s go over them. Aside of the value_or function that returns the expected value or some other “default” value, we get some “monadic” operations.
First, we have and_then. and_then is invoked if the expected value is currently set. It supplies the expected value, as an argument, to the provided function and returns the result of the function. If the expected value is not set, and_then does nothing and returns the unexpected value. On the other hand, or_else is invoked if the expected value is not set. The unexpected value is supplied as an argument to the provided function and returns the result of the function. Similarly, if the expected value is set, or_else does nothing and returns the expected value.
We may chain these functions together to create a “pipeline” of operations.

std::expected<Tokens, Error> tryParse(std::string text) { /* ... */ }
std::expected<Tokens, Error> handleErrorAndParse(Error error) { /* ... */ }
std::expected<Token, Error> getFirstToken(Tokens token) { /* ... */ }

auto /* std:: expected<Token, Error> */ token
    = tryParse(text)
        .or_else(handleErrorAndParse)
        .and_then(getFirstToken);
std::cout << token.value() << std::endl;

Alright, enough theory, let’s go back to our example. As far as readFile is concerned, things look pretty simple. If there’s an error, we return the error code as an unexpected value. If everything went well, we return the contents of the file.

std::expected<std::string, ReadFileResult> readFile(std::string) {
  if (noFile) {
      return std::unexpected{ReadFileResult::FileDoesNotExist};
  }
  return "◉_◉";
}

On the caller side, we can use has_value to check if the expected value is set or not. Then, we can use value to access the contents of the file. In case the result contains an unexpected value, we end up on the else branch of the if statement. There we can fetch the error code, using error, and handle the error.

const auto result = readFile("file.txt");
if (result) { // or result.has_value()
    const auto fileContents = result.value();
    // Use fileContents (std::string)
}
else {
    const auto error = result.error();
    // Handle error (ReadFileResult)
}

Conclusion 🎉

When it comes to error reporting, we have many options. While exceptions are a choice, yes, they should not necessarily be your first one. Reserve them for exceptional errors. Things you truly do not expect to happen under typical circumstances. Things that need to be escalated. If you do use exceptions, make sure you only catch them when you can do something about them. One issue with exceptions is that they can make your code harder to follow. They are also not part of the function signature and may come with a performance penalty if they are thrown frequently.
Next, depending on whether the function you are creating returns some kind of value or not, you have different options. The goal, is to avoid the caller from accidentally accessing the contents of the file when an error has occurred and that going unnoticed.
By the way, I am not suggesting you overengineer things either. If your function does not return anything special and you are only interested in whether an error has occurred or not, then you can use a boolean to indicate that. If you have multiple kinds of errors, then you can use an error code. On the other hand, if you want to return some kind of “payload”, let’s say the contents of a file, then you can use an optional value if you only have one kind of error. If you have multiple kinds of errors, then use expected.

To summarize, if there’s no payload, return a boolean or an error code. If there’s a payload, use an std::optional or an std::expected. If std::expected is not available, then you could use a std::variant instead or implement your own expected-like class.

  • Use exceptions for… exceptional situations
    • Think twice before avoiding them
    • Don’t overuse them, can be verbose, keep things simple
  • Function with no “payload” to return:
    • Return bool if function can only fail or succeed
    • Return error code if function can fail in multiple ways
  • Function with “payload” to return:
    • Return std::optional<Payload> if it can fail or succeed
    • Return std::variant<Payload, Code> if it can fail in multiple ways
  • If std::expected not available, use std::variant
    • Or make your own expected class
  • Andrei Alexandrescu: “Expect the expected”
  • C++ API Design - Jason Turner - CppCon 2022