Discriminated Unions in C# — An Unexceptional Love Story
It’s no secret that programmers like to think about happy paths, and sometimes happy paths only. After all that’s the interesting part of the work, and in most cases reflects well-defined business logic. However, it is the error cases that result in service disruptions, headaches and mid-night support calls. Figuring out ways to handle error cases better and as comprehensively as possible is a significant part of what software engineering is about. For a recent project at Kabbage, we have experimented using Discriminated Union types in C# to improve error handling in our code base.
Errors as Exceptions
Traditionally, many error cases are represented as an
Exception in the Object-Oriented programming model, which is generally fine if the error is indeed "exceptional", namely:
- doesn’t happen very often;
- is difficult to predict at the time of coding.
However, if the error cases are easy to foresee, or even come “by design”, this model would easily be abused and cause a number of issues. The most obvious one is that it doesn’t require explicit handling, because the compiler has no knowledge of where exceptions could be thrown at compile time, you end up completely on your own to remember to visit all the sad paths. Again, not our favorite thing to do as programmers.
Once exceptions are not handled, unexpected things will happen, which ranges from displaying mysterious stack-trace with sensitive information to clueless customers, to withholding resources that were supposed to be released in the normal flow.
A common solution for this is to create a “safety net”
catch block at the top level of the call stack, which prevents the process from completely crashing, but is far from being an elegant solution. For example, throwing exceptions and catching them comes with a cost, especially if the method call has a deep call stack, gets frequently invoked, and contains rich metadata about the error. Benchmark shows throwing errors in .NET can be hundreds of times slower than simply returning an error code with value in the normal flow [source].
In contrast, functional programming normally demands explicit error handling at compile time, and integrates the error cases along with the happy path in the code flow.
At Kabbage we try to strive for a better way to handle errors, however it is not realistic to move all of our C# code bases to a functional programming language. Therefore we came up with a solution to enforce explicit error handling with special C# types that mimics the
Discriminated Union type in the functional world.
What the Functional Way Looks Like
In a functional programming environment, if a function is expected to have an either successful or error outcome, you can make it return a union type defined as
type TReturn = TSuccess of successValue | TFailure of failureValue
Then if you want to use the
TSuccess case value, you will need to untangle the type and handle both cases with the
let process returnedValue:
match returnedValue with
| TSuccess success -> ...
| TError error -> ...
The type system enforces explicit error handling here by utilizing assignability rules, you won’t be able to compile if you don’t handle every single error cases.
Implementation in C#
Given union type is not yet supported in C#, we have to make our own. The good news is, we don’t have to be comprehensive in our implementation, as in most cases the union type we use will have just 2 members (the success case and the error case), maybe 3 occasionally. What we started with was:
We created the implicit type conversion to make assigning to this type more concise. In C#, the compiler can perform implicit type conversion so you can declare variables as
var while keep the type checking working. We also implemented the
IEquatable interface so that two union type variables can be directly compared.
With this implementation, we can model the problem as follows in C#:
It is noticeable that we can directly return
happyResult instead of
new Union<TSuccess, TError>(happyResult), thanks to the implicit type conversion we added.
More Friendly Union Type
Now we have the basic union type implementation, we have to consider how to integrate that with the existing code base. When we started doing so, the first issue we found out is that the code can quickly become messy if several such methods need to be chained together. You could end up with something like this:
Here because generating
success2 requires access to the happy case value for
result1, we have to use the nested
Match, which seems dreadful and hard to read.
By observing this pattern, we found that the two error types
TError2 could probably be combined together to a unified
TError, as most errors have very similar data structure: an error message, an error type, maybe some contextual data. We may as well combine the error handler functions for
TError2, so a unified
Handler(TError e) will handle them both. Then it would become:
A little better, but still with the nested structure.
What we are trying to achieve here is also sometimes called “Railway Oriented Programming”. The high-level concept is we want the code to only have two flows: the happy flow, and the error flow. So ideally, we want the code to “look” like it has two flows as well, namely we want the happy path to converge into one code block, and the error flow to converge into the other.
We already have a unified error type and the handler method, all we need to do is to add a few new operators to make the code “look the look”. The
Map operator and
FlatMap operator that are common in functional programming are added for exactly this. They are defined as below:
Either type can be viewed as a specialized union type with two cases: Left and Right. Because of the fixed number of cases, we can perform some extra actions on it. Both the
FlatMap operators share a common characteristic: it takes an
Either type variable, does something with it, and returns another
Either type. This feature enables us to write "LINQ" style code for our original problem:
With the addition, the code is much cleaner now.
We started the project with mostly the types and operators mentioned in this post, and gradually added more helpers as we expand the scope of adoption, which include:
Do: takes the Left case value, does some work and returns the value as is. Useful for side effects;
FlatMapOther: same with
FlatMapbut for the Right case;
We also added integration with the asynchronous types in C#, so the same code style can work for both synchronous and asynchronous functions.
In general, after we rolled out the types in several of our projects, we started to see smaller function bodies and a generally cleaner “composition” code style. Once we model the possible error cases with
Either types, the changes are guaranteed to "bubble up" to all the callers so that explicit error handling is required.
Exception types are still very useful in the correct use cases, but we are glad this experiment allows us to have a good middle ground between the Object-Oriented code style and the functional programming error handling model.