Breaking Free From Exceptions – A Different Way Forward

Exception handling is a critical aspect of software development, and C# provides powerful mechanisms to handle and propagate exceptions. However, what if there was an alternative approach that allows us to return exceptions instead of throwing them? Is there a better way than try, catch, throw, handle, maybe rethrow, etc… Can we find an alternative way?

I’ve always found myself dealing with unstructured data as input in the applications I’ve had to build. Whether that’s handling user input, or writing complex digital forensics software to recover deleted information for law enforcement. I wanted to take a crack a solving this with some of the things I had dabbled with regarding implicit conversion operators.

I created a class called TriedEx<T>, that can represent either a value or an exception, providing more control over error handling and control flow. In this blog post, we’ll explore how I use TriedEx<T> and how it helps me with cleaning up my code while offering more failure details than a simple boolean return value for pass or fail. It might not be your cup of tea, but that’s okay! It’s just another perspective that you can include in your travels.

Implicit Operator Usage

Before diving into the specifics of TriedEx<T>, let’s recap the concept of implicit operators. In the previous blog post, we explored how implicit operators allow for seamless conversion between different types. This concept also applies to TriedEx<T>. By defining implicit operators, we can effortlessly convert between TriedEx<T>, T, and Exception types. This flexibility allows us to work with TriedEx<T> instances as if they were the underlying values or exceptions themselves, simplifying our code and improving readability.

And the best part? If we wanted to deal with exceptional cases, we could avoid throwing exceptions altogether.

You can check out this video for more details about implicit operators as well:

YouTube player

Pattern Matching with Match and MatchAsync

A key feature of TriedEx<T> is the ability to perform pattern matching using the Match and MatchAsync methods. These methods enable concise and expressive handling of success and failure cases, based on the status of the TriedEx<T> instance. Let’s look at some examples to see how this works. In the following example, we’ll assume that we have a method called Divide that will be able to handle exceptional cases for us. And before you say, “Wait, I thought we wanted to avoid throwing exceptions!”, I’m just illustrating some of the functionality of TriedEx<T> to start:

TriedEx<int> result = Divide(10, 0);

result.Match(
    success => Console.WriteLine($"Result: {success}"),
    failure => Console.WriteLine($"Error: {failure.Message}")
);

In the above example, the Match method takes two lambda expressions: one for the success case (where the value is available) and one for the failure case (where the exception is present). Depending on the state of the TriedEx<int> instance, the corresponding lambda expression will be executed, allowing us to handle the outcome of the operation gracefully.

If you’d like to be able to return a value after handling the success or error case, there are overrides for that as well:

TriedEx<int> result = Divide(10, 0);

var messageToPrint = result.Match(
    success => $"Result: {success}"),
    failure => $"Error: {failure.Message}")
);

Console.WriteLine(messageToPrint);

Similarly, the MatchAsync method provides the same functionality but allows us to work with asynchronous operations. This is particularly useful when dealing with I/O operations or remote calls that may take some time to complete.

Deconstructor Usage

Another feature of TriedEx<T> is its support for deconstruction. Deconstruction enables us to extract the success status, value, and error from a TriedEx<T> instance in a convenient and readable way. Let’s take a look at an example:

TriedEx<string> result = ProcessInput(userInput);

var (success, value, error) = result;

if (success)
{
    Console.WriteLine($"Processed value: {value}");
}
else
{
    Console.WriteLine($"Error occurred: {error.Message}");
}

In this example, the deconstruction pattern is used to unpack the success status, value, and error from the TriedEx<string> instance. By leveraging the deconstruction syntax, we can access these components and perform the appropriate actions based on the outcome of the operation.

Practical Example: Avoiding User Input Exceptions

Let’s explore a practical scenario where TriedEx<T> can be useful for us when we might usually have exceptional cases: parsing user input. Consider a situation where a user provides input in various formats, and our goal is to parse and process that input. However, we cannot always enforce proper input formatting, and errors can occur during the parsing process. We’d ideally like to avoid throwing exceptions, interrupting the program flow, and potentially causing a slowdown in our application to bubble and catch exceptions through the call stack. So instead of using thrown exceptions to control logical flow, we can leverage TriedEx<T> to handle these formatting errors gracefully.

public TriedEx<int> ParseUserInput(string userInput)
{
    if (int.TryParse(userInput, out int value))
    {
        return value; // Success: Return the parsed value
    }
    else
    {
        return new FormatException("Invalid input"); // Failure: Return the exception
    }
}

// Usage
var userInput = Console.ReadLine();
var result = ParseUserInput(userInput);

result.Match(
    success => Console.WriteLine($"Parsed value: {success}"),
    failure => Console.WriteLine($"Error: {failure.Message}")
);

In this example, the ParseUserInput method attempts to parse the user input as an integer. If the parsing is successful, the parsed value is returned as a TriedEx<int> instance with the success status. Otherwise, if the input cannot be parsed, a FormatException is wrapped in a TriedEx<int> instance and returned as a failure.

By utilizing TriedEx<T>, we can handle formatting errors without throwing exceptions and communicate the error to the caller with more granularity regarding the problem. The Match method allows us to process the parsed value or the exception, depending on the success status, providing a clean and readable approach to error handling. Of course, in very simple cases we could get away with a boolean return type like many of the built-in .NET TryXXX patterns that exist in the framework. However, when we’d like to be more verbose about the errors we’re handing while still avoiding throwing, catching, handling, and sometimes rethrowing, we can instead use TriedEx<T>.

Revisiting The Earlier Example: Divide By Zero Exceptions

Let’s think back to that example from the start where I mentioned this idea of a Divide method that might handle exceptions for us, and then we used some of the features of TriedEx<T> to handle the result. Instead, let’s illustrate how we can avoid the exception altogether, and then go into leveraging the result:

public TriedEx<int> Divide(int dividend, int divisor)
{
    if (divisor == 0)
    {
        return new DivideByZeroException("Cannot divide by zero");
    }

    return dividend / divisor;
}

// Usage
TriedEx<int> result = Divide(10, 5);

if (!result.Success)
{
    Console.WriteLine($"Error occurred: {result.Error.Message}");
}
else
{
    Console.WriteLine($"Result: {result}");
}

In this example, the Divide method attempts to perform integer division. If the division is successful (i.e., the divisor is not zero), the result value is returned as a TriedEx<int> instance with the success status. On the other hand, if the divisor is zero, a DivideByZeroException is wrapped in a TriedEx<int> instance and returned as a failure. No exceptions thrown!

By utilizing implicit operators, we can extract the result value or the exception directly from the TriedEx<int> instance, without explicitly using the Match or MatchAsync methods. Depending on your preferences, you may find that this is more readable or more aligned with how code is written in your codebase, versus the Match or MatchAsync approach.

Conclusion

In this blog post, we explored the multi-type class I created called TriedEx<T> and its ability to return exceptions instead of requiring us to throw them. We covered implicit operator usage, pattern matching with Match and MatchAsync, and deconstructor usage, all of which contribute to the versatility and expressiveness of this TriedEx<T> data type.

By adopting TriedEx<T> in my code, I have been able to gain more control over error handling, improve the readability of my code, and enhance the resilience of my applications. It is particularly valuable in scenarios like parsing user input, where handling formatting errors is essential, or in situations where I ultimately don’t have control over the structure of the incoming data.

Remember, error handling is a critical aspect of software development, and with TriedEx<T>, I now have a useful tool at my disposal. You can try creating your own type like this, use TriedEx<T> if you like what I’ve created, or even leverage something like the very popular OneOf nuget package!

Note: It’s worth mentioning that a variation of TriedEx<T> called TriedNullEx<T> exists, which allows for nullable return types. While not the primary focus of this blog post, TriedNullEx<T> can be valuable in situations where nullable values need to be represented upon successful operations.

author avatar
Nick Cosentino Principal Software Engineering Manager
Principal Software Engineering Manager at Microsoft. Views are my own.

This Post Has 6 Comments

  1. Darin

    On the surface, this seems like just another form of return code, albeit multi valued and with the potential to pass more expressive info about the error via an exception, which is a big plus.

    But one thing I’ve always found incredibly useful with thrown exceptions is the stack trace.

    Does simply newing up exceptions like this still give a meaningfull trace?

    1. Nick Cosentino

      Hey there! No, it does not create a stack trace to just new them up. But in this case, I actually don’t want the stack trace because the Exception object is just being used like a DTO to contain some additional context (most of the time). There are some cases where I use this pattern on caught exceptions, wrap them in this object, and they’ll keep the trace. However, I only log with this pattern when I’m catching an exception that’s meaningful in the context (i.e. truly exceptional behavior) and then bubble it back up in these objects. The primary motivation for the pattern was with parsers where the exceptions were not really exceptional behavior I needed to worry about, but they did offer extra insight about why parsing data would not be successful.

      In short: I still log out exceptional cases where I need traces but I generally bubble them up in this wrapped object instead for better control.

  2. Moroni

    There is probably an error in your last code example:
    if (!exception.Success)
    should be
    if (!result.Success)
    .

    1. Nick Cosentino

      Great catch (no pun intended?) 🙂 I’ve made the update, thanks!!

  3. FvdG

    Great Post, love how you went all-in with this and even allowed for deconstruction and pattern matching.
    How do you handle nested-code exceptions with TriedEx, in comparison to catching them? How does TriedEx bubble up?

    1. Nick Cosentino

      Thank you!

      I have another extension I use called “Safely” which is essentially a try/catch wrapper that returns a TriedEx object for me. So I wrap things with “Safely.GetResultOrException” and then I check the Success property of the result to make decisions about continuing on or not. For me, this cleans up all the try/catch code and allows me to have logical flow with switches/ifs to feel a bit more natural.

      I understand that many people take the perspective that exceptional behavior should break the app early (and what quicker way than allowing it to blow EVERYTHING up immediately). But I have found many times the context of what is exceptional can change based on who the caller is, so wrapping things with TriedEx allows me to make a decision later about if it’s truly exceptional!

      It’s not a fit for everyone 🙂 But I find it much more clear this way.

Leave a Reply