Alternatives to Enums - Part 1: Strongly Typed Strings

Posted by : on

Category : C#

Introduction

Enum types are useful for representing a fixed set of choices. They are commonly used in code to group related values by assigning names to numeric constants. Each enum member has an underlying integral value, which by default is of type int. The first member is assigned the value 0, the second 1, and so on.

Although we can change the underlying integral type or assign custom values to each member, enums are difficult to extend when additional values need to be added without modifying the existing enum definition. This issue is especially common in scenarios where we want to design a library with methods that accept or return values of a specific enum type. If we aim to make our class extensible, allowing users to inherit from it, it would be natural to also allow new values to be added to the enum.

Unfortunately, extending an enum with new values is not possible without modifying the original enum definition. In C#, there is no concept of deriving from an existing enum to introduce additional values. While some workarounds exist, as we will explore, none feel as natural as using the original enum values.

That’s why we should use an alternative: Strongly typed strings.

Enums And Compile-Time Safety

One of the key advantages of enums is that they provide compile-time safety. Instead of using a method that accepts or returns an arbitrary integer value, enums restrict valid inputs to predefined values, improving code clarity and reducing the likelihood of errors. IntelliSense also benefits from this, as it suggests only the available enum values.

However, while compile-time safety is valuable, achieving extensibility in a library while maintaining this safety can be challenging, if not impossible. In most cases, extending enums requires sacrificing compile-time safety. Let’s explore an example of a class that supports inheritance and how we might attempt to add new values to an existing enum type.

Let’s suppose we have the following enum:

public enum Choice
{
   None = 0,
   First = 1,
   Second = 2,
   Third = 3,
   Forth = 4
}

And let’s create a class with two methods, one that takes values of this enum as a parameter and one that returns a value of type Choice:

public class Example
{
   public virtual void MakeChoice(Choice choice)
   {
      switch (choice)
      {
         case Choice.First:
            Console.WriteLine("You picked first");
            break;
         case Choice.Second:
            Console.WriteLine("You picked second");
            break;
         case Choice.Third:
            Console.WriteLine("You picked third");
            break;
         case Choice.Forth:
            Console.WriteLine("You picked forth");
            break;
      }
   }

   public virtual Choice GetChoice(int number)
   {
      return number switch
      {
         1 => Choice.First,
         2 => Choice.Second,
         3 => Choice.Third,
         4 => Choice.Forth,
         _ => Choice.None
      };
   }
}

If we create this class for our own use, enums work fine since we can freely add values to the Choice enum as needed. However, if this class is part of a library and we want to allow extensibility by enabling users to inherit from it, we also need a way to extend the enum.

Currently, this is not possible. At best, users who want to extend our tool can use integer values explicitly cast to the Choice type.

Derived Types With Enums

Here’s an example that extends the Example class:

public class ExampleChild : Example
{
   public override void MakeChoice(Choice choice)
   {
      base.MakeChoice(choice);

      switch (choice)
      {
         case (Choice)5:
            Console.WriteLine("You picked fifth");
            break;
         case (Choice)6:
            Console.WriteLine("You picked sixth");
            break;
      }
   }

   public override Choice GetChoice(int number)
   {
      var result = base.GetChoice(number);
      
      if (result != Choice.None)
         return result;

      return number switch
      {
         5 => (Choice)5,
         6 => (Choice)6,
         _ => Choice.None
      };
   }
}

This illustrates the problems enums can have with extensibility. In the Example class, we can call methods like this:

Example ex = new();
ex.MakeChoice(Choice.Third);
var choice = ex.GetChoice(3);
Console.WriteLine(choice);

and the result would be:

You picked third
Third

But in our ExampleChild class, we have to call our methods like this:

Example exChild = new ExampleChild();
exChild.MakeChoice((Choice)5);
var choiceChild = exChild.GetChoice(5);
Console.WriteLine(choiceChild);

This will print:

You picked fifth
5

There are two main problems here:

  1. Passing a value not in the original enum When we need to pass a value that isn’t part of our original enum, we lose compile-time safety. Since any integer can be cast to the enum, we also lose the enum’s intended semantics, as an integer can represent anything within the enum group. Additionally, our code becomes harder to understand because we start relying on arbitrary conventions to pair integers with specific choices.

  2. Returning a value not in the original enum If a method returns a Choice value that wasn’t originally defined in the enum, we face all the previous disadvantages and more. This approach breaks compatibility with existing code, as any logic expecting a Choice value won’t anticipate or handle these new, undefined values. Furthermore, using an integer like 5 as a stand-in for an enum member introduces a “magic number” problem. Imagine an enum representing days of the week or possible error codes, what does 5 actually mean in that context?

Even worse, developers using the Choice type will naturally assume they only need to handle the values explicitly defined within the enum. They won’t expect or account for additional values introduced through this workaround, making the solution feel unintuitive and unreliable..

Strongly Typed Strings

If we need a library that provides default values belonging to a conceptual group while also allowing users to extend the type with new values, a strongly typed string approach is preferable.

Below is the previous enum, reimplemented using a strongly typed string type called StringChoice:

public readonly struct StringChoice(string? choice) : IEquatable<StringChoice>
{
   private string Choice { get; } = choice ?? string.Empty;
   
   public static StringChoice None { get; } = new(string.Empty);
   public static StringChoice First { get; } = new("First");
   public static StringChoice Second { get; } = new("Second");
   public static StringChoice Third { get; } = new("Third");
   public static StringChoice Forth { get; } = new("Forth");

   public override string ToString() => Choice ?? string.Empty;
   public bool Equals(StringChoice other) => Choice == other.Choice;
   public override bool Equals(object? obj) => obj is StringChoice && Equals((StringChoice)obj);
   public override int GetHashCode() => Choice == null ? 0 : Choice.GetHashCode();
   public static bool operator ==(StringChoice left, StringChoice right) => left.Equals(right);
   public static bool operator !=(StringChoice left, StringChoice right) => !left.Equals(right);
}

This struct wraps a standard string type, implements equality operations, and provides public, static, get-only properties that serve as the default values of our original enum. IntelliSense will assist with these default static values just as it does with an enum. While we lose compile-time safety, something we also sacrificed when attempting to extend an enum, we still have the ability to use named values. This keeps our code readable and maintainable while allowing for easy extension, as all values remain of type StringChoice.

One important detail to note is the Choice property. Since structs always have a default parameterless constructor, we need to check whether the choice parameter is null and handle it appropriately. In this case, we assign string.Empty, which corresponds to the StringChoice.None static property.

For more details, you can check my post: How To Ensure Correct Struct Initialization In C#

Here’s an example of a class that mirrors the parent class in our original enum:

public class ExampleStronglyTypedString
{
   public virtual void MakeChoice(StringChoice choice)
   {
      switch (choice)
      {
         case var _ when choice == StringChoice.First:
            Console.WriteLine("You picked first");
            break;
         case var _ when choice == StringChoice.Second:
            Console.WriteLine("You picked second");
            break;
         case var _ when choice == StringChoice.Third:
            Console.WriteLine("You picked third");
            break;
         case var _ when choice == StringChoice.Forth:
            Console.WriteLine("You picked forth");
            break;
      }
   }

   public virtual StringChoice GetChoice(int number)
   {
      return number switch
      {
         1 => StringChoice.First,
         2 => StringChoice.Second,
         3 => StringChoice.Third,
         4 => StringChoice.Forth,
         _ => StringChoice.None
      };
   }
}

We can call it like this:

ExampleStronglyTypedString exString = new();
exString.MakeChoice(StringChoice.Third);
var stringChoice = exString.GetChoice(3);
Console.WriteLine(stringChoice);

and the result will be equivalent to the enum:

You picked third
Third

Now, the possible values for a parameter or return type can be easily extended in a derived class without requiring awkward type casting. New values can be explicitly defined, making the code more intuitive and maintainable.

Another important detail is how the switch statement is implemented. Since each case must be a compile-time constant, we can use a discard variable along with a condition, like this:

case var _ when choice == StringChoice.First:

Derived Types With Strongly Typed Strings

If a user wants to extend this class to support new StringChoice values, they can do so easily with the following approach:

public class ExampleStronglyTypedStringChild : ExampleStronglyTypedString
{
   public static StringChoice Fifth { get; } = new("Fifth");
   public static StringChoice Sixth { get; } = new("Sixth");
   
   public override void MakeChoice(StringChoice choice)
   {
      base.MakeChoice(choice);

      switch (choice)
      {
         case var _ when choice == Fifth:
            Console.WriteLine("You picked fifth");
            break;
         case var _ when choice == Sixth:
            Console.WriteLine("You picked sixth");
            break;
      }
   }

   public override StringChoice GetChoice(int number)
   {
      var result = base.GetChoice(number);

      if (result != StringChoice.None)
         return result;

      return number switch
      {
         5 => Fifth,
         6 => Sixth,
         _ => StringChoice.None
      };
   }
}

Adding a new StringChoice is as simple as creating a new instance of StringChoice. This approach is not only easier to read and understand—since it avoids obscure integer casting—but also makes the available choices explicit at the beginning of the derived class. Users of the class can see these choices clearly, and IntelliSense provides additional support.

Now, we can call these methods like this:

ExampleStronglyTypedString exStringChild = new ExampleStronglyTypedStringChild();
exStringChild.MakeChoice(ExampleStronglyTypedStringChild.Fifth);
var stringChoiceChild = exStringChild.GetChoice(5);
Console.WriteLine(stringChoiceChild);

And the output will be:

You picked fifth
Fifth

While we still lack compile-time safety for new values, this approach makes the code easier to read and understand. There are no “magic numbers,” and new values exist as public static properties, making them easily accessible. Additionally, using the StringChoice type makes it clear to consumers of our code that this is not a fixed set of values, like an enum, but rather an extendable type that allows for new instances to be created.

Conclusion

Strongly typed strings are simply structs that wrap a string, implement equality checks, and provide static read-only properties for default values. Unlike enums, they do not offer compile-time safety, but they allow for easy extension by creating new instances, which is expected behavior for most types.

For a standalone program, strongly typed strings may not be particularly useful, as we can modify our enums whenever needed. However, if we are developing a library meant for external use, and we want to provide extensibility for classes that use enumerations as parameters or return values, making the enumerations themselves extensible is essential. This approach signals to users that the enumeration is designed to be extended and discourages them from resorting to “hacky” solutions, such as explicitly casting arbitrary integers to an enum type.

Thank you for reading, and if you have any questions or comments you can use the comments section, or contact me directly via the contact form or by email. Also, if you don’t want to miss any of the new blog posts, you can subscribe to my newsletter or the RSS feed.


Follow me: