Better error handling in C# with Result types
Eesaa Philips
Posted on June 10, 2023
The Problem
Exceptions are slow, cumbersome, and often result in unexpected behavior. Even the official Microsoft docs tell you to limit your use of exceptions.
Most of the time, you want to handle both the success and failure cases without allowing the exception to propagate. You might be wondering "if I won't use exceptions, how can I tell the caller function that something went wrong?". This is where the result type comes in.
The Result type
When our function needs to represent two states: a happy path and a failure path, we can model it with a generic type Result <T, E>
where T represents the value and E represents the error. A function that gets a user could look like this:
public async Result<User, string> FindByEmail(string email) {
User user = await context.Users.FirstOrDefaultAsync(
u => EF.Functions.Like(u.Email, $"%{email}%"));
if(user is null) {
return "No user found";
}
return user;
}
You would call the function like this:
[HttpGet("{email}")]
public async Task<ActionResult<User>> GetByEmail(string email)
{
if(string.IsNullOrEmpty(email)) {
return BadRequest("email cannot be empty");
}
Result<User, string> result = await FindByEmail(email);
return result.Match<ActionResult<User>>(
user => Ok(user),
_ => NotFound());
}
If you don't want to return strings for errors, but instead a different type, you can define those classes/structs and return them or use the existing Exception types for your errors. Returning exceptions is fine, throwing them is what's costly.
Here is the code for the result type:
public readonly struct Result<T, E> {
private readonly bool _success;
public readonly T Value;
public readonly E Error;
private Result(T v, E e, bool success)
{
Value = v;
Error = e;
_success = success;
}
public bool IsOk => _success;
public static Result<T, E> Ok(T v)
{
return new(v, default(E), true);
}
public static Result<T, E> Err(E e)
{
return new(default(T), e, false);
}
public static implicit operator Result<T, E>(T v) => new(v, default(E), true);
public static implicit operator Result<T, E>(E e) => new(default(T), e, false);
public R Match<R>(
Func<T, R> success,
Func<E, R> failure) =>
_success ? success(Value) : failure(Error);
}
The implicit operators allow you to return a value or error directly. For example, return "err"
can be used instead of return new Result<User, string>.Err("err")
. It will automatically convert it to a result type for you.
Performance comparison
I wrote a benchmark that compares returning a result with throwing an exception. The benchmark compares the identical functions if they fail 0% of the time, 30% of the time, 50% of time, and 100% of the time.
Here are the results:
What we care about here is the mean. You can see that returning the result directly and matching outperforms exception handling as exceptions get thrown more often.
In reality, your function will probably not throw exceptions 50% of the time but this is just a benchmark to illustrate how slow exceptions can be if used often.
This is the benchmark code:
[Params(0, 30, 50, 100)]
public int failPercent;
[Benchmark]
public int[] result() {
int[] res = new int[100];
for(int i = 0; i < 100; i++) {
res[i] = getNum_Res(i < failPercent).Match<int>(
n => n + 10,
err => 0);
}
return res;
}
[Benchmark]
public int[] exception() {
int[] res = new int[100];
for(int i = 0; i < 100; i++) {
try {
res[i] = getNum_Exception(i < failPercent) + 10;
}catch(Exception) {
res[i] = 0;
}
}
return res;
}
public Result<int, string> getNum_Res(bool fail) {
if(fail) {
return "fail";
}
return 1;
}
public int getNum_Exception(bool fail) {
if(fail) {
throw new Exception("fail");
}
return 1;
}
Closing Thoughts
Throwing less exceptions means writing less lines of code, and handling errors more efficiently. Remember, "The best code, is no code at all". Have a good day :)
Posted on June 10, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 26, 2024