Mastering Flow Control with Either Monad in C#
Ditch the Drama, Embrace the Monad!
Flow control
Imagine a language where exception handling is the only flow control. You know what? Such a language exists! Welcome Hurl, the Exceptional language. You could proudly write code to calculate factorial and impress your co-workers:
let factorial = func(n) {
try {
hurl n == 0;
} catch (true) {
hurl 1;
} catch (false) {
let next = 1;
try {
factorial(n - 1);
} catch into next;
hurl n * next;
};
};
try {
factorial(10);
} catch as x {
println("factorial(10) = ", x);
};
Jokes aside, using exceptions for flow control is a terrible idea. Here’s why:
Performance Concerns: Throwing and catching exceptions is significantly slower than using standard control flow mechanisms. Exceptions should be reserved for truly exceptional conditions, not for regular control flow logic. High throw rates can adversely impact application performance. This recommendation is highlighted in the ASP.NET Core best practices, which state that exceptions should not be used to control normal program flow, especially in performance-critical paths.
Code Complexity: Using exceptions for flow control can make code harder to read and maintain. It obscures the normal flow of the program and can lead to error-prone and difficult-to-debug code. Microsoft's guidelines for exception handling in .NET advise against using exceptions for expected conditions or regular control flow, instead suggesting that exceptions should only be used for truly exceptional circumstances.
For more details, you can refer to the following resources from Microsoft:
The recommended approach is to use the Tester-Doer or Try-Parse pattern to avoid the aforementioned problems. But there is another, more elegant approach you can learn from functional programming techniques. It will make your design more flexible and composable.
What is a Monad?
A monad is just a monoid in the category of endofunctors, what's the problem?
With that joke, functional practitioners welcome newbies into the world of FP. In reality, you don't need to know all the nitty-gritty theory behind this concept to utilize it in practice. In simple terms, a monad is a design pattern used in functional programming to handle computations that include side effects, such as state management, I/O, or exception handling, in a purely functional way. It provides a way to chain operations together while maintaining a clear structure and encapsulating side effects.
You can think of it as a container that holds a value along with a context. For instance, the context could be a possibility of failure, an optional value, or a sequence of computations. For a better understanding of the concept I highly advise Mark Seemann's series of articles.
Either Monad
The Either
monad is a functional programming construct that represents computations that can result in one of two values: a success (often called Right
) or a failure (often called Left
). It is commonly used for error handling, where Left
contains error information and Right
contains the successful result.
This is the canonical definition. Applying it in the context of an operation that could succeed or fail, Left
and Right
become Ok
and Error
. For example in F#
Either monad represented as a built-in Result
type:
type Result<'T,'TError> =
| Ok of 'T
| Error of 'TError
F# is a beautiful functional language that makes our lives as developers much easier and coding a joyful experience. But let's look at how we can implement the same approach in C# with the latest language features.
I use following code to define Result
type:
public readonly record struct Result<T>(T Value, bool IsSuccess, string ErrorMessage)
{
public static Result<T> Ok(T value) => new Result<T>(value, true, string.Empty);
public static Result<T> Fail(string message) => new Result<T>(default, false, message ?? string.Empty);
public Result<U> Bind<U>(Func<T, Result<U>> func) =>
IsSuccess ? func(Value) : Result<U>.Fail(ErrorMessage);
public Result<U> Map<U>(Func<T, U> func) =>
IsSuccess ? Result<U>.Ok(func(Value)) : Result<U>.Fail(ErrorMessage);
public static implicit operator Result<T>(T value) => Ok(value);
}
Explanation:
public readonly record struct
combines the benefits of structs (value type), records (value-based equality and immutability), and the readonly modifier (ensuring immutability).It is ideal for creating small, immutable, value-based data types with minimal syntax and maximum efficiency.
Exception-driven example
To put it in practice let's use a task where we need to read the file and count amount of words in it. Production-ready code that uses exception-based approach will look similar to this:
public string ReadFile(string path)
{
if (string.IsNullOrEmpty(path))
throw new ArgumentException("Path cannot be null or empty", nameof(path));
if (!File.Exists(path))
throw new FileNotFoundException($"The file at {path} was not found.");
try
{
return File.ReadAllText(path);
}
catch (IOException ex)
{
throw new ApplicationException("An error occurred while reading the file.", ex);
}
}
public int CountWords(string content)
{
if (string.IsNullOrEmpty(content))
throw new ArgumentException("Content cannot be null or empty");
return content.Split(new[] { ' ', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries).Length;
}
public void ProcessFile()
{
try
{
string fileContent = ReadFile("file.txt");
int wordCount = CountWords(fileContent);
Console.WriteLine("File read successfully.");
Console.WriteLine($"Word count: {wordCount}");
}
catch (ArgumentException ex)
{
Console.WriteLine($"Invalid argument: {ex.Message}");
}
catch (FileNotFoundException ex)
{
Console.WriteLine($"File not found: {ex.Message}");
}
catch (ApplicationException ex)
{
Console.WriteLine($"Application error: {ex.Message}");
}
catch (Exception ex)
{
Console.WriteLine($"Unexpected error: {ex.Message}");
}
}
public static void Main(string[] args)
{
var program = new Program();
program.ProcessFile();
}
Explanation:
Error Handling: Uses try-catch blocks to handle different exceptions, including
ApplicationException
and generalException
.Flow Control: The flow control involves multiple try-catch blocks which can make the code harder to follow.
Readability: Error handling logic is interspersed with main logic.
Either monad example
On the other hand, code using newly created Result
type could look similar to this:
public Result<string> ReadFile(string path)
{
if (string.IsNullOrEmpty(path))
return Result<string>.Fail("Path cannot be null or empty");
if (!File.Exists(path))
return Result<string>.Fail($"The file at {path} was not found.");
try
{
return File.ReadAllText(path);
}
catch (IOException ex)
{
return Result<string>.Fail($"An error occurred while reading the file: {ex.Message}");
}
catch (Exception ex)
{
return Result<string>.Fail($"Unexpected error: {ex.Message}");
}
}
public Result<int> CountWords(string content)
{
if (string.IsNullOrEmpty(content))
return Result<int>.Fail("Content cannot be null or empty");
try
{
int wordCount = content.Split(new[] { ' ', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries).Length;
return wordCount; // Implicit conversion from int to Result<int>
}
catch (Exception ex)
{
return Result<int>.Fail($"Unexpected error: {ex.Message}");
}
}
public void ProcessFile()
{
Result<int> result = ReadFile("file.txt").Bind(CountWords);
switch (result)
{
case { IsSuccess: true, Value: var wordCount }:
Console.WriteLine("File read successfully.");
Console.WriteLine($"Word count: {wordCount}");
break;
case { IsSuccess: false, ErrorMessage: var errorMessage }:
Console.WriteLine($"Error processing file: {errorMessage}");
break;
}
}
public static void Main(string[] args)
{
var program = new Program();
program.ProcessFile();
}
Explanation
Error Handling: Centralized and consistent error handling using the
IsSuccess
property andErrorMessage
. Unexpected errors are captured and handled within the methods.Flow Control: The flow control is more explicit and easier to follow with the
Bind
method chaining followed by pattern matching of success and failure cases.Readability: Clear separation between main logic and error handling.
Summary
Using monads for flow control leads to cleaner and more maintainable code by making the flow of errors and optional values explicit. This is especially beneficial in a functional programming context, where composability and immutability are key principles.
In .NET, you can leverage libraries like LanguageExt
to work with monads, gaining the benefits of functional programming while still using the .NET ecosystem.