Skip to content

Instantly share code, notes, and snippets.

@zhengbli

zhengbli/blog.md Secret

Created December 8, 2019 04:24
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save zhengbli/76c36f875efc51d3421f99d3d116ad2c to your computer and use it in GitHub Desktop.
Save zhengbli/76c36f875efc51d3421f99d3d116ad2c to your computer and use it in GitHub Desktop.

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 logics. However, it is the error cases that result in service disruptions, headache 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 make error handling better in our code base.

Errors as Exceptions

Traditionally, many error cases are represented as Exceptions 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 get unhandled, unexpected things will happen, which ranges from displaying mysterious stacktrace 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 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 https://gist.github.com/6563b969f809a3a6892e84983930c73c

Then if you want to use the TSuccess case value, you will need to untangle the type and handle both cases with the match operator: https://gist.github.com/6b5e685bbd9859c0ed638cfe08488f5e

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:

https://gist.github.com/2a5a2add99028e78ee59720cca5c0e05

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.

With this implementation, we can model the problem as something like in C#:

https://gist.github.com/deea36b8fd17a471f04b542f7b80ea02

It is noticeable that we can directly return happyResult instead of new Union<TSuccess, TError>(happyResult), thanks for 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:

https://gist.github.com/73acecfd19fb3b442ed277890ba63fe0

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 TError1 and 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 TError1 and TError2, so a unified Handler(TError e) will handle them both. Then it would become:

https://gist.github.com/53559db5a04565e1b8a475cc0f7a0e51

A little better, but still with the nested structure.

What wer 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 Either type, Map operator and FlatMap operator that are common in functional programming are added for exactly this. They are defined as below:

https://gist.github.com/6652e1c6d8e10e271e5083918cf60e1a

The Either type can be viewed as a specialized union type with two cases: Left and Right. Because of the fixed number of case, we can perform some extra actions on it. Both the Map and FlatMap operator share a common characteristic: it takes an Either type variable, does something with it, and return another Either type. This feature enables us to write "LINQ" style code for our original problem:

https://gist.github.com/b182c4cf52f494aea7df03b89d692899

With the addition, the code is much cleaner now.

Conclusion

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, do some work and return the value as is. Useful for side effects;
  • DoOther / MapOther / FlatMapOther : same with Do / Map / FlatMapOther but for the Right case;

We also added integration with the async features 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 general clearer "composition" code style. Once we model the possible error case with an Either type, the changes are guaranteed to "bubble up" to all the callers so that explicit error handling is required.

Of course, 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 of Object-oriented code base and the functional programming error handling model.

@voronoipotato
Copy link

Why not just use F# unions?

@zhengbli
Copy link
Author

To do that you need:

  1. Train existing C# devs to learn F#
  2. Re-write the other existing business logic code in F# (and there are a lot of them). Which means re-implement some of the things that's already has library support in C# (things like model mapping, ef sql translation etc) because F# has way fewer libraries available.
  3. All future recruits need to learn F# as well to maintain the code
  4. Hope F# updates always keep up with C# dotnet updates
  5. Still hit the product release deadline.

What you get is:

  1. A little nicer error handling pattern.

So to conclude: that's hard do to.

@voronoipotato
Copy link

You lose some things too but nonetheless I still appreciate the work you've put into this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment