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:
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.