Enumerations
August 25, 2018 — September 7, 2018Enums in C# are very useful. Unfortunately, using them introduces risk into our programs in both obvious and subtle ways.
Let’s explore these problems and look at a safer alternative.
enum
An Enumeration or Enumerated type is a type that defines a set of named values. An instance of that type can only be one of those values.
A typical example:
enum TransactionState
{
Queued = 1,
Ongoing = 2,
Done = 3,
Cancelled = 4
}
And its usage:
class Transaction
{
public TransactionState State { get; private set; }
//...
public string GetLabel()
{
switch (State)
{
case TransactionState.Queued:
return "It is queued"; break;
case TransactionState.Ongoing:
return "It is ongoing"; break;
case TransactionState.Done:
return "It is done"; break;
case TransactionState.Cancelled:
return "It is cancelled"; break;
default
return "it is a mistery";
}
}
public void Retry()
{
if (State == TransactionState.Cancelled)
// retry logic...
}
}
At first glance this looks quite fine: We’re using a proper type instead of just numbers for the different states and the code using the enum is readable enough.
Still, the “proper type” part is where our dissapointment starts:
you can have an enum with a value wich was not defined as one of its possible values.
var legit = (TransactionState)7
is not a compile time error.
Historically enums are a formalization of the common pattern of defining constants like this:
const int TRANSACTIONSTATE_QUEUED = 1;
const int TRANSACTIONSTATE_ONGOING = 2;
const int TRANSACTIONSTATE_DONE = 3;
const int TRANSACTIONSTATE_CANCELLED = 4;
Today still, enums keep a close connection to ints. Sometimes this connection can be useful. For example:
- when an enum is represented in serialization or communication by an int.
- when an enum represents the options of a bitfield.
- when it makes sense to add or subtract the values of an enum
Unfortunately, when the enum values don’t have a int equivalent and the enum is better represented by something else, like a two letter ISO country code, the int representation can not be opted out of.
Another, more subtle issue is the use of switch case.
If a function accepts a parameter of type TransactionState
,
it creates the expectation that it can handle any of its possible values.
This expectation is not backed by a guarantee.
When we add a possible value to our enum TransactionState
,
every switch case on it has become incomplete and probably incorrect but our code still compiles.
This is not good for maintainability. Not handling the full set of possible values of an enum should result in a compilation failure.
Rigid design
Often a concept starts out as a simple set of possible values, but over time either the concept itself or our understanding of the domain will evolve. The modeling of this concept needs to evolve along, but enums are not extendible. We can not add properties or behaviors to them.
We either have to replace the enum with a class at that point or settle for a worse design by keeping these associated properties and behaviors somewhere else.
An Enumeration class as safer alternative
I was first introduces to this idea when Jan Van Ryswyck showed me this blogpost from 2008. The Enumeration class I’m currently using, is an improvement on that implementation. Mostly by stealing some ideas from F#.
Let’s start by rewriting our example:
class TransactionState: Enumeration<TransactionState>
{
private TransactionState(string name): base(name) {}
public static readonly TransactionState Queued = new TransactionState(nameof(Queued));
public static readonly TransactionState Ongoing = new TransactionState(nameof(Ongoing));
public static readonly TransactionState Done = new TransactionState(nameof(Done));
public static readonly TransactionState Cancelled = new TransactionState(nameof(Cancelled));
public T Match<T>(
T queued,
T ongoing,
T done,
T cancelled)
{
if (this == Queued)
return queued;
if (this == Ongoing)
return ongoing;
if (this == Done)
return done;
if (this == Cancelled)
return cancelled;
throw new NotImplementedException();
}
}
And its usage:
class Transaction
{
public TransactionState State { get; private set; }
//...
public string GetLabel()
=> State.Match(
queued: "It is queued",
ongoing: "It is ongoing",
done: "It is done",
cancelled: "It is cancelled");
public void Retry()
{
if (State == TransactionState.Cancelled)
// retry logic...
}
}
So, what’s the difference?
The first observation is that the definition of TransactionState
has become more verbose.
This is caused by moving logic from outside to inside the Enumeration.
Instead of an unsafe switch
statement,
we now have something like exhaustive pattern matching
through the Match
method.
This Match
method gives us a compile time guarantee that adding a possible value to our Enumeration
will have to be dealt with everywhere we match on TransactionState
.
But what about that if statement?
class Transaction
{
...
public void Retry()
{
if (State == TransactionState.Cancelled)
// retry logic...
}
}
The if
here is like a switch case
where all but one possible values are ignored.
What if a new retryable state is added? We should use TransactionState.Match
here as well.
It seems cumbersome to have to do a Match
every time you need to know whether a Transaction.IsCancelled
or not;
The thing is though, you don’t need to. Extract just this match expression to a method
and let its name reflect why you want to know whether it is cancelled. CanRetry
, IsRetryable
or maybe the opposite IsFinal
.
Our code is now more robust under change and communicates its intention better. Hurray!
You will still need a match for every different reason to match, but I consider that a good thing. It makes intent explicit.
Since TransactionState
is a class now, it can contain this definition if it needs to.
class TransactionState : Enumeration<TransactionState>
{
//...
public bool IsRetryable => Match(
queued: false,
ongoing: false,
done: false,
cancelled: true
);
}
class Transaction
{
//...
public void Retry()
{
if (State.IsRetryable)
// retry logic...
}
}
If we’re trying to avoid if statements, how about conditional behaviour like this?
class TransactionState : Enumeration<TransactionState>
{
//...
public void Cancel()
{
if (State == TransactionState.Queued)
Queue.DeQueue(this.TransactionId);
if (State == TransactionState.Ongoing)
Queue.Abort(this.TransactionId);
}
}
The match approach can be used here as well:
class TransactionState : Enumeration<TransactionState>
{
//...
public void Cancel()
=> State.Match<Action>(
queued: () => Queue.DeQueue(this.TransactionId),
ongoing: () => Queue.Abort(this.TransactionId),
done: () => {},
cancelled: () => {}
)();
}
Notice that we do have to take an action for every possible state, but that we can make the explicit decision that that action should do nothing.
Mapped Enumerations
Often a value originates in an external system and needs to be mapped to an Enumeration. When that value leaves our system again, it needs to be translated back to the external representation.
For this I’m using MappedEnumerations
.
Specifically IntMappedEnumeration
public class Priority : IntMappedEnumeration<Priority>
{
protected Priority(string name, int mappedValue) : base(name, mappedValue) { }
public static readonly Priority High = new Priority(nameof(High), 1);
public static readonly Priority Medium = new Priority(nameof(Medium), 2);
public static readonly Priority Low = new Priority(nameof(Low), 3);
}
and StringMappedEnumeration
.
public class Language : StringMappedEnumeration<Language>
{
protected Language(string name, string mappedValue) : base(name, mappedValue) { }
public static readonly Language Dutch = new Language(nameof(Dutch), "NL");
public static readonly Language French = new Language(nameof(French), "FR");
public static readonly Language English = new Language(nameof(English), "EN");
}
Having an explicit distinction between how a value is represented inside and outside of our system, allows for better naming inside our system. It also makes the impact of changes more predictable.
Sorting
Since the mapped value is usualy not under my control, I don’t use it for sorting.
Both Enumeration
and IndexEnumeration
are IComparable
.
The sort order is the order in which the values are defined in the class.
I typically dont’t like this kind of hard to discover implicit behavior,
but it avoids having to pass a sorting parameter to every instance of Enumeration
.
I’m still on the fence about this one.
From int or string to Enumeration
Since not every int
or string
represents a valid Enumeration
, the constructor is not public.
Every Enumeration has a static Create
method to get an Option<Enumeration>
based on the name of the Enumeration.
For example:
Language GetLanguageOrDefault(string languageCode)
=> Language.Create(languageCode).ValueOr(Language.English);
MappedEnumerations
additionally have a public static FromMappedValue
method
to create an Option<Enumeration>
from the external representation.
For example:
public static class MessageMapper
{
public static Message Map(MessagePoco poco)
=> new Message(
language: Language.FromMappedValue(poco.Language).ValueOrThrowException(),
priority: Language.FromMappedValue(poco.Priority).ValueOr(Priority.Medium)
//...
);
}
MVC modelbinding
In my current ASP.NET Core MVC project, I have custom modelbinding for Enumeration
,
so my form models and controller actions can use the Enumeration
types directly.
This binding only uses Enumeration.Create
and not Enumeration.FromMappedValue
.
if I need to cross system boundaries, I only want to do that explicitly.
[HttpPost]
public async Task<IActionResult> SetLanguage(Language language)
{
// if Language.Create returned None, there will be a validation error for language
if (!ModelState.IsValid)
return BadRequest();
// ...
}
Enumerators
Just like with an enum
, you can iterate over the members of an Enumeration
.
// enum
foreach (var value in (Language[])Enum.GetValues(typeof(Language)))
{
//...
}
// Enumeration
foreach (Language.Enumerators)
{
//...
}
One drawback…
The major thing that bothers me is that the code below will work with an enum
, but not with an Enumeration
.
[Theory]
[InlineData(Language.Dutch)]
ALanguageDependentTest(Language language)
{
//...
}
As an enum
, Language.Dutch
is a compile-time constant, but as an Enumeration
it isn’t.
The not very elegant workaround looks like this:
[Theory]
[InlineData(nameof(Language.Dutch))]
ALanguageDependentTest(string languageCode)
{
var language = Language.CreateOrThrowException(languageCode);
//...
}
Is all of this practical?
Yes. I have been using this in my current project for a year now.
- In this project I have
- 36 concrete
T : Enumeration<T>
classes - 20 concrete
T : IntMappedEnumeration<T>
classes - 2 concrete
T : StringMappedEnumeration<T>
classes
- 36 concrete
- The largest
Match
statement has 40 parameters.
This may sound unwieldy, but especially in this case I find it more pleasant to useMatch
than non-exhaustiveif
orswitch case
statements. When I changed the original implementation into aMatch
, several forgotten cases where discovered. - Many of these
Enumerations
have properties or methods beyond the enumerators themselves. - They help a lot towards the “if it compiles it’s probably correct” experience.
My Enumeration class
This is a slightly simplified version of the Enumeration class I’m currently using.
using Core.Extensions;
using Optional;
using Optional.Collections;
using System;
using System.Collections.Generic;
using System.Linq;
namespace Core.Types
{
public class IntMappedEnumeration<T> : MappedEnumeration<T, int>
where T : IntMappedEnumeration<T>
{
protected IntMappedEnumeration(string name, int index)
: base(name, index) { }
public static explicit operator int(IntMappedEnumeration<T> obj)
=> obj._mappedValue;
public static Option<T> FromMappedValue(int? mappedValue)
=> mappedValue.ToOption().FlatMap(v => CreateFromMappedValue(v));
}
public class StringMappedEnumeration<T> : MappedEnumeration<T, string>
where T : StringMappedEnumeration<T>
{
protected StringMappedEnumeration(string name, string mappedValue)
: base(name, mappedValue) { }
}
public abstract class MappedEnumeration<T, TMapped> : Enumeration<T>
where T : MappedEnumeration<T, TMapped>
{
private static IDictionary<TMapped, Enumeration<T>> _mapLookup
= new Dictionary<TMapped, Enumeration<T>>();
protected readonly TMapped _mappedValue;
protected MappedEnumeration(string name, TMapped mappedValue)
: base(name)
{
_mappedValue = mappedValue;
_mapLookup.Add(_mappedValue, this);
}
public TMapped ToMappedValue()
=> _mappedValue;
public static Option<T> FromMappedValue(TMapped mappedValue)
=> CreateFromMappedValue(mappedValue);
protected static Option<T> CreateFromMappedValue(TMapped mappedValue)
{
EnsureStaticFieldsAreInitialized();
return mappedValue.SomeNotNull()
.Filter(key => _mapLookup.ContainsKey(key))
.Map(key => (T)_mapLookup[key]);
}
}
public class Enumeration<T> : IComparable
where T : Enumeration<T>
{
private static IDictionary<string, Enumeration<T>> _nameLookup
= new Dictionary<string, Enumeration<T>>();
protected readonly string _name;
protected readonly int _sortOrder;
protected Enumeration() { }
protected Enumeration(string name)
{
_name = name.ToKebabCase();
_nameLookup.Add(_name, this);
_sortOrder = _nameLookup.Count;
}
public override string ToString()
=> _name;
public static explicit operator string(Enumeration<T> obj)
=> obj._name;
public static Option<T> Create(string name)
{
EnsureStaticFieldsAreInitialized();
return name.SomeNotNull()
.Map(key => key.ToKebabCase())
.Filter(key => _nameLookup.ContainsKey(key))
.Map(key => (T)_nameLookup[key]);
}
public static T CreateOrThrowException(string name)
=> Create(name).ValueOrThrowException();
protected static void EnsureStaticFieldsAreInitialized()
{
// The initialization of the static fields of a class
// can be postponed until the first static field of that class gets used.
// Since the values of the static fields of our concrete Enumeration classes
// register themselves in a lookuplist when constructed,
// lookup (and thus Create) fails when these fields have not been instantiated yet.
// Currently, we use reflection to work around this.
if (_nameLookup.IsEmpty())
typeof(T)
.GetFields(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static)
.FirstOrNone()
.Map(f => f.GetValue(null));
}
public override bool Equals(object obj)
=> obj is Enumeration<T> enumeration
? _name == enumeration._name
: false;
public override int GetHashCode() => _name.GetHashCode();
public int CompareTo(object obj)
{
if (obj is Enumeration<T> enumeration)
return obj == null
? 1
: _sortOrder.CompareTo(enumeration._sortOrder);
else
throw new ArgumentException(
$"An instance of type \"{obj.GetType().Name}\" " +
$"can not be compared to an instance of type \"{nameof(Enumeration<T>)}\"");
}
private static IEnumerable<T> _enumerators;
public static IEnumerable<T> Enumerators
{
get
{
if (null == _enumerators)
{
EnsureStaticFieldsAreInitialized();
_enumerators = _nameLookup
.OrderBy(v => v.Value._sortOrder)
.Select(v => (T)v.Value);
}
return _enumerators;
}
}
}
}