Making Enums More Flexible and Extensible
Enums are great at what they do: creating a simple integer type with specific values. Their main purpose is to choose one value out of many like enum Color { Red, Green, Blue }
. But what if you have data attached to those choices? What if the data is one type for one choice and another type for another choice? What if there are two pieces of data to attach to one choice and only one for another? Today’s article shows a simple pattern you can use instead of enum
in these cases to get a lot more flexibility and extensibility. Read on to see how!
Say you’ve got a Login
function that logs a user into a server:
void Login(string username, string password, Action<bool> callback);
The callback
parameter gets called with true
upon success and false
upon failure. You usually quickly realize that a simple bool
is an insufficient way of expressing all the errors that could happen while logging in. This is where you switch to an enum
:
enum LoginError { Success, InvalidCredentials, AccountLocked, TemporarilyBanned, PermanentlyBanned, } void Login(string username, string password, Action<LoginError> callback);
That’s a lot better than a simple bool
because we can specify just what kind of error happened rather than a simple “pass” or “fail”. This would probably help out error reporting to the user a lot. It’ll definitely help extensibility a lot since we can add more error types to the enum
as we discover them without changing the function signature of the Login
function.
Problems start to emerge with this simple enum
when we want to attach data to these states. For InvalidCredentials
, how many more attempts is the user allowed to make before their account is locked? For AccountLocked
and TemporarilyBanned
, how long will this be the case? For TemporarilyBanned
and PermanentlyBanned
, what was the reason for the ban?
Really, we want a class
for each of these types so we have somewhere to put one or more pieces of data related to the error. Here’s how they might look for these errors:
class InvalidCredentials { int RemainingAttempts; } class AccountLocked { DateTime UnlockDate; } class TemporarilyBanned { DateTime UnbanDate; string Reason; } class PermanentlyBanned { string Reason; }
You can probably think of tons of more fields to add—ban dates, account lock reason, credentials hints—and now there’s a really convenient place to put them. You might even make a class
for the success case if you want to attach some data to that, too. The only problem is that the Login
function’s callback
callback parameter can’t have pass four different class
types. You don’t know what kind of error you’ll get ahead of time, so you can’t even make four overloads of the function. Instead, we need to group them together using an empty interface
that they all implement:
interface ILoginError { } class InvalidCredentials : ILoginError { int RemainingAttempts; } class AccountLocked : ILoginError { DateTime UnlockDate; } class TemporarilyBanned : ILoginError { DateTime UnbanDate; string Reason; } class PermanentlyBanned : ILoginError { string Reason; }
Now the Login
function’s callback
can just use ILoginError
:
void Login(string username, string password, Action<ILoginError> callback);
Instead of specifying an enum
value, create the error like so:
new PermanentlyBanned { Reason = "Cheating" };
When handling the error, you’ll need to check each possible cause just like with enum
:
void HandleLogin(ILoginError error) { if (error == null) { Debug.Log("Success"); return; } var invalidCredentials = error as InvalidCredentials; if (invalidCredentials != null) { Debug.LogError( "Invalid credentials. You have " + invalidCredentials.RemainingAttempts + " more tries." ); } var accountLocked = error as AccountLocked; if (accountLocked != null) { Debug.LogError( "Account locked until " + accountLocked.UnlockDate + "." ); } var temporarilyBanned = error as TemporarilyBanned; if (temporarilyBanned != null) { Debug.LogError( "Banned until " + temporarilyBanned.UnbanDate + " for " + temporarilyBanned.Reason + "." ); } var permanentlyBanned = error as PermanentBanned; if (permanentlyBanned != null) { Debug.LogError( "Banned for " + permanentlyBanned.Reason + "." ); } }
That’s really all there is to it. There are three levels of specifying errors here: bool
, enum
, class
. The class approach is by far more flexible and extensible than bool
or enum
. It also takes the most work to set up, but that’s largely due to the extra data you want to specify. The same is true of enum
and bool
: it takes a lot more setup to specify the kinds of errors with enum
than to just use true
and false
.
Which level is right for you? Only you can decide. Which do you use in your apps? Share your thoughts in the comments!
#1 by Cardin on December 7th, 2015 ·
One thing I fancied but haven’t tried using is, enums with extension methods.
In most cases I ended up using classes with implicit casts and operator overloading.
#2 by jackson on December 7th, 2015 ·
Those sound like interesting alternatives. Could you share a little sample of how the extension methods for enums and operator overloading look?
#3 by benjamin guihaire on September 7th, 2017 ·
#4 by Zach on December 7th, 2015 ·
This is a good solution in some cases, but creates a new problem: Some other class creates its own implementation of ILoginError. Now your blocks to handle errors get a type that shouldn’t exist according to the architecture.
So while this is nice, it discards one of the best features of an enum: it sanitizes inputs to a restricted set of possibilities.
For example, in a days of the week enum, I don’t need to check that a bit of code has created their own day of the week. There are only the 7 possibilities defined by the enum.
#5 by jackson on December 7th, 2015 ·
Excellent point! Unfortunately, if someone adds to the
enum
then you don’t get an error or warning message either. Days of the week are basically fixed for all time, but new error types appear all the time. If you add a “network unreachable” error type to yourenum
then you’ll still have to go update yourswitch
statement handling theenum
. The same goes withILoginError
: you’ll have to update theif-else
chain in the handler function.If only C# had something like F#’s discriminated unions where
match
(F#’sswitch
) forced you to handle all the cases. TheILoginError
technique described in this article is basically a translation of F#’s discriminated unions, except with a lot more verbosity and less safety.