Introduction
This is an analyzer-only package that aims to provide a dead simple, yet complete, "discriminated unions" experience for C# with compile-time exhaustiveness checking.
"Enum classes" are a generalization of C#'s native enums
. They can be used to represent a fixed, predefined set of possible values. Unlike regular enum
s, "enum classes" can also store additional data per variant.
It is loosely based on the C# proposal. The proposed future syntax:
public enum class Shape
{
Circle(float Radius),
Rectangle(float Width, float Height),
Triangle(float SideLength),
}
Unfortunately, since we don't live in the future, this is the actual syntax we'll be working with today:
using Badeend;
[EnumClass]
public abstract record Shape
{
private Shape() {}
public record Circle(float Radius) : Shape;
public record Rectangle(float Width, float Height) : Shape;
public record Triangle(float SideLength) : Shape;
}
A bit more verbose, but close enough... ;) FYI, this package comes bundled with automatic code fixers to help write some of this boilerplate for you.
Installation
dotnet add package Badeend.EnumClass
# Optional:
dotnet add package Badeend.EnumClass.Reflection
dotnet add package Badeend.EnumClass.SystemTextJson
More introduction
All the magic happens at compile-time as part of the analyzers shipped with this package.
Continuing with the example from above:
- We define a
Shape
"enum" type, that has three "case" types:Circle
,Rectangle
&Triangle
. - The
Shape
type has an[EnumClass]
attribute, which is the cue for the analyzers to kick in. - The analyzers enforce that the base type and nested subtypes satisfy all the required criteria for them to be worthy of the title "enum class". Some of these criteria can be seen right there in the example:
abstract
base type, private constructor, cases extend their parent type, etc... All for the ultimate goal: Shape
is now protected against external extension and we can be sure that anyShape
instance we encounter at runtime will be either aCircle
, aRectangle
or aTriangle
. Exactly one of those three and nothing else.
Exhaustiveness checking
This is the true superpower of enum classes: all the subtypes are known at compile-time, so we can enforce that every switch
-expression/statement on them is exhaustive. I.e. we can warn developers when they've missed a case:
var area = shape switch // Warning EC2001: Switch is not exhaustive. Unhandled cases: Triangle.
{
Shape.Circle circle => Math.PI * circle.Radius * circle.Radius,
Shape.Rectangle rectangle => rectangle.Width * rectangle.Height,
};
The analyzer warns us that we've not handled triangles yet. To save us some typing, it provides an Add remaining cases
codefix that automatically appends the unhandled cases at the end of the switch
:
var area = shape switch
{
Shape.Circle circle => Math.PI * circle.Radius * circle.Radius,
Shape.Rectangle rectangle => rectangle.Width * rectangle.Height,
+ Shape.Triangle triangle => ,
};
Ofcourse it is still up to us to actually define how to compute the area of a triangle.
At this point, we've successfully prevented the program from blowing up, and turned a runtime error into a compile-time error.
Yay!
Codefixes FTW
At the bare minimum you need to write the following code yourself:
[EnumClass]
record Shape
{
record Circle(float Radius);
record Rectangle(float Width, float Height);
record Triangle(float SideLength);
}
var area = shape switch
{
};
... and can then use the codefixes to autocomplete yourself into this:
[EnumClass]
abstract record Shape
{
private Shape()
{
// Private constructor to prevent external extension.
}
public record Circle(float Radius) : Shape;
public record Rectangle(float Width, float Height) : Shape;
public record Triangle(float SideLength) : Shape;
}
var area = shape switch
{
Shape.Circle circle => ,
Shape.Rectangle rectangle => ,
Shape.Triangle triangle => ,
};
Applied fixes:
- On enum class:
Make abstract
- On enum class:
Add private constructor
- On enum cases:
Extend Shape
- On enum cases:
Make public
- On switch expression:
Add remaining cases
Practical example
So far we've been working with the rather theoretical Shape
example. Next, we'll take a look at something that you might actually encounter in the real world.
Let's assume we're building some kind of background processing service and we want to be able to query the current state of a background job along with relevant metadata. One way to model this state could be:
public enum JobState
{
Pending,
Running,
Finished,
Failed,
}
public record Job
{
public Guid Id { get; init; }
public JobState State { get; init; } // Current state of the job.
public float Progress { get; init; } // Current progress. Percentage between 0 and 100.
public byte[] Output { get; init; } // Result of the job.
public string ErrorMessage { get; init; } // Reason why the job failed.
public DateTime DeleteAfter { get; init; } // Automatically remove the job from the queue after this timestamp.
}
At first glance, this looks like perfectly fine, run-of-the-mill C# code. However, a few questions pop up:
- What is the value of
Output
when the job hasn'tFinished
yet? Is it null? Is it an empty array? Will it throw? - Similarly for the
ErrorMessage
property: what will its value be when the job didn't fail? - What is the
Progress
of aFailed
job?0
?100
? The last progress before it failed? It throws? Who knows...
These issues could be resolved by simply adding more documentation and/or annotating the properties to be nullable. Or, we can take advantage of the type system:
[EnumClass]
public abstract record JobState
{
private JobState() {}
public record Pending : JobState;
public record Running(float Progress) : JobState;
public record Finished(byte[] Output) : JobState;
public record Failed(string ErrorMessage) : JobState;
}
public record Job
{
public Guid Id { get; init; }
public JobState State { get; init; }
public DateTime DeleteAfter { get; init; }
}
In this new design, all properties that were dependent on the State
have been pushed into the JobState
type. This answers all of our earlier questions:
- only finished jobs have an
Output
, - only failed jobs have an
ErrorMessage
, - only in-progress jobs report their
Progress
.
Preconditions that previously only lived within comments or inside the heads of developers are now codified in the type system. And if you didn't notice already: we've eliminated the need for any nullability or exceptions. I.e. if a job has Finished
it definitely has an Output
, if a job has Failed
it definitely has an ErrorMessage
, etc.
Comparison with interfaces
Both enum classes and interfaces can be used to represent "one of multiple things". I've tried to summarize the distinction below:
Enum classes (and regular enums) |
Interfaces (and publicly extendable abstract base classes) |
---|---|
Typical usage: as a concrete data type. | Typical usage: to abstract away behavior. |
The set of possible cases is closed and known at compile time. |
The set of possible implementations is open and can't be known until run time. |
The cases are part of the public contract. Consumers need to be aware of them. |
The implementations are an implementation detail. Consumers of the interface shouldn't need to be aware of them. |
Adding a new operation to an existing enum class:
|
Adding a new operation to an existing interface:
|
Adding a new case to an existing enum class:
|
Adding a new implementation for an existing interface:
|
As always, the real world isn't as black and white as this table makes it out to be. Interfaces can define methods with default implementations and enum classes can (ab)use inheritance between their base type & case types and/or even implement interfaces themselves. ¯\_(ツ)_/¯ |
In other languages
Depending on which corner of the internet you come from, you might also know "enum classes" by different names:
- "Sum types"
- "Tagged unions"
- "Discriminated unions"
- "Closed type hierarchies"
- "Sealed classes" (completely unrelated to C#'s concept of 'sealed' classes...)
- "Algebraic Data Types"
- "Variants"
- Or even simply: "enums"
Languages with built-in support: