-
Notifications
You must be signed in to change notification settings - Fork 1
Version 5.x.x Smart Enums
- Getting started
- Customizing
- JSON serialization
- MessagePack serialization
- Support for ASP.NET Core Model Binding
- Support for Entity Framework Core
- Limitations
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
.
- TargetFramework: .NET Standard 2.1 or higher (like .NET Core 3.1, .NET 5)
- Language version: C# 9.0
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.
public partial class ProductType : IEnum<string>
{
public static readonly ProductType Groceries = new("Groceries");
public static readonly ProductType Housewares = new("Housewares");
}
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
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 anInvalidOperationException
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 areadonly 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 befalse
, 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);
}
}
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
}
}
}
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);
Due to the fact, that the code is generated on-the-fly we can customize virtually anything. Following customization options are currently available.
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();
}
}
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"
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 usingEqualityComparer<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");
}
Depending on the concrete JSON library you use, you need a different Nuget package:
There are 2 options to make the Smart Enums JSON convertible.
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.
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
withSystem.Text.Json
- Use
ValueObjectNewtonsoftJsonConverter
withNewtonsoft.Json
Don't be confused by the names
ValueObjectJsonConverterFactory
andValueObjectNewtonsoftJsonConverter
. 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()));
})
There are 2 options to make the Smart Enums MessagePack serializable.
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.
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);
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()));
})
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.
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));
});
}
}
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);
}
}
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)
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");
}