Yes, the visitor pattern can be used as an alternative to enums+switch/case. I would consider this if your enum variants are more complicated (need to carry associated data, not just represent an integer), or if it is very important that all variants are always considered.
Limitations of C# enums
In C#, enums just provide names for some values of the underlying integral type, but should not be considered to be exhaustive:
The set of values of the enum type is the same as the set of values of the underlying type and is not restricted to the values of the named constants.
Thus, when switching on an enum, it is necessary to have a default
case (or a discard pattern in case of a switch-expression). It is not reasonably possible to exhaustively handle all possible values of the enum.
Consequences of using class hierarchies instead
Indeed, the alternative is to use a class hierarchy to define the alternatives. By making information about the variants accessible to the type system, we can get an error when we forgot to handle a case. However, there is some mismatch between this enum-oriented thinking and OOP approaches.
There are two ways in which such a class hierarchy could be used: using the visitor pattern, or adding methods to an interface. Or possibly, a combination of both.
You already mentioned the visitor pattern. Implementing it does require a bit of extra boilerplate, but it's not so bad. The visitor pattern makes it easy to create more visitor implementations, i.e. to create new operations that process the entire class hierarchy. However, adding a new variant (introducing a new class) is a breaking change, requiring all existing visitors to be updated. This is exactly what you want. But this also means that this kind of change tends to be inappropriate for the API of a library, as changing this API would break all downstream code.
In some cases it might be possible to provide a fallback implementation for an easier migration (e.g. by providing a visitor base class with stub implementation), but this would prevent the compiler from alerting consumers that a new option was added, leading us back to the problem with C# enums…
C# does offer convenient syntax for checking the type of a value with switch-expressions, but there's no information to tell the type checker what all possible subtypes are, preventing exhaustiveness checking.
An alternative is to just use a normal OOP design, with an interface or base class that declares all possible operations. Each variant is a class that then implements these methods. This makes it easy to introduce new variants, but adding a new operation to the interface or base class would be a breaking change that requires all variants to be updated.
Note that these techniques – declaring variants in a visitor interface vs declaring operations in a base class – both allow one kind of extensibility while making extensibility along another axis difficult: you can either make it easy to add new operations or new variants, but not both.
Another significant consequence of using your own classes to define variants is that you're no longer limited to just having integers as the underlying data – your variants can include arbitrary fields. Whereas a C# enum is a collection of named integers, these type hierarchies define a collection of classes that may in turn have multiple distinct instances. In the visitor pattern, all instances of a class are handled as one case, which may or may not be desirable.
How other languages do it
Other languages have direct support for exhaustiveness checking, making it unnecessary for workarounds like the visitor pattern. Many functional languages have "sum types". E.g. Rust has enums that don't just name some integers, but that define a type that can only have the given values. This lets the compiler show an error when one variant was forgotten, though it's also possible to opt-in to non-exhaustive enums that always require a fallback case (great for evolving APIs in a backwards-compatible manner).
C++ has std::variant
to construct enum-like types in an ad-hoc manner. Instead of having the variants inherit from a common interface, the variants are defined first and then combined. Then, std::visit
can be used to match on the values in the variant group. This uses C++ features like templates, compile-time conditionals, and static_assert to ensure exhaustiveness at compile time. It's better than writing visitors by hand, but it's not necessarily pretty either.
Some languages have built-in support for defining closed class hierarchies that then allow exhaustiveness checking. E.g. Kotlin lets us define a "sealed" class or interface that we can only inherit in the current file. This allows the compiler to know when a when
expression (Kotlin's switch-expression) has exhaustively matched all possible types. I think it would be delightful if C# got this feature as well.
Java inhabits a weird spot in this design space. Its enums are conceptually a class, and variants name all valid instances of this class. You can't cast arbitrary ints to an enum. Thus, exhaustiveness checking would be possible. However, Java has compatibility rules that say that adding a new enum member is not a breaking change, thus the compiler can at most emit a warning when a case was missed.
ThirdValue
then you know where that is.