Skip to content

Version 5.x.x Smart Enums

Pawel Gerr edited this page Mar 4, 2023 · 1 revision

This library provides some types and Roslyn Source Generators for implementation of feature-rich Smart Enums. The library comes with some Roslyn Analyzers and Code Fixes to guide the software developer through the implementation.
Furthermore, additional Nuget packages add support for System.Text.Json, Newtonsoft.Json, MessagePack, Entity Framework Core and ASP.NET Core Model Binding.

Requirements

  • TargetFramework: .NET Standard 2.1 or higher (like .NET Core 3.1, .NET 5)
  • Language version: C# 9.0

Getting started

Required Nuget package: Thinktecture.Runtime.Extensions

Simple Smart Enum without custom properties and methods. The Smart Enum has a key-property of type string. A key property is kind of underlying-type which brings a lot of benefits (shown below). The key property can be of any type.

What you implement

public partial class ProductType : IEnum<string>
{
   public static readonly ProductType Groceries = new("Groceries");
   public static readonly ProductType Housewares = new("Housewares");
}

What is implemented for you

Behind the scenes a Roslyn Source Generator, which comes with Thinktecture.Runtime.Extensions, generates additional code. Some of the basic features that are now available are ...

// a private constructor which takes the key and additional members (if we had any)
public static readonly ProductType Groceries = new("Groceries");

// getting all known product types
IReadOnlyList<ProductType> allTypes = ProductType.Items;

// getting a product type with specific name, i.e., its key
ProductType productType = ProductType.Get("Groceries");

// dictionary-style :)
bool found = ProductType.TryGet("Groceries", out ProductType productType);

// implicit conversion to the type of the key
string key = ProductType.Groceries; // "Groceries"

// explicit conversion from the type of the key to Smart Enum
ProductType productType = (ProductType)"Groceries";

// Equality comparison with 'Equals' 
// which compares the keys using default or custom 'IEqualityComparer<T>'
bool equal = ProductType.Groceries.Equals(ProductType.Groceries);

// Equality comparison with '==' and '!='
bool equal = ProductType.Groceries == ProductType.Groceries;
bool notEqual = ProductType.Groceries != ProductType.Groceries;

// Hash code
int hashCode = ProductType.Groceries.GetHashCode();

// 'ToString' implementation
string key = ProductType.Groceries.ToString(); // "Groceries"

// Accessing the key property directly
string key = ProductType.Groceries.Key; // "Groceries"

Additionally, the source generator implements a TypeConverter which are used by some libraries/frameworks like JSON serialization or ASP.NET Core Model Binding.

var typeConverter = TypeDescriptor.GetConverter(typeof(ProductType));

string key = (string)typeConverter.ConvertTo(ProductType.Groceries, typeof(string)); // "Groceries"
ProductType productType = (ProductType)typeConverter.ConvertFrom("Groceries"); // ProductType.Groceries

IEnum<T> vs IValidatableEnum<T>

When working with plain C# enums then we may cast virtually any number to the type of enum. By doing so, we can end up working with invalid items which may lead to bugs.

// plain C# enum
public enum MyEnum
{
   Groceries = 1,
   Housewares = 2
}

// "myEnum" is an invalid/unknown item
MyEnum myEnum = (MyEnum)42;
bool isDefined = Enum.IsDefined(myEnum); // false

Usually, having invalid enumeration items is not a requirement, that's why an implementation of IEnum<T> provides no means to create an invalid item. Still, there are use cases where you might want to create an invalid item for later analysis. One of such use cases could be parsing a CSV file, which may contain invalid data. Using an implementation of IEnum<T>, like our ProductTyp, would lead to a UnknownEnumIdentifierException when trying to convert a string to a ProductType.

// throws UnknownEnumIdentifierException
ProductType productType = ProductType.Get("SomeValue");

// identical outcome as the line above
ProductType productType = (ProductType)"SomeValue";

The solution in such use cases is to use IValidatableEnum<T> instead of IEnum<T>. A validatable Smart Enum offers not only the same features as IEnum<T> but provides

  • additional property bool IsValid for checking the validity
  • additional (guard) method void EnsureValid() which throws an InvalidOperationException if item is not valid
  • implements System.ComponentModel.DataAnnotations.IValidatableObject which is being used by some libraries/frameworks like ASP.NET Core model-state validation

Simple implementation of an IValidatableEnum<T>:

An implementation of IValidatableEnum<T> can be a readonly struct as well.

public partial class OtherProductType : IValidatableEnum<string>
{
   public static readonly OtherProductType Groceries = new("Groceries");
   public static readonly OtherProductType Housewares = new("Housewares");
}

Creation of an invalid item is now possible.

OtherProductType productType = OtherProductType.Get("SomeValue");

string key = productType.Key; // "SomeValue"
bool isValid = productType.IsValid; // false
productType.EnsureValid(); // throws InvalidOperationException

The creation of invalid items is done by the method CreateInvalidItem which is implemented by the source generator. For more control, it is possible to provide own implementation. There are 2 important conditions:

  • the key must not be changed, so it is equal to a valid item. For example, don't change "SomeValue" to "Groceries".
  • the second constructor argument isValid must always be false, i.e., don't try to make an invalid item to a valid item.
public partial class OtherProductType : IValidatableEnum<string>
{
   public static readonly OtherProductType Groceries = new("Groceries");
   public static readonly OtherProductType Housewares = new("Housewares");

   private static OtherProductType CreateInvalidItem(string key)
   {
      return new(key: key, isValid: false);
   }
}

Adding behavior

The Smart Enums really shine when the enumeration item has to provide additional data (fields/properties) or specific behavior (methods). With plain C# enum there is no other way as to use if-else or switch-case clauses. Having a class like the ProductType, we can add further fields, properties and methods as to any class.

The ProductType from above has got an additional read-only property RequiresFoodVendorLicense. The value of this new property must be provided via the constructor.

public partial class ProductType : IEnum<string>
{
   public static readonly ProductType Groceries = new("Groceries", requiresFoodVendorLicense: true);
   public static readonly ProductType Housewares = new("Housewares", requiresFoodVendorLicense: false);

   public bool RequiresFoodVendorLicense { get; }
}

Adding a method, which provides the same behavior for all items, requires no special treatment. The method may have any method arguments and any return type.

public partial class ProductType : IEnum<string>
{
   public static readonly ProductType Groceries = new("Groceries");
   public static readonly ProductType Housewares = new("Housewares");

   // provides the same behavior for all items
   public void Do()
   {
      // do something
   }
}

If different items must provide different implementations, then we have (at least) 2 options:

  • using delegates (Func<T>, Action, etc.)
  • using derived private classes, i.e., inheritance

Option 1: using delegates

public partial class ProductType : IEnum<string>
{
   public static readonly ProductType Groceries = new("Groceries", DoForGroceries);
   public static readonly ProductType Housewares = new("Housewares", Empty.Action);

   private readonly Action _do;

   public void Do()
   {
      _do();
   }

   private static void DoForGroceries()
   {
      // do something
   }
}

Option 2: inheritance

public partial class ProductType : IEnum<string>
{
   public static readonly ProductType Groceries = new("Groceries");
   public static readonly ProductType Housewares = new HousewaresProductType();

   public virtual void Do()
   {
      // do default stuff
   }

   private class HousewaresProductType : ProductType
   {
      public HousewaresProductType()
         : base("Housewares")
      {
      }

      /// <inheritdoc />
      public override void Do()
      {
         // do something else - or not
      }
   }
}

Switch-case

A replacement for the switch-case statement. The method Switch provides method arguments for every item, i.e. if the ProductType has 2 items (Groceries, Housewares) then the method Switch will have 2 pairs of arguments. A Roslyn Analysers ensures that all items are present in the method call.

ProductType productType = ProductType.Groceries;

// Switch-case with an Action
// Prints "Switch with Action: Groceries"
productType.Switch(ProductType.Groceries, () => Console.WriteLine("Switch with Action: Groceries"),
                   ProductType.Housewares, () => Console.WriteLine("Switch with Action: Housewares"));

// Switch-case with a Func<T>
var returnValue = productType.Switch(ProductType.Groceries, () => "Switch with Func<T>: Groceries",
                                     ProductType.Housewares, () => "Switch with Func<T>: Housewares");

// Prints "Switch with Func<T>: Groceries"
Console.WriteLine(returnValue);

Customizing

Due to the fact, that the code is generated on-the-fly we can customize virtually anything. Following customization options are currently available.

Validation of the constructor arguments

Although the constructor is implemented by the source generator, still, the arguments can be validated in the partial method ValidateConstructorArguments. Please note, that the key must never be null.

public partial class ProductType : IEnum<string>
{
   public static readonly ProductType Groceries = new("Groceries");
   public static readonly ProductType Housewares = new("Housewares");

   static partial void ValidateConstructorArguments(ref string key)
   {
      if (String.IsNullOrWhiteSpace(key))
         throw new Exception("Key cannot be empty.");

      key = key.Trim();
   }
}

A validatable enum gets an additional parameter bool isValid which is an indication what kind of item is being created. That way the validation may pass (but doesn't have to) invalid input if an invalid item is being created.

public partial class ProductType : IValidatableEnum<string>
{
   public static readonly ProductType Groceries = new("Groceries");
   public static readonly ProductType Housewares = new("Housewares");

   static partial void ValidateConstructorArguments(ref string key, bool isValid)
   {
      if (!isValid)
         return;

      if (String.IsNullOrWhiteSpace(key))
         throw new Exception("Key cannot be empty.");

      key = key.Trim();
   }
}

Changing the key property name

The key property name is Key be default. Use the EnumGenerationAttribute to change the property name to something else.

[EnumGeneration(KeyPropertyName = "Name")]
public partial class ProductType : IEnum<string>
{
   public static readonly ProductType Groceries = new("Groceries");
   public static readonly ProductType Housewares = new("Housewares");
}

The key property is now called Name instead of Key.

string name = ProductType.Groceries.Name; // "Groceries"

Custom key comparer

By default, the source generator is using the comparer EqualityComparer<T>.Default for all types except string. If the key is a string, then the comparer StringComparer.OrdinalIgnoreCase is being used.

The reason a string-based Smart Enum is not using EqualityComparer<T>.Default is because I haven't encountered a use case where the comparison of enumeration names must be performed case-sensitive.

The provided KeyComparer must be a static field or property accessible by the Smart Enum.

[EnumGeneration(KeyComparer = nameof(_keyComparer))]
public partial class ProductType : IEnum<string>
{
   private static readonly IEqualityComparer<string> _keyComparer = StringComparer.InvariantCultureIgnoreCase;

   public static readonly ProductType Groceries = new("Groceries");
   public static readonly ProductType Housewares = new("Housewares");
}

JSON serialization

Depending on the concrete JSON library you use, you need a different Nuget package:

  • For System.Text.Json: Thinktecture.Runtime.Extensions.Json
  • For Newtonsoft.Json: Thinktecture.Runtime.Extensions.Newtonsoft.Json

There are 2 options to make the Smart Enums JSON convertible.

Option 1: Make Smart Enums depend on Nuget package

The easiest way is to make Thinktecture.Runtime.Extensions.Json / Thinktecture.Runtime.Extensions.Newtonsoft.Json a dependency of the project(s) the Smart Enums are in. The dependency doesn't have to be a direct one but transitive as well. Both Nuget packages come with another Roslyn source generator, which implements a JSON converter and flags the Smart Enum with a JsonConverterAttribute. This way the Smart Enum can be converted to and from JSON without extra code.

Option 2: Register JSON converter with JSON serializer settings

If making previously mentioned Nuget package a dependency of project(s) with Smart Enums is not possible or desirable, then the other option is to register a JSON converter with JSON serializer settings. By using a JSON converter directly, the Nuget package can be installed in any project where the JSON settings are configured.

  • Use ValueObjectJsonConverterFactory with System.Text.Json
  • Use ValueObjectNewtonsoftJsonConverter with Newtonsoft.Json

Don't be confused by the names ValueObjectJsonConverterFactory and ValueObjectNewtonsoftJsonConverter. This library has another feature which helps with implementation of immutable value objects. A Smart Enum is kind of specialization of a value object.

An example for ASP.NET Core application using System.Text.Json:

var webHost = new HostBuilder()
              .ConfigureServices(collection =>
               {
                  collection.AddMvc()
                            .AddJsonOptions(options => options.JsonSerializerOptions
                                                              .Converters
                                                              .Add(new ValueObjectJsonConverterFactory()));
               })

The code for Newtonsoft.Json is almost identical:

var webHost = new HostBuilder()
              .ConfigureServices(collection =>
               {
                   collection.AddMvc()
                             .AddNewtonsoftJson(options => options.SerializerSettings
                                                                  .Converters
                                                                  .Add(new ValueObjectNewtonsoftJsonConverter()));
               })

MessagePack serialization

  • Required nuget package: Thinktecture.Runtime.Extensions.MessagePack

There are 2 options to make the Smart Enums MessagePack serializable.

Option 1: Make Smart Enums depend on Nuget package

The easiest way is to make Thinktecture.Runtime.Extensions.MessagePack a dependency of the project(s) the Smart Enums are in. The dependency doesn't have to be a direct one but transitive as well. The Nuget package comes with another Roslyn source generator, which implements a MessagePack formatter and flags the Smart Enums with a MessagePackFormatterAttribute. This way the Smart Enum can be converted to and from MessagePack without extra code.

Option 2: Register MessagePack FormatterResolver with MessagePack serializer options

If making previously mentioned Nuget package a dependency of project(s) with Smart Enums is not possible or desirable, then the other option is to register the MessagePack formatter with MessagePack serializer options. By using the ValueObjectMessageFormatterResolver directly, the Nuget package can be installed in any project where the MessagePack options are configured.

Don't be confused by the names ValueObjectMessageFormatterResolver.Instance. This library has another feature which helps with implementation of immutable value objects. A Smart Enum is kind of specialization of a value object.

An example of a round-trip-serialization of the Smart Enum ProductType.Groceries:

// Use "ValueObjectMessageFormatterResolver.Instance"
var resolver = CompositeResolver.Create(ValueObjectMessageFormatterResolver.Instance, StandardResolver.Instance);
var options = MessagePackSerializerOptions.Standard.WithResolver(resolver);

var productType = ProductType.Groceries;

// Serialize to MessagePack
var bytes = MessagePackSerializer.Serialize(productType, options, CancellationToken.None);

// Deserialize from MessagePack
var deserializedproductType = MessagePackSerializer.Deserialize<ProductType>(bytes, options, CancellationToken.None);

Support for ASP.NET Core Model Binding

Required nuget package: Thinktecture.Runtime.Extensions.AspNetCore

Having JSON convertible Smart Enums is just half of the equation. If a value is received as a query parameter, then there is no JSON conversion in play but a rather simple type conversion by the ASP.NET Core Model Binding. Besides model binding, i.e., conversion from query string to a Smart Enum, there is model validation as well.

For example, if we expect from client a ProductType and receive the value SomeValue, then the ASP.NET Core ModelState must be invalid. In this case we can reject (or let ApiControllerAttribute reject) the request.

By rejecting the request, the client gets the status code BadRequest (400) and the error:

{
   "productType":["The enumeration item of type 'ProductType' with identifier 'SomeValue' is not valid."]
}

To help out the Model Binding we have to register the ValueObjectModelBinderProvider with ASP.NET Core. By using the custom model binder, the Nuget package can be installed in any project where ASP.NET Core is configured.

Don't be confused by the name ValueObjectModelBinderProvider. This library has another feature which helps with implementation of immutable value objects. A Smart Enum is kind of specialization of a value object.

var webHost = new HostBuilder()
              .ConfigureServices(collection =>
              {
                   collection.AddMvc(options => options.ModelBinderProviders
                                                       .Insert(0, new ValueObjectModelBinderProvider()));
              })

Support for Entity Framework Core

Optional nuget package: Thinktecture.Runtime.Extensions.EntityFrameworkCore

Starting with Entity Framework Core 2.1 we've got the feature Value Conversion. By providing a value converter, the EF Core can convert a Smart Enum (like Product) to a primitive type (like string) before persisting the value and back to a Smart Enum when reading the value from database.

Option 1: Manual registration of the ValueConverter

The registration of a value converter can be done manually by using one of the method overloads of HasConversion in OnModelCreating.

// Entity
public class Product
{
   // other properties...

   public ProductType ProductType { get; private set; }
}

public class ProductsDbContext : DbContext
{
   protected override void OnModelCreating(ModelBuilder modelBuilder)
   {
      base.OnModelCreating(modelBuilder);

      modelBuilder.Entity<Product>(builder =>
      {
        builder.Property(p => p.ProductType)
               .HasConversion(p => p.Key,
                              key => ProductType.Get(key));
      });
   }
}

Option 2: Registration of the ValueConverter via extension method for ModelBuilder

Alternatively, if you are using Entity Framework Core v5/v6, you can install the Nuget package Thinktecture.Runtime.Extensions.EntityFrameworkCore and use the extension method AddEnumAndValueObjectConverters to register the value converters for you.

public class ProductsDbContext : DbContext
{
   protected override void OnModelCreating(ModelBuilder modelBuilder)
   {
      base.OnModelCreating(modelBuilder);

      modelBuilder.AddEnumAndValueObjectConverters(validateOnWrite: true);
   }
}

Option 3: Registration of the ValueConverter via extension method for DbContextOptionsBuilder

Requires version 4.1.0 or later

The other options is to use the extension method UseValueObjectValueConverter for the DbContextOptionsBuilder.

Don't be confused by the name UseValueObjectValueConverter. This library has another feature which helps with implementation of immutable value objects. A Smart Enum is kind of specialization of a value object.

services
   .AddDbContext<DemoDbContext>(builder => builder
                                           .UseValueObjectValueConverter(validateOnWrite: true)

Limitations

Source Generation: Aliases are not supported

Aliases, like IStringEnum or EnumGen in the examples below, are not supported due to performance reasons during source code generation.

using IStringEnum = Thinktecture.IValidatableEnum<string>;
using EnumGen = Thinktecture.EnumGenerationAttribute;

namespace Thinktecture.SmartEnums;

public partial class ProductGroup : IStringEnum
{
}

[EnumGen]
public sealed partial class SpecialProductType : ProductType
{
   public static readonly SpecialProductType Special = new("Special");
}
Clone this wiki locally