Please, don't use enums in C#

Enumerated types

Enumerated (discrete) types are a powerful modeling tool for software developers: they allows them to explicitly state all and only the permitted values a variable can hold, with guarantee that

  • no invalid values can be pushed into a function, and
  • conditional (switch/case or pattern-matching based) depending on enumerated types can be recognized to be exhaustive by compilers.

This is strictly true for enums you can define in languages like Scala (case classes), Kotlin (enums) or even the old, mistreated Java (enums), but is only an unmaintained, misleading promise for C#’s enums.

The problem (or “The C# way” to enums”)

Defining an enum in C# indeed is only a syntactic sugar you can leverage to define related, “namespaced” integer constants:

1
2
3
public enum Ordinal {
First = 1, Second = 2, Third = 3
}

is in essence only a shortcut for

1
2
3
4
5
public static class Ordinal {
public const int First = 1;
public const int Second = 2;
public const int Third = 3;
}

I’m not saying the compiler produces the same output - I’m saying in both cases you can refer to something like Ordinal.Second in order to get an int constant whose value is 2.

Issue #1

No way to define a method, say

1
2
3
void DoSomething(Ordinal o) {
Console.WriteLine($"Ordinal value is {o:D}");
}

preventing callers to pass invalid values into:

1
2
DoSomething(Ordinal.First);
DoSomething((Ordinal)500);

is definitely valid code (from the compiler’s point of view) producing the following output:

1
2
Ordinal value is 1
Ordinal value is 500 // WTF??? Value not present in Ordinal declaration...

Issue #2

No way to rely on compiler in order to check exhaustiveness of conditional checks: you can indeed write

1
2
3
4
5
public int Foo(Ordinal o) => o switch {
Ordinal.First => 1,
Ordinal.Second => 2,
Ordinal.Third => 3,
};

but the compilers gives you a warning like The switch expression does not handle all possible inputs (is is not exhaustive), even if all values defined by the enum are explicitly treated; in order to avoid this inappropriate warning you must add a fourth, never used branch to the switch:

1
_ => throw new Exception("Unexpected value")

(or you can return a special value, if you like code smells ;-)…).

So, C#’s enums are syntactic sugar for int constants, and defining a method parameter of type Ordinal is nothing different from defining it of type int (yes, you can define an enum having byte or long or ${other integral type} as underlying representation (see here), but… you got the idea).

From a modelling point of view, C#’s enums are a very poor feature, which does not allow developers to define true enumerated (discrete) types: so… please, don’t use them, or at least don’t use them as if they were.

The right way

The right way to model enumerated/discrete types in C# is imho to adopt a pattern that I first heard about in 2004, reading Hardcore Java enlightening book:

1
2
3
4
5
6
7
public sealed class Ordinal {
public int Value { get; }
private Ordinal(int value) { Value = value; }
public static Ordinal First = new Ordinal2(0);
public static Ordinal Second = new Ordinal2(1);
public static Ordinal Third = new Ordinal2(2);
}

This does not solve the problem of exhaustiveness’ check, but models true discrete type allowing only intended values to be used where Ordinal parameters are required.

A variant of this pattern, based on inheritance, allow developers to attach polymorphic behaviours to enumeration cases.

Bonus (or “The Java way to enums”)

By the way, this is the way enum‘s implementation in Java (since 2004!!!) and Kotlin work: they provide substantially a syntactic sugar for the pattern above, allowing true enumerated/discrete types modelling:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public enum Ordinal {
First {
@Override
void doSomething() {
// Behaviour for case First
}
}, Second {
@Override
void doSomething() {
// Behaviour for case Second
}
}, Third {
@Override
void doSomething() {
// Behaviour for case Third
}
};

abstract void doSomething();
}

Java supports exhaustiveness check out of the box, too:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void doSomething(Ordinal o) {
var value = switch (o) { // compiler's error: 'switch' expression does not cover all possible input values
case First -> 1;
case Second -> 2;
};
}

void doSomething(Ordinal o) {
var value = switch (o) { // no errors, no warning
case First -> 1;
case Second -> 2;
case Third -> 3;
};
}