Skip to content

Version 7.x.x Smart Enums

Pawel Gerr edited this page Aug 13, 2024 · 1 revision

Documentation for version 7.x.x

This library provides an easy way for implementation of Smart Enums. The library comes with some Roslyn Analyzers and Code Fixes to guide the software developers 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.

Don't be confused when some type start with ValueObject... instead of SmartEnum.... Some types are being used for both, the Smart Enums and the Value Objects. You can think of a Smart Enum as a "special" Value Object with limited number of items.

Requirements

  • C# 11 (or higher) for generated code
  • SDK 7.0.401 (or higher) for building projects

Getting started

Required Nuget package: Thinktecture.Runtime.Extensions

In this example the Smart Enum ProductType has the underlying-type (key-member type) string. The underlying type can be anything, not just primitive types like a string or an int.

As of version 7 you can define a keyless Smart Enum (see SalesCsvImporterType below), i.e. without any underlying type. A keyless Smart Enum has just a subset of the features of a keyed Smart Enum.

What you implement

[SmartEnum<string>]
public sealed partial class ProductType
{
   public static readonly ProductType Groceries = new("Groceries");
   public static readonly ProductType Housewares = new("Housewares");
}

-------------

[SmartEnum<int>]
public sealed partial class ProductGroup
{
   public static readonly ProductGroup Fruits = new(1);
   public static readonly ProductGroup Vegetables = new(2);
}

-------------

[SmartEnum] 
public sealed partial class SalesCsvImporterType 
{ 
   public static readonly SalesCsvImporterType Daily = new(); 
   public static readonly SalesCsvImporterType Monthly = new(); 
} 

What is implemented for you

Behind the scenes a Roslyn Source Generator generates additional code. Some of the features that are now available are ...

// A private constructor which takes the key "Groceries" and additional members (if we had any)
[SmartEnum<string>]
public sealed partial class ProductType
{
   public static readonly ProductType Groceries = new("Groceries");
   ...

------------

// A property for iteration over all items
IReadOnlyList<ProductType> allTypes = ProductType.Items;

------------

// Getting the item with specific name, i.e. its key.
// Throws UnknownEnumIdentifierException if the provided key doesn't belong to any item
ProductType productType = ProductType.Get("Groceries");

// Alternatively, using an explicit cast (behaves the same as "Get")
ProductType productType = (ProductType)"Groceries";

------------

// the same as above but returns a bool instead of throwing an exception (dictionary-style)
bool found = ProductType.TryGet("Groceries", out ProductType? productType);

------------

// similar to TryGet but accepts `IFormatProvider` and returns a ValidationError instead of a boolean.
ValidationError? validationError = ProductType.Validate("Groceries", null, out ProductType? productType);

if (validationError is null)
{
    logger.Information("Product type {Type} found with Validate", productType);
}
else
{
    logger.Warning("Failed to fetch the product type with Validate. Validation error: {validationError}", validationError.ToString());
}

------------

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

------------

// Equality comparison
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"

------------

ILogger logger = ...;

// Switch-case (with "Action")
productType.Switch(ProductType.Groceries, () => logger.Information("Switch with Action: Groceries"),
                   ProductType.Housewares, () => logger.Information("Switch with Action: Housewares"));
                   
// Switch-case with parameter (Action<TParam>) to prevent closures
productType.Switch(logger,
                   ProductType.Groceries, static l => l.Information("Switch with Action: Groceries"),
                   ProductType.Housewares, static l => l.Information("Switch with Action: Housewares"));

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

// Switch case with parameter returning a value (Func<TParam, TResult>) to prevent closures
var returnValue = productType.Switch(logger,
                                     ProductType.Groceries, static l => "Switch with Func<T>: Groceries",
                                     ProductType.Housewares, static l => "Switch with Func<T>: Housewares");

// Map an item to another instance
returnValue = productType.Map(ProductType.Groceries, "Map: Groceries",
                              ProductType.Housewares, "Map: Housewares");
------------

// Implements IParsable<T> which is especially helpful with minimal apis.
bool parsed = ProductType.TryParse("Groceries", null, out ProductType? parsedProductType);

------------

// Implements IFormattable if the underlyng type (like int) is an IFormattable itself.
var formatted = ProductGroup.Fruits.ToString("000", CultureInfo.InvariantCulture); // 001

------------

// Implements IComparable and IComparable<T> if the key member type (like int) is an IComparable itself.
var comparison = ProductGroup.Fruits.CompareTo(ProductGroup.Vegetables); // -1

// Implements comparison operators (<,<=,>,>=) if the underlyng type (like int) has comparison operators itself.
var isBigger = ProductGroup.Fruits > ProductGroup.Vegetables;       

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

Always-valid vs maybe-valid Smart Enum

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 myEnum = (MyEnum)42;
bool isDefined = Enum.IsDefined(myEnum); // false

Usually, having invalid enumeration items is not a requirement, that's why - by default - a Smart Enum 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 a Smart Enum, like our ProductType, would lead to a UnknownEnumIdentifierException when trying to convert a string to a ProductType.

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

// Explicit cast will lead to identical outcome as the line above
ProductType productType = (ProductType)"SomeValue";

The solution in such use cases is to use [SmartEnum<T>(IsValidatable = true)].
A validatable Smart Enum provides:

  • additional property bool IsValid for checking the validity
  • additional (guard) method void EnsureValid() which throws an InvalidOperationException if item is not valid

A validatable Smart Enum can be either a class or a readonly struct. An always-valid Smart Enum can be a class only because the creation of a struct can bypass any validation by using the default constructor or the keyword default.

Implementation of a validatable Smart Enum:

[SmartEnum<string>(IsValidatable = true)]
public sealed partial class OtherProductType
{
   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 provided key must not be changed, so it becomes 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 one.
[SmartEnum<string>(IsValidatable = true)]
public sealed partial class OtherProductType
{
   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);
   }
}

Make use of abstract static members

The property Items and methods Get, TryGet, Validate, Parse and TryParse are implementations of static abstract members of interfaces IEnum<TKey, T, TValidationError> and IParsable<T>. All interfaces are implemented by the Source Generator. Use generics to access aforementioned members without knowing the concrete type.

// Use T.Items to get all items.
PrintAllItems<ProductType, string>();

private static void PrintAllItems<T, TKey>()
   where T : IEnum<TKey, T, ValidationError>
   where TKey : notnull
{
   Console.WriteLine($"Print all items of '{typeof(T).Name}':");

   foreach (T item in T.Items)
   {
      Console.WriteLine($"Item: {item}");
   }
}

------------

// Use T.Get/TryGet/Validate to get the item for provided key.
Get<ProductType, string>("Groceries");

private static void Get<T, TKey>(TKey key)
   where T : IEnum<TKey, T, ValidationError>
   where TKey : notnull
{
   T item = T.Get(key);

   Console.WriteLine($"Key '{key}' => '{item}'");
}

Custom fields, properties and methods

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 to handle a specific item. Having a Smart Enum, like the ProductType, we can add further fields, properties and methods as to any other classes.

Custom fields and properties

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

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

   public bool RequiresFoodVendorLicense { get; }
}

Custom methods

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

[SmartEnum<string>]
public sealed partial class ProductType
{
   public static readonly ProductType Groceries = new("Groceries");
   public static readonly ProductType Housewares = new("Housewares");

   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. via inheritance

Option 1: using delegates

[SmartEnum<string>]
public sealed partial class ProductType
{
   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

[SmartEnum<string>]
public partial class ProductType
{
   public static readonly ProductType Groceries = new("Groceries");
   public static readonly ProductType Housewares = new HousewaresProductType();

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

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

      public override void Do()
      {
         // do something else
      }
   }
}

Generic derived types

Derived types of a Smart Enum can use generics.

[SmartEnum<string>]
public partial class Operator
{
   public static readonly Operator Item1 = new("Operator 1");
   public static readonly Operator Item2 = new GenericOperator<int>("Operator 2");
   public static readonly Operator Item3 = new GenericOperator<decimal>("Operator 3");
   public static readonly Operator Item4 = new GenericOperator<int>("Operator 4");

   private sealed class GenericOperator<T> : Operator
   {
      public GenericOperator(string key)
         : base(key)
      {
      }
   }
}

Switch-case/Map

It is not possible to use the standard switch-case clause with non-constant values, so we need a replacement for that. The method Switch provides a pair of 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 Analyzer ensures that all items are present in the method call exactly once.

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);

// Map an item to another instance
returnValue = productType.Map(ProductType.Groceries, "Map: Groceries",
                              ProductType.Housewares, "Map: Housewares");

// Prints "Map: Groceries"
Console.WriteLine(returnValue);

There are 2 other overloads to pass an argument to the delegates without creation of a closure. Use tuples, like (ILogger Logger, string OtherParam), to provide more than one value to the delegates.

ILogger logger = ...;

// Switch-case with an Action<TParam>
productType.Switch(logger,
                   ProductType.Groceries, static l => l.Information("Switch with Action: Groceries"),
                   ProductType.Housewares, static l => l.Information("Switch with Action: Housewares"));

// Switch case with a Func<TParam, TResult>
returnValue = productType.Switch(logger,
                                 ProductType.Groceries, static l => "Switch with Func<T>: Groceries",
                                 ProductType.Housewares, static l => "Switch with Func<T>: Housewares");

Customizing

Thanks to Roslyn Source Generators, we can customize virtually anything. Following customization options are currently available.

Key member generation

The key member is generated by the source generator.
Use KeyMemberName, KeyMemberAccessModifier and KeyMemberKind to change the generation of the key member.

Example: Let source generator generate field private readonly string _name; instead of property public string Key { get; } (Default).

[SmartEnum<string>(KeyMemberName = "_name",
                   KeyMemberAccessModifier = ValueObjectAccessModifier.Private,
                   KeyMemberKind = ValueObjectMemberKind.Field)]
public sealed partial class ProductType
{
   public static readonly ProductType Groceries = new("Groceries");
   public static readonly ProductType Housewares = new("Housewares");
}

--------------------------
----- generate code ------

partial class ProductType
{
  ...
  
  private readonly string _name;

  ...

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.

[SmartEnum<string>]
public sealed partial class ProductType
{
   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();
   }
}

Additional fields and properties are passed to the method as well (see DisplayName below):

[SmartEnum<string>]
public sealed partial class ProductType
{
   public static readonly ProductType Groceries = new("Groceries", "Display name for groceries");
   public static readonly ProductType Housewares = new("Housewares", "Display name for housewares");

   public string DisplayName { get; }

   static partial void ValidateConstructorArguments(ref string key, ref string displayName)
   {
      // validate
   }
}

A validatable enum gets an additional parameter bool isValid which is an indication what kind of item is being created.

[SmartEnum<string>(IsValidatable = true)]
public sealed partial class ProductType
{
   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();
   }
}

Custom equality comparer

By default, the source generator is using the default implementation of Equals and GetHashCode, except for strings. If the key member is a string, then the source generator is using StringComparer.OrdinalIgnoreCase.

The reason strings are not using default implementation is, because I encountered very few use cases where the comparison must be performed case-sensitive. Most often, case-sensitive string comparisons are bugs because the developers have forgotten to pass appropriate (case-insensitive) comparer.

Use ValueObjectKeyMemberEqualityComparerAttribute<TComparerAccessor, TMember> to define an equality comparer for comparison of key members and for computation of the hash code. Use one of the predefined ComparerAccessors or implement a new one (see below).

The example below changes the comparer from OrdinalIngoreCase to Ordinal.

[SmartEnum<string>]
[ValueObjectKeyMemberEqualityComparer<ComparerAccessors.StringOrdinal, string>]
public sealed partial class ProductType
{
   public static readonly ProductType Groceries = new("Groceries");
   public static readonly ProductType Housewares = new("Housewares");
}

Implement the interface IEqualityComparerAccessor<T> to create a new custom accessor. The accessor has 1 static property that returns an instance of IEqualityComparer<T>. The generic type T is the type of the member to compare.

public interface IEqualityComparerAccessor<in T>
{
   static abstract IEqualityComparer<T> EqualityComparer { get; }
}

Implementation of an accessor for members of type string.

public class StringOrdinal : IEqualityComparerAccessor<string>
{
  public static IEqualityComparer<string> EqualityComparer => StringComparer.Ordinal;
}

Predefined accessors in static class ComparerAccessors:

// Predefined:
ComparerAccessors.StringOrdinal
ComparerAccessors.StringOrdinalIgnoreCase
ComparerAccessors.CurrentCulture
ComparerAccessors.CurrentCultureIgnoreCase
ComparerAccessors.InvariantCulture
ComparerAccessors.InvariantCultureIgnoreCase
ComparerAccessors.Default<T>; // e.g. ComparerAccessors.Default<string> or ComparerAccessors.Default<int>

Custom comparer

Use ValueObjectKeyMemberComparerAttribute<TComparerAccessor, TMember> to specify a comparer. Use one of the predefined ComparerAccessors or implement a new one (see below).

Please note that this section is about implementation of IComparable<T> and IComparer<T>. Don't confuse the IComparer<T> with IEqualityComparer<T> which is being used for equality comparison and the computation of the hash code.

[SmartEnum<string>]
[ValueObjectKeyMemberComparer<ComparerAccessors.StringOrdinal, string>]
public sealed partial class ProductType
{
   public static readonly ProductType Groceries = new("Groceries");
   public static readonly ProductType Housewares = new("Housewares");
}

Implement the interface IComparerAccessor<T> to create a new custom accessor. The accessor has 1 static property that returns an instance of IComparer<T>. The generic type T is the type of the member to compare.

public interface IComparerAccessor<in T>
{
   static abstract IComparer<T> Comparer { get; }
}

Implementation of an accessor for members of type string.

public class StringOrdinal : IComparerAccessor<string>
{
    public static IComparer<string> Comparer => StringComparer.OrdinalIgnoreCase;
}

Predefined accessors in static class ComparerAccessors:

// Predefined:
ComparerAccessors.StringOrdinal
ComparerAccessors.StringOrdinalIgnoreCase
ComparerAccessors.CurrentCulture
ComparerAccessors.CurrentCultureIgnoreCase
ComparerAccessors.InvariantCulture
ComparerAccessors.InvariantCultureIgnoreCase
ComparerAccessors.Default<T>; // e.g. ComparerAccessors.Default<string> or ComparerAccessors.Default<int>

Skip implementation of IComparable/IComparable<T>

Use SmartEnumAttribute<T> to set SkipIComparable to true to disable the implementation of IComparable and IComparable<T>.

[SmartEnum<int>(SkipIComparable = true)]
public sealed partial class ProductGroup
{

Implementation of comparison operators

Use SmartEnumAttribute<T> to set ComparisonOperators to OperatorsGeneration.None to disable the implementation of comparison operators: >, >=, <, <=. Set the property to OperatorsGeneration.DefaultWithKeyTypeOverloads to generate additional operators to be able to compare a Smart Enum with a value of the key-member type.

[SmartEnum<int>(ComparisonOperators = OperatorsGeneration.None)]
public sealed partial class ProductGroup
{

Skip implementation of IParsable<T>

Use SmartEnumAttribute<T> to set SkipIParsable to true to disable the implementation of IParsable<T>.

[SmartEnum<int>(SkipIParsable = true)]
public sealed partial class ProductGroup
{

Skip implementation of IFormattable

Use SmartEnumAttribute<T> to set SkipIFormattable to true to disable the implementation of IFormattable.

[SmartEnum<int>(SkipIFormattable = true)]
public sealed partial class ProductGroup
{

Skip implementation of ToString

Use SmartEnumAttribute<T> to set SkipToString to true to disable the implementation of the method ToString().

[SmartEnum<int>(SkipToString = true)]
public sealed partial class ProductGroup
{

Convert from/to non-key type

With ValueObjectFactoryAttribute<T> you can implement additional methods to be able to convert an item from/to type T. This conversion can be one-way (T -> Smart Enum) or two-way (T <-> Smart Enum).

Conversion from a string allows ASP.NET Model Binding to bind Smart Enums with any key-member type.

Example: Given is an int-based Smart Enum MyEnum.

[SmartEnum<int>]
public sealed partial class MyEnum
{
   public static readonly MyEnum Item1 = new(1);
}

The Smart Enum MyEnum must be convertible from specific strings, e.g. the value =1= should return Item1. By applying ValueObjectFactoryAttribute<string> the source generator adds the interface IValueObjectFactory<MyEnum, string> which forces you to implement new method Validate(string, IFormatProvider, out MyEnum). With new Validate we have a one-way conversion, i.e. from string to MyEnum.

[SmartEnum<int>]
[ValueObjectFactory<string>]
public sealed partial class MyEnum
{
   public static readonly MyEnum Item1 = new(1);

   public static ValidationError? Validate(string? value, IFormatProvider? provider, out MyEnum? item)
   {
      switch (value)
      {
         case "=1=":
            item = Item1;
            return null;
            
         ...
      }
   }
}

Two-way conversion is required if the type T must be used for serialization/deserialization. This can be achieved by using the property UseForSerialization = SerializationFrameworks.All. This leads to implementation of the interface IValueObjectConvertable<string> which contains the method T ToValue(). The serialization frameworks (like System.Text.Json) specified by SerializationFrameworks are starting to prefer the newly implemented methods instead of using the key-member type, which is int in our example.

[SmartEnum<int>]
[ValueObjectFactory<string>(UseForSerialization = SerializationFrameworks.All)]
public sealed partial class EnumWithFactory
{
   public static readonly EnumWithFactory Item1 = new(1);
   public static readonly EnumWithFactory Item2 = new(2);

   public static ValidationError? Validate(string? value, IFormatProvider? provider, out EnumWithFactory? item)
   {
      switch (value)
      {
         case "=1=":
            item = Item1;
            return null;
         ...
      }
   }

   public string ToValue()
   {
      return $"={Key}=";
   }
}

Hide fields and properties from Source Generator and Analyzer

Use this feature with caution!

A Smart Enum must be immutable. Hiding a member from the Generator and Analyzer means that there is no validation of this member anymore.

Use ValueObjectMemberIgnoreAttribute to hide a member.

[SmartEnum<string>]
public sealed partial class ProductType
{
   public static readonly ProductType Groceries = new("Groceries");
   public static readonly ProductType Housewares = new("Housewares");

   // With ValueObjectMemberIgnoreAttribute the Analyzer doesn't emit a compiler error that the member is not read-only.
   [ValueObjectMemberIgnore]
   private string _someValue;
}

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 project with Smart Enums depend on corresponding 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 activate generation of additional code that 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

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()));
               })

An example for minimal apis:

var builder = WebApplication.CreateBuilder();

builder.Services
       .ConfigureHttpJsonOptions(options => options.SerializerOptions
                                                   .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 project with 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 activates generation of additional code that 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.

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 Minimal Api Parameter Binding and 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 string parameter, then there is no JSON conversion in play but ASP.NET Core Model Binding. Besides model binding, i.e., conversion from query string or route parameter to a Smart Enum, there is model validation as well.

See section Convert from/to non-key type to improve model binding by adding a new factory method that converts a Smart Enum from a string.

Minimal Api

The parameter binding of Minimal Apis in .NET 7 is still quite primitive in comparison to the model binding of MVC controllers. To make a type bindable it has to implement either TryParse or BindAsync. A Smart Enum implements TryParse (interface IParsable<T>) by default, so it can be used with Minimal Apis without any changes.

At the moment, all means (i.e. TryParse and BindAsync) doesn't allow to pass custom validation errors to be returned to the client. The only information we can pass is an indication whether the parameter could be bound or not.

ASP.NET Core MVC (Controllers)

ASP.NET MVC gives us more control during model binding. For example, if we expect from client a ProductType and receive the (invalid) value SomeValue, then the ASP.NET Core ModelState must become 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.

Place the "ValueObjectModelBinderProvider" before default providers, so they don't try to bind value objects.

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

Support for Entity Framework Core

Optional nuget packages:
Thinktecture.Runtime.Extensions.EntityFrameworkCore5
Thinktecture.Runtime.Extensions.EntityFrameworkCore6
Thinktecture.Runtime.Extensions.EntityFrameworkCore7
Thinktecture.Runtime.Extensions.EntityFrameworkCore8

Starting with Entity Framework Core 2.1 we've got the feature Value Conversion. By providing a value converter, EF Core can convert a Smart Enum (like ProductType) to and from a primitive type (like string) when persisting the data and 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, you can install the appropriate Nuget package for EF Core 5, EF Core 6, EF Core 7 or EF Core 8 and use the extension method AddValueObjectConverters to register the value converters for you.

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

      modelBuilder.AddValueObjectConverters(validateOnWrite: true);
   }
}

You can provide a delegate to adjust the configuration of Smart Enums and Value Objects.

modelBuilder.AddValueObjectConverters(validateOnWrite: true,
               configureEnumsAndKeyedValueObjects: property =>
                                                   {
                                                      if (property.ClrType == typeof(ProductType))
                                                         property.SetMaxLength(20);
                                                   });

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

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

services
   .AddDbContext<DemoDbContext>(builder => builder
                                           .UseValueObjectValueConverter(validateOnWrite: true,
                                                                         configureEnumsAndKeyedValueObjects: property =>
                                                                               {
                                                                                 if (property.ClrType == typeof(ProductType))
                                                                                    property.SetMaxLength(20);
                                                                               })

Logging (v6.1.0 or higher)

Logging can be activated in the csproj-file. Define the property ThinktectureRuntimeExtensions_SourceGenerator_LogFilePath pointing to an existing(!) folder (like C:\temp\). You can provide a file name (like samples_logs.txt) which is being used as a template for creation of a unique log file name like samples_logs_20230322_220653_19c0d6c18ec14512a1acf97621912abb.txt.

Please note, that there will be more than 1 log file (per project) because IDEs (Rider/VS) usually create 1 Source Generator for constant running in the background, and 1 for each build/rebuild of a project. Unless, ThinktectureRuntimeExtensions_SourceGenerator_LogFilePathMustBeUnique is set to false.

With ThinktectureRuntimeExtensions_SourceGenerator_LogLevel you can specify one of the following log levels: Trace, Debug, Information (DEFAULT), Warning, Error.

<Project Sdk="Microsoft.NET.Sdk">
   <PropertyGroup>
      ...

      <ThinktectureRuntimeExtensions_SourceGenerator_LogFilePath>C:\temp\samples_logs.txt</ThinktectureRuntimeExtensions_SourceGenerator_LogFilePath>
      <ThinktectureRuntimeExtensions_SourceGenerator_LogLevel>information</ThinktectureRuntimeExtensions_SourceGenerator_LogLevel>
      <ThinktectureRuntimeExtensions_SourceGenerator_LogFilePathMustBeUnique>false</ThinktectureRuntimeExtensions_SourceGenerator_LogFilePathMustBeUnique> 
      
   </PropertyGroup>

If the logger throws an exception, for example due to insufficient file system access permissions, then the logger will try to write the exception into a temp file. You can find the file ThinktectureRuntimeExtensionsSourceGenerator.log in the temp folder of the user the IDE/CLI is running with.

Real-world use cases and ideas

I started to write down some examples I used in the past to show the developers the benefits of value objects and smart enums.

CSV-Importer-Type

Imagine we need an importer for daily and monthly sales.

The CSV for daily sales has the following columns: id,datetime,volume. The datetime has a format yyyyMMdd hh:mm.

id,datetime,volume
1,20230425 10:45,345.67

The CSV for monthly sales differs from time to time. It can have either 3 columns volume,datetime,id or 4 columns volume,quantity,id,datetime. If the CSV has 3 columns, then the datetime format is the same in daily imports (yyyyMMdd hh:mm), but if there are 4 columns, then the format is yyyy-MM-dd.

volume,datetime,id
123.45,20230426 11:50,2

OR

volume,quantity,id,datetime
123.45,42,2,2023-04-25

We are interested in id, volume and datetime only.

Regular C#-enum

With regular C#-enums we have to use either switch-case or if-else. The readability of the code is ok, but not the best one. Furthermore, if there will be added another type in the future, say Yearly, then we have to remember to adjust the switch-case otherwise we get ArgumentOutOfRangeException.

public enum SalesCsvImporterType
{
   Daily,
   Monthly
}

// Usage
var type = SalesCsvImporterType.Monthly;
var csv = ...;

using var textReader = new StringReader(csv);
using var csvReader = new CsvReader(textReader, new CsvConfiguration(CultureInfo.InvariantCulture) { HasHeaderRecord = true });

csvReader.Read();
csvReader.ReadHeader();

while (csvReader.Read())
{
   int articleId;
   decimal volume;
   DateTime dateTime;

   switch (type)
   {
      case SalesCsvImporterType.Daily:
         articleId = csvReader.GetField<int>(0);
         volume = csvReader.GetField<decimal>(2);
         dateTime = DateTime.ParseExact(csvReader[1], "yyyyMMdd hh:mm", null);
         break;

      case SalesCsvImporterType.Monthly:
         articleId = csvReader.GetField<int>(2);
         volume = csvReader.GetField<decimal>(0);

         dateTime = csvReader.HeaderRecord?.Length == 3
                       ? DateTime.ParseExact(csvReader[1], "yyyyMMdd hh:mm", null) // same as "Daily"
                       : DateTime.ParseExact(csvReader[3], "yyyy-MM-dd", null);
         break;

      default:
         throw new ArgumentOutOfRangeException();
   }

   logger.Information("CSV ({Type}): Article-Id={Id}, DateTime={DateTime}, Volume={Volume}", type, articleId, dateTime, volume);
}

Smart Enum

As an alternative to switch-case, we can move the parts that differ to a Smart Enum. The benefits are: (1) the actual importer is easier to read and to maintain, and (2) it is impossible to forget to adjust the code if another type, like Yearly, is implemented in the future.

[SmartEnum<string>(KeyMemberName = "Name")]
public sealed partial class SalesCsvImporterType
{
   // Constructor is generated according to fields and properties of the smart enum.
   // This prevents "forgetting" to provide values to members.
   public static readonly SalesCsvImporterType Daily = new(name: "Daily", articleIdIndex: 0, volumeIndex: 2, GetDateTimeForDaily);
   public static readonly SalesCsvImporterType Monthly = new(name: "Monthly", articleIdIndex: 2, volumeIndex: 0, GetDateTimeForMonthly);

   public int ArticleIdIndex { get; }
   public int VolumeIndex { get; }

   // Alternative: use inheritance instead of delegate to have different implementations for different types
   private readonly Func<CsvReader, DateTime> _getDateTime;
   public DateTime GetDateTime(CsvReader csvReader) => _getDateTime(csvReader);

   private static DateTime GetDateTimeForDaily(CsvReader csvReader)
   {
      return DateTime.ParseExact(csvReader[1] ?? throw new Exception("Invalid CSV"),
                                 "yyyyMMdd hh:mm",
                                 null);
   }

   private static DateTime GetDateTimeForMonthly(CsvReader csvReader)
   {
      return csvReader.HeaderRecord?.Length == 3
                ? GetDateTimeForDaily(csvReader)
                : DateTime.ParseExact(csvReader[3] ?? throw new Exception("Invalid CSV"),
                                      "yyyy-MM-dd",
                                      null);
   }
}

The smart enum SalesCsvImporterType eliminates the need for a switch-case.

var type = SalesCsvImporterType.Monthly;
var csv = ...;

using var textReader = new StringReader(csv);
using var csvReader = new CsvReader(textReader, new CsvConfiguration(CultureInfo.InvariantCulture) { HasHeaderRecord = true });

csvReader.Read();
csvReader.ReadHeader();

while (csvReader.Read())
{
   var articleId = csvReader.GetField<int>(type.ArticleIdIndex);
   var volume = csvReader.GetField<decimal>(type.SalesVolumeIndex);
   var dateTime = type.GetDateTime(csvReader);

   logger.Information("CSV ({Type}): Article-Id={Id}, DateTime={DateTime}, Volume={Volume}", type, articleId, dateTime, volume);
}
Clone this wiki locally