Hey there, fellow coders! Have you ever written some C# code, thought everything was working perfectly, and then... BAM! 💥 A wild NullReferenceException appears out of nowhere? Or maybe you've found
yourself writing endless if (x != null) checks until your fingers hurt?
Programmer Joke: Why do C# developers never get lost? Because they always check if the directions are null before following them! 😄
If you've been nodding along, you're not alone. These are common headaches that even experienced developers face daily - even with the latest version of C# and nullable-reference types. What if I told you there's a better way to handle these situations that can make your code cleaner, safer, and easier to understand? In this post, we'll explore two super helpful concepts borrowed from functional programming that can transform the way you write C# code and types. Don't worry if you're new to programming or haven't heard of functional programming before—I'll explain everything in simple terms with plenty of examples—there will even be a few bad jokes along the way!
Let's start by understanding the everyday coding problems these patterns help solve:
Did you know that the concept of null is considered a "billion-dollar mistake" by its own inventor, Sir Tony Hoare? That's because null references have caused countless bugs, system crashes, and
security
problems over the decades.
Think about this common scenario:
// This method might return null, but there's no way to tell from the signature
public User GetUserById(int id)
{
// What if user doesn't exist in the database?
return _userRepository.Find(id);
}
// Somewhere else in your code
var user = GetUserById(123);
var email = user.Email; // BOOM! 💥 NullReferenceException if user is nullAs a beginner (or, to be fair, even a senior software engineer with, say 30+ years experience... not that I am thinking of myself!), you might not even realise that GetUserById could return null
unless you dig into the implementation or documentation (if it exists, which it probably doesn't and, if it does, can you trust it is up-to-date?). There's nothing in the method signature that warns
you
about this possibility!
Another common pattern that causes headaches is using exceptions for handling expected situations:
public int Divide(int a, int b)
{
if (b == 0)
{
throw new DivideByZeroException(); // This is an expected case!
}
return a / b;
}
// Now the caller has to use try/catch for something that's not really "exceptional"
try
{
var result = Divide(10, userInput);
// Process result...
}
catch (DivideByZeroException)
{
// Handle division by zero...
}Exceptions should be for exceptional situations (the clue is in the name 😉), not for normal business logic! Using them for regular control flow makes your code harder to follow, less efficient, and
more error-prone. Whilst C# has the goto keyword, it is considered a very bad practice to use it, yet, somehow, using exceptions to effectively achieve the same effect is not always considered as
anything other than normal.
Programmer Joke: Why do programmers always mix up Christmas and Halloween? Because Oct 31 = Dec 25! 🎃🎄
Is there a solution? Yes! To address these problems, we can use two powerful tool paradigms from functional programming:
- An object that explicitly represents a value that might or might not exist: Option
- An object that represents an operation that might succeed or fail: Result<TSuccess, TError>
Let's explore how these types can make your coding life much easier!
The type (sometimes called Maybe<T> in other languages such as Haskell - did you know that not only is Haskell the original functional language but it gives us the concept of *currying——*both base
on the inventors name: Haskell Curry? No! This is not a joke!) explicitly tells everyone that a value might be missing. It has two possible states: Option<T>
Some - Contains an actual value
None - Represents the absence of a value (similar to null, but explicit)
Instead of returning null when a user isn't found, we explicitly state that maybe there will be a user: using the Option<User> in the methods signature (specifically, the return type):
public Option<User> FindUserById(int id)
{
var user = _repository.GetUserById(id);
// If the user exists, wrap it in Some
// If user is null, return None
return user != null
? Option.Some(user)
: Option.None<User>();
// Or, more simply, using the "ToOption" extension method:
// return user.ToOption();
}Now, when someone calls your method, they can immediately see from the return type that they might not get a user back. If you've read any of my previous posts, you will know that, where applicable, I
am proud to be lazy. Using the Option<T> is a classic example of this laziness: the compiler will force me to handle both possibilities! I do not have to remember: the compiler will remind me with
a failing build! Could it be simpler / more reliable?
// Consuming the option
var userOption = FindUserById(123);
// Using pattern matching - handles both cases elegantly
string greeting = userOption.Match(
user => $"Hello, {user.Name}!", // This runs if user exists
() => "User not found" // This runs if user doesn't exist
);
// Or, if you prefer a more step-by-step (procedural) approach to help you understand the concept:
if (userOption.IsSome())
{
// We've checked the user exists, so this is safe - despite the "OrThrow"
User user = userOption.OrThrow();
Console.WriteLine($"Found user: {user.Name}");
}
else
{
Console.WriteLine("User not found");
}When I started with functional programming, I struggled with this concept but, over time, I came up with this: Think of Option<T> like a gift box 🎁: it might contain a present (Some), or it might be
empty (None). But unlike a present, you always know you need to check!
Where the Option<T> really shines is with its transformation methods. Let's say you want to find a user's profile picture:
// The old way with null checks
User user = GetUserById(123);
string imageUrl;
if (user != null && user.Profile != null && user.Profile.ImageUrl != null)
{
imageUrl = user.Profile.ImageUrl;
}
else
{
imageUrl = "default-profile.jpg";
}
// The new way - much cleaner!
Option<User> userOption = FindUserById(123);
// Transform the user object into an ImageUrl pointing to their profile image URL if they exist
Option<string> profileImageOption = userOption
.Map(user => user.Profile?.ImageUrl);
// Get the image URL or fall back to default
string imageUrl = profileImageOption.OrElse("default-profile.jpg");The method is like saying: "If there's a value inside, transform it using the Map function. If not, just stay empty."
The above new way is much cleaner and easier to understand—but there is a bonus to be had for you when you reach the end of this post: you will know that even this example can be both condensed and
made clearer at the same time! The Option type has a plethora of map, bind, match (to name but 3) extension methods that can be used to make the whole pseudo-method a one-liner! Ooh, that
feels like a great seq-way into the next section!
Whilst using the basic Option<T> (and Result<T> etc. objects), the real power comes when you chain multiple operations that might fail:
// Without Option - the "pyramid of doom":
User user = GetUserById(userId);
if (user != null)
{
var account = GetAccountByUser(user);
if (account != null)
{
var settings = GetSettingsByAccount(account);
if (settings != null)
{
// Finally use settings
ApplySettings(settings);
}
else
{
ShowError("Settings not found");
}
}
else
{
ShowError("Account not found");
}
}
else
{
ShowError("User not found");
}
// With Option - clean, simple and beautiful:
FindUserById(userId)
.Bind(user => GetAccountByUser(user))
.Bind(account => GetSettingsByAccount(account))
.Match(
settings => ApplySettings(settings),
() => ShowError("User, account or setting were not found")
);The method is perfect for chaining operations that themselves return an Option<T>. It's like saying: "If I have a value, run this function that might or might not return another value." And,
remember, if you don't handle both scenarios, the compiler will make sure you are reminded with a failing build—no brainpower needed / used!
You could stop here. Your code would be much more explicit, easier to understand, and bug-free! OK, I admit that using Option<T> on its own will not guarantee a lack of bugs but, it will be much
harder to introduce a bug in the code that uses Option<T>!
Ready to take things to the next level? Sure? Excellent!
While Option<T> handles the case of missing values, Result<TSuccess, TError> goes a step further by allowing you to return specific error information when something goes wrong.
Result<TSuccess, TError> has two possible states:
OkContains a success valueErrorContains a specific error reason
// Instead of throwing an exception for an expected case:
public Result<int, string> Divide(int a, int b)
{
if (b == 0)
{
// Return an error with a message
return new Result<int, string>.Error("Cannot divide by zero");
}
// Return successful result
return new Result<int, string>.Ok(a / b);
}Now when someone calls your method, they know it might fail, and they have to handle both possibilities (if they don't know / remember, they can lean on the compiler and rest assured it will remind them!):
// Using the result returned by the Divide method:
var divisionResult = Divide(10, userInput);
// Pattern matching makes it easy to handle both cases
string message = divisionResult.Match(
result => $"The result is {result}",
error => $"Error: {error}"
);
// Or you can check each case separately if you want even more explicit control. I won't judge you if you do, I'll just say that the above is my preference
if (divisionResult is Result<int, string>.Ok ok)
{
int value = ok.Value;
Console.WriteLine($"Success: {value}");
}
else if (divisionResult is Result<int, string>.Error err)
{
string reason = err.Reason;
Console.WriteLine($"Failed: {reason}");
}Think of Result like a package delivery 📦—it either contains what you ordered (Ok) or a note explaining why your order couldn't be fulfilled (Error).
Like Option<T>, Result<TSuccess, TError> really shines when working with chains of operations:
// Example: User registration process with multiple steps
// Step 1: Validate the request
Result<UserDto, string> ValidateUser(UserRegistrationRequest request)
{
if (string.IsNullOrEmpty(request.Email))
{
return new Result<UserDto, string>.Error("Email is required");
}
if (string.IsNullOrEmpty(request.Password) || request.Password.Length < 8)
{
return new Result<UserDto, string>.Error("Password must be at least 8 characters");
}
// More validation...
// If everything is valid, return success
return new Result<UserDto, string>.Ok(new UserDto(request));
}
// Step 2: Save to database
Result<User, string> SaveUser(UserDto userDto)
{
try
{
var user = _repository.Save(userDto);
return new Result<User, string>.Ok(user);
}
catch (Exception ex)
{
return new Result<User, string>.Error($"Database error: {ex.Message}");
}
}
// Step 3: Send welcome email
Result<EmailResult, string> SendWelcomeEmail(User user)
{
try
{
var result = _emailService.SendWelcome(user.Email);
return new Result<EmailResult, string>.Ok(result);
}
catch (Exception ex)
{
return new Result<EmailResult, string>.Error($"Email error: {ex.Message}");
}
}
// Chain all operations together - explicit and MUCH shorter but achieves the same thing!
var registrationResult = ValidateUser(request)
.Bind(userDto => SaveUser(userDto))
.Bind(user => SendWelcomeEmail(user));
// Handle the final result
registrationResult.Match(
emailResult => DisplaySuccess($"Welcome email sent to {emailResult.RecipientEmail}!"),
error => DisplayError($"Registration failed: {error}")
);The 3-step approach keeps the happy path clean while properly handling errors at each step. The first error in the chain explicitly short-circuits the rest of the operations—like knocking over the first domino 🎮.
The chain example uses 40+ lines to achieve the same result (pun intended!) as the chain example (and, the 6 lines can actually be condensed even more without losing the clarity of the code - I
will leave this as an exercise for you to complete!)
Programmer Joke: Why do functional programmers prefer Result types? Because they like their errors to be handled with class! 😁
So far, we've moved forward in leaps and bounds. Our code is much more expressive, potentially MASSIVELY shorter, and almost impossible to mess up! But (isn't there always one?), we've ignored unexpected exceptional outcomes: Internal Server Errors, Connection Timeouts, even legacy code that throws rather than return a Bad Request. The time has come, the walrus said to accept that errors occur and handle them with the same clean approach as elsewhere.
Working with existing code that throws exceptions can be challenging. That's where the utility Try comes in—it provides a bridge between the exception-based world and our nice, clean Result—based
approach.
The Try class is quite simple but extremely powerful. It contains the following two methods:
// This converts any function that might throw an exception into one that returns a Result
public static Result<T, Exception> Run<T>(Func<T> func)
{
try
{
return new Result<T, Exception>.Ok(func());
}
catch(Exception ex)
{
return new Result<T, Exception>.Error(ex);
}
}
// There's also an async version for Task-based operations
public static async Task<Result<T, Exception>> RunAsync<T>(Func<Task<T>> func)
{
try
{
return new Result<T, Exception>.Ok(await func());
}
catch(Exception ex)
{
return new Result<T, Exception>.Error(ex);
}
}Think of Try as a stunt double for your code 🎬 - it takes the fall (the exception) so your main code doesn't have to crash!
Let's see how makes working with potentially throwing code much easier: Try
// Example: Parsing XML that might be invalid
public Result<XmlDocument, Exception> ParseXml(string xml)
{
return Try.Run(() => {
var doc = new XmlDocument();
doc.LoadXml(xml); // This might throw exceptions
return doc;
});
}
// Usage is clean and explicit (and can be made even shorter than in this example):
var result = ParseXml(xmlString);
result.Match(
doc => ProcessXmlDocument(doc),
ex => Console.WriteLine($"Failed to parse XML: {ex.Message}")
);Or let's say you're working with the file system:
// Read a file that might not exist or the user might not have permission to access
public Result<string, Exception> ReadTextFile(string path)
{
return Try.Run(() => File.ReadAllText(path));
}
// Usage
var contentResult = ReadTextFile("config.json");
contentResult.Match(
content => Console.WriteLine($"File contains: {content}"),
ex => Console.WriteLine($"Couldn't read file: {ex.Message}")
);And the async version works great for API calls or database operations:
public Task<Result<ApiResponse, Exception>> CallApiAsync(string url)
{
return Try.RunAsync(async () => {
using var client = new HttpClient();
var response = await client.GetAsync(url);
response.EnsureSuccessStatusCode(); // This throws on non-success
var content = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<ApiResponse>(content);
});
}
// Usage
var apiResult = await CallApiAsync("https://api.example.com/data");
await apiResult.MatchAsync(
response => DisplayData(response),
ex => LogError($"API call failed: {ex.Message}")
);The beauty of Try is that it lets you work with legacy code or external libraries that throw exceptions, without compromising your clean Result-based approach!
OK, whilst what follows is still pseudo-code, let's look at some everyday coding scenarios that are very close to real-world examples, and how these patterns improve them:
// Traditional approach
public User GetUserByUsername(string username)
{
var user = _dbContext.Users.FirstOrDefault(u => u.Username == username);
return user; // Might be null! Surprise! 😱
}
// With Option - no surprises!
public Option<User> GetUserByUsername(string username)
{
var user = _dbContext.Users.FirstOrDefault(u => u.Username == username);
return user.ToOption(); // Extension method to convert to Option
}
// With Result - even more explicit
public Result<User, string> GetUserByUsername(string username)
{
var user = _dbContext.Users.FirstOrDefault(u => u.Username == username);
if (user == null)
{
return new Result<User, string>.Error($"No user found with username '{username}'");
}
return new Result<User, string>.Ok(user);
}// Traditional approach with lots of if/else and try/catch
[HttpGet("users/{id}")]
public IActionResult GetUser(int id)
{
try
{
var user = _userService.GetById(id);
if (user == null)
{
return NotFound("User not found");
}
return Ok(user);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting user");
return StatusCode(500, "An error occurred");
}
}
// With Result - cleaner and more explicit
[HttpGet("users/{id}")]
public IActionResult GetUser(int id)
{
return _userService.GetById(id)
.Match<IActionResult>(
user => Ok(user),
error => error switch {
"NotFound" => NotFound("User not found"),
_ => StatusCode(500, "An error occurred")
}
);
}
// The service method
public Result<User, string> GetById(int id)
{
return Try.Run(() => {
var user = _repository.GetById(id);
if (user == null)
{
return new Result<User, string>.Error("NotFound");
}
return new Result<User, string>.Ok(user);
}).MapFailure(ex => $"DatabaseError: {ex.Message}");
}// Traditional approach
public bool TryTransferMoney(Account from, Account to, decimal amount, out string error)
{
error = null;
if (amount <= 0)
{
error = "Amount must be positive";
return false;
}
if (from.Balance < amount)
{
error = "Insufficient funds";
return false;
}
try
{
from.Debit(amount);
to.Credit(amount);
_repository.SaveChanges();
return true;
}
catch (Exception ex)
{
error = $"Transfer failed: {ex.Message}";
return false;
}
}
// With Result - clean, explicit, and chainable!
public Result<Transfer, string> TransferMoney(Account from, Account to, decimal amount)
{
if (amount <= 0)
{
return new Result<Transfer, string>.Error("Amount must be positive");
}
if (from.Balance < amount)
{
return new Result<Transfer, string>.Error("Insufficient funds");
}
return Try.Run(() => {
from.Debit(amount);
to.Credit(amount);
var transfer = new Transfer(from, to, amount, DateTime.UtcNow);
_repository.SaveTransfer(transfer);
return transfer;
})
.MapFailure(ex => $"Transfer failed: {ex.Message}");
}Programmer Joke: Why did the functional programmer get kicked out of the bank? They kept trying to deposit a
Result<Money, String>instead of actual cash! 💸
If you're just starting out with programming or are a junior developer, adopting these patterns can help you:
- Write more predictable code 🔮: By making null values and errors explicit, you'll have fewer unexpected bugs.
- Create self-documenting APIs 📝: The method signature itself tells consumers what to expect, reducing the need for detailed documentation.
- Improve error handling 🧯: You'll be forced to consider error cases upfront, not as an afterthought when things break in production.
- Reduce defensive coding 🛡️: No more checking for null everywhere or wrapping everything in try/catch blocks.
- Think more clearly about your domain 🧠: By modeling possible outcomes explicitly, you'll gain a deeper understanding of your problem domain.
- Avoid the dreaded NullReferenceException 💥: One of the most common runtime errors will become much rarer in your code.
- Make your code more testable 🧪: Clear inputs and outputs make unit testing easier and more comprehensive.
Ready to use these patterns in your own code? Here are some beginner-friendly tips:
- Start small 🌱: Begin by using Option for methods that might return null, or Result for methods that might fail.
- Be consistent 📏: Once you adopt these patterns, use them consistently across your codebase.
- Use the provided extension methods 🧩: The included extension methods like
Map,Bind, andMatchand make working with these types much easier. - Create helper methods 🔧: If you find yourself writing the same pattern multiple times, create helper methods to reduce duplication.
- Add extensions when needed 🔌: If you find a common operation missing, create your own extension methods.
- Use pattern matching 🧩: C#'s pattern matching makes working with discriminated unions like Option and Result more elegant.
If you're excited about these patterns but need to convince your team, here are some talking points:
- Reduced bugs: "These patterns can help us avoid null reference exceptions and make our error handling more consistent."
- Self-documenting code: "Our method signatures will clearly show what can go wrong, making our code easier to understand."
- Easier maintenance: "We'll spend less time debugging null reference issues (or other exceptions) and more time adding features."
- Better onboarding: "New team members will immediately understand the possible outcomes of our methods."
- Progressive adoption: "We can start using these patterns in new code without having to rewrite everything at once."
The Option<T> and Result<TSuccess, TError> types, along with the handy Try class, bring powerful functional programming concepts to C#, making your code more robust, explicit, and maintainable.
By modeling the absence of values and the
possibility of failure directly in your types, you create APIs that are harder to misuse and easier to understand.
Next time you find yourself reaching for a null or an exception for control flow, consider whether Option or Result might lead to cleaner, more maintainable code. Your future self (and your
teammates)
will thank you!
Remember, even experienced developers make mistakes with nulls and exception handling. Using these patterns doesn't mean you're not a "real programmer"—quite the opposite! It shows you care about
writing code that's clear, correct, and maintainable.
WOW! This has been a long ride! If you've stayed with me until now—THANK YOU for staying with me! - you deserve a bonus: the functional types in this post, the extension methods mentioned (and many not mentioned), additional examples and a few thousand lines of test code are all available on GitHub. Whilst I work for Capgemini, the code does not come with a guarantee from Capgemini. It does, however, come with a guarantee from me: you can fork the code, extend it as you see fit (and even push back to the original repository!)—all whilst knowing that every effort (and many, many hours of my personal time) has been spent on this project. I use this code every day for my private projects (I can neither confirm nor deny any professional usage). The repository is relatively new, but the code has been tested in anger and even has a NuGet package for easy inclusion into your projects.
Want to help me develop this project further? Please join my Patreon...😉 OK, that is not serious! I have a fake organisation on GitHub, and the relevant repository is: https://github.com/astar-development/astar-dev-functional-extensions - take a look, contribute or give feedback.
Thank you for your perseverance!
Final Programmer Joke: A programmer walks into a bar and orders 1.0000001 beers. The bartender says, "I'll round that up to 2." The programmer responds, "That's why I always use Option instead of double!" 🍻