Skip to content

Version 8.x.x Smart Enums

Pawel Gerr edited this page May 8, 2025 · 1 revision

Smart Enums

Smart Enums provide a powerful alternative to traditional C# enums, offering type-safety, extensibility, and rich behavior. This library implements Smart Enums through source generation, combining the simplicity of enums with the flexibility of classes.

Why Smart Enums?

Traditional C# enums have several limitations:

  • They allow only numbers as underlying values
  • They can't include additional data or behavior
  • They allow invalid values through direct casting of a number
  • They require (non-exhaustive) switch and if statements for different behaviors

Smart Enums solve these problems by providing:

  • ✨ Support for any type as the underlying value (e.g. string)
  • 🛡️ Type-safety with compile-time validation
  • 🧩 Ability to include additional data and behavior
  • 🔄 Built-in pattern matching with Switch/Map methods
  • 🔌 Seamless integration with JSON serializers, MessagePack, Entity Framework Core and ASP.NET

This library provides an easy way to implement Smart Enums with Roslyn Analyzers and Code Fixes guiding you through the process. Additional packages add support for System.Text.Json, Newtonsoft.Json, MessagePack, Entity Framework Core and ASP.NET Core Model Binding.

Note: Some types in the library start with ValueObject... instead of SmartEnum.... These types are shared between Smart Enums and Value Objects, as a Smart Enum can be thought of as a specialized Value Object with limited set of valid instances.

Getting Started

Installation

Prerequisites

  • .NET SDK: Version 8.0.400 or higher (for building projects)
  • C# Version: C# 11 or higher (for generated code)

Core Package

Install core NuGet package:

Thinktecture.Runtime.Extensions

Optional Packages

Depending on your needs, you may want to install additional packages:

Package Purpose
Thinktecture.Runtime.Extensions.Json JSON serialization support using System.Text.Json
Thinktecture.Runtime.Extensions.Newtonsoft.Json JSON serialization support using Newtonsoft.Json
Thinktecture.Runtime.Extensions.MessagePack MessagePack serialization support
Thinktecture.Runtime.Extensions.AspNetCore ASP.NET Core model binding support
Thinktecture.Runtime.Extensions.EntityFrameworkCore6 Thinktecture.Runtime.Extensions.EntityFrameworkCore7 Thinktecture.Runtime.Extensions.EntityFrameworkCore8 Thinktecture.Runtime.Extensions.EntityFrameworkCore9 Entity Framework Core support (versions 6-9)

Basic Concepts

Key concepts:

  1. Key Member Type: Each Smart Enum can have an underlying type (called key-member type) which can be of any type, not just numbers
  2. Type Safety: Unlike regular enums, Smart Enums (by default) prevent creation of invalid enum items
  3. Rich Behavior: Add properties and methods to your enum items
  4. Pattern Matching: Built-in Switch/Map methods are exhaustive, i.e. no more forgetting to handle an item

New in v7: You can now create keyless Smart Enums without an underlying type. These are perfect for use cases where you just need a set of valid instances without associated values for serialization and deserialization.

What You Implement

Smart Enums are easy to implement. Here are three different examples to start with:

1. String-based Smart Enum

// Use string as the key type - perfect for human-readable identifiers
[SmartEnum<string>]
public partial class ProductType
{
   // Define your enum items as static readonly fields
   // and pass the corresponding string to constructor
   public static readonly ProductType Groceries = new("Groceries");
   public static readonly ProductType Housewares = new("Housewares");
}

2. Integer-based Smart Enum

// Use int as the key type - great for persistence in a database
[SmartEnum<int>]
public partial class ProductGroup
{
   public static readonly ProductGroup Fruits = new(1);
   public static readonly ProductGroup Vegetables = new(2);
}

3. Keyless Smart Enum (New in v7)

// No key type needed - useful for cases that don't require serialization
[SmartEnum] 
public partial class SalesCsvImporterType 
{ 
   // Items don't need any key, just the instance itself
   public static readonly SalesCsvImporterType Daily = new(); 
   public static readonly SalesCsvImporterType Monthly = new(); 
} 

Key Points:

  • Mark your class with [SmartEnum] or [SmartEnum<T>] where T is your key type
  • Make the class partial
  • Define enum items as public static readonly fields

What is implemented for you

Basic Operations

[SmartEnum<string>]
public partial class ProductType
{
    // The source generator creates a private constructor
    public static readonly ProductType Groceries = new("Groceries");
}

// Enumeration over all defined items
IReadOnlyList<ProductType> allTypes = ProductType.Items;

// Value retrieval
ProductType productType = ProductType.Get("Groceries");        // Get by key (throws if not found)
ProductType productType = (ProductType)"Groceries";            // Same as above but by using a cast
bool found = ProductType.TryGet("Groceries", out var productType);  // Safe retrieval (returns false if not found)

// Validation with detailed error information
ValidationError? error = ProductType.Validate("Groceries", null, out ProductType? productType);

// IParsable<T> (useful for Minimal APIs)
bool parsed = ProductType.TryParse("Groceries", null, out ProductType? parsedType);

// IFormattable (e.g. for numeric keys)
string formatted = ProductGroup.Fruits.ToString("000", CultureInfo.InvariantCulture);  // "001"

// IComparable
int comparison = ProductGroup.Fruits.CompareTo(ProductGroup.Vegetables);
bool isGreater = ProductGroup.Fruits > ProductGroup.Vegetables;  // Comparison operators

Type Conversion and Equality

// Implicit conversion to key type
string key = ProductType.Groceries;  // Returns "Groceries"

// Equality comparison
bool equal = ProductType.Groceries.Equals(ProductType.Groceries);
bool equal = ProductType.Groceries == ProductType.Groceries;  // Operator overloading
bool notEqual = ProductType.Groceries != ProductType.Housewares;

// Methods inherited from Object
int hashCode = ProductType.Groceries.GetHashCode();
string key = ProductType.Groceries.ToString();  // Returns "Groceries"

// TypeConverter
var converter = TypeDescriptor.GetConverter(typeof(ProductType));
string? keyStr = (string?)converter.ConvertTo(ProductType.Groceries, typeof(string));
ProductType? converted = (ProductType?)converter.ConvertFrom("Groceries");

Pattern Matching with Switch/Map

All Switch/Map methods are exhaustive by default ensuring all cases are handled correctly.

ProductType productType = ProductType.Groceries;

// Execute different actions based on the enum value (void return)
productType.Switch(
    groceries: () => Console.WriteLine("Processing groceries order"),
    housewares: () => Console.WriteLine("Processing housewares order")
);

// Transform enum values into different types
string department = productType.Switch(
    groceries: () => "Food and Beverages",
    housewares: () => "Home and Kitchen"
);

// Direct mapping to values
// Direct mapping to values - clean and concise
decimal discount = productType.Map(
    groceries: 0.05m,    // 5% off groceries
    housewares: 0.10m    // 10% off housewares
);

Partial Pattern Matching with SwitchPartially/MapPartially

For scenarios where you only need to handle specific enum values and provide a default behavior for others, you can enable partial methods:

// Enable partial methods in your Smart Enum definition
[SmartEnum<string>(
    SwitchMethods = SwitchMapMethodsGeneration.DefaultWithPartialOverloads,
    MapMethods = SwitchMapMethodsGeneration.DefaultWithPartialOverloads)]
public partial class ProductType
{
    public static readonly ProductType Groceries = new("Groceries");
    public static readonly ProductType Housewares = new("Housewares");
}

These methods let you handle specific cases while providing a default for unspecified items:

// SwitchPartially with Action
productType.SwitchPartially(
    @default: item => Console.WriteLine($"Default handling for {item}"),
    groceries: () => Console.WriteLine("Special handling for groceries")
);

// Omitting default handler is also possible
productType.SwitchPartially(
    groceries: () => Console.WriteLine("Only handle groceries specifically")
);

// MapPartially with values
string result = productType.MapPartially(
    @default: "Standard department",
    groceries: "Food section"
);

Like standard Switch/Map methods, partial versions also offer overloads for state passing to prevent closures:

ILogger logger = ...;

// SwitchPartially with state
productType.SwitchPartially(
    logger,
    @default: static (l, item) => l.LogInformation("Default: {Item}", item),
    groceries: static l => l.LogInformation("Groceries specific logic")
);

For optimal performance Smart Enums provide overloads that prevent closures.

ILogger logger = ...;

// Prevent closures by passing the parameter as first method argument
productType.Switch(logger,
    groceries: static l => l.LogInformation("Processing groceries order"),
    housewares: static l => l.LogInformation("Processing housewares order")
);

// Use a tuple to pass multiple values
var context = (Logger: logger, OrderId: "123");

productType.Switch(context,
    groceries: static ctx => ctx.Logger.LogInformation("Processing groceries order {OrderId}", ctx.OrderId),
    housewares: static ctx => ctx.Logger.LogInformation("Processing housewares order {OrderId}", ctx.OrderId)
);

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 UnknownSmartEnumIdentifierException when trying to convert a string to a ProductType.

// Throws UnknownSmartEnumIdentifierException
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 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.
  • The second constructor argument isValid must always be false, i.e., don't try to make an invalid item to a valid one.
[SmartEnum<int>(IsValidatable = true)]
public partial class ProductGroup
{
   public static readonly ProductGroup Apple = new(1, "Apple");
   public static readonly ProductGroup Orange = new(2, "Orange");

   public string DisplayName { get; }
   
   private static ProductGroup CreateInvalidItem(int key)
   {
      // Provide custom values for invalid items
      return new(key, false, "Unknown product group");
   }
}

Use guard clause EnsureValid() to prevent invalid items from executing certain operations or IsValid to check whether the item is a valid one.

public class MyClass
{
   public void ProcessOrder(ProductGroup productGroup)
   {
      productGroup.EnsureValid(); // Throws InvalidOperationException if item is not valid

      // Process the order...
   }
}

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 ISmartEnum<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 : ISmartEnum<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 : ISmartEnum<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

Smart Enums can have multiple properties and even reference other Smart Enums.

[SmartEnum<int>]
public partial class ProductGroup
{
   // The constructor is genereted according to defined fields and properties
   public static readonly ProductGroup Apple = new(1, "Apple", ProductCategory.Fruits);
   public static readonly ProductGroup Orange = new(2, "Orange", ProductCategory.Fruits);

   // Custom properties
   public string DisplayName { get; }
   public ProductCategory Category { 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 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) 3 options:

  • using the UseDelegateFromConstructorAttribute
  • using delegates (Func<T>, Action, etc.)
  • via inheritance

Option 1: using UseDelegateFromConstructorAttribute

The UseDelegateFromConstructorAttribute provides a clean way to implement item-specific behavior without manually managing delegates:

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

   [UseDelegateFromConstructor]
   public partial void Do();

   private static void DoForGroceries()
   {
      // Implementation for Groceries
   }

   private static void DoForHousewares()
   {
      // Implementation for Housewares
   }
}

The source generator will automatically:

  1. Create a private delegate field
  2. Assign the field with method/delegate passed to the constructor
  3. Implement the partial method to call the delegate

Option 2: using delegates

[SmartEnum<string>]
public 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 3: 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 class HousewaresProductType : ProductType
   {
      public HousewaresProductType()
         : base("Housewares")
      {
      }

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

Nested Types

New in v8: Smart Enums can be defined as nested classes.

public partial class Container
{
    [SmartEnum<string>]
    public partial class NestedProductType
    {
        public static readonly NestedProductType Basic = new("BASIC");
        public static readonly NestedProductType Premium = new("PREMIUM");
    }
}

Inheritance

Smart Enums support inheritance

[SmartEnum<string>]
public partial class BaseProductType
{
   public static readonly BaseProductType Standard = new("Standard");
   public static readonly BaseProductType Premium = new PremiumProductType();

   protected virtual decimal GetDiscount() => 0m;
   protected virtual string GetDescription() => "Standard product";

   private sealed class PremiumProductType : BaseProductType
   {
      public PremiumProductType() : base("Premium") { }

      protected override decimal GetDiscount() => 0.1m; // 10% discount
      protected override string GetDescription() => "Premium product with special benefits";
   }
}

// Usage with polymorphic behavior
foreach (var product in BaseProductType.Items)
{
    Console.WriteLine($"{product.Key}: {product.GetDescription()}");
}

Generic derived types

Smart Enums support generic derived types, enabling powerful type-safe specializations:

[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)
      {
      }
   }
}

Base Class Support

Smart Enums can inherit from regular classes to extend their functionality:

public class SomeBaseClass
{
   public int Value { get; }

   public SomeBaseClass(int value)
   {
      Value = value;
   }
}

[SmartEnum<string>]
public partial class EnumWithBaseClass : SomeBaseClass
{
   public static readonly EnumWithBaseClass Item1 = new("item 1", 42);
}

Customization

Smart Enums are highly customizable thanks to Roslyn Source Generators.

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 = AccessModifier.Private,
                   KeyMemberKind = MemberKind.Field)]
public partial class ProductType
{
   public static readonly ProductType Groceries = new("Groceries");
   public static readonly ProductType Housewares = new("Housewares");
}

--------------------------
----- generated 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 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 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 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. Additionally, the analyzer will warn you if you don't provide an equality comparer for a string-based value object.

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 KeyMemberEqualityComparerAttribute<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>]
[KeyMemberEqualityComparer<ComparerAccessors.StringOrdinal, string>]
public 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 KeyMemberComparerAttribute<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>]
[KeyMemberComparer<ComparerAccessors.StringOrdinal, string>]
public 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>

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 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 partial class ProductGroup
{

Implementation of IParsable<T>

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

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

Implementation of IFormattable

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

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

Implementation of ToString

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

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

Conversion from/to non-key type

With ObjectFactoryAttribute<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 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 ObjectFactoryAttribute<string> the source generator adds the interface IObjectFactory<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>]
[ObjectFactory<string>]
public 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 IConvertible<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>]
[ObjectFactory<string>(UseForSerialization = SerializationFrameworks.All)]
public 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 IgnoreMemberAttribute to hide a member.

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

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

OpenAPI/Swashbuckle Integration

Smart Enums can be integrated with Swashbuckle to provide OpenAPI documentation for Web APIs.

Required Package Thinktecture.Runtime.Extensions.Swashbuckle

Configuration

To enable OpenAPI support for Smart Enums, register the Thinktecture OpenAPI filters with dependency injection:

services.AddEndpointsApiExplorer()
        .AddSwaggerGen(options => options.SwaggerDoc("v1", new OpenApiInfo { Title = "My API", Version = "v1" }))
        .AddThinktectureOpenApiFilters();

You can customize the OpenAPI schema generation with options:

services.AddThinktectureOpenApiFilters(options => 
{
    // Configure the schema filter type
    options.SmartEnumSchemaFilter = SmartEnumSchemaFilter.Default;

    // Additional configure how Smart Enums are represented in the schema
    options.SmartEnumSchemaExtension = SmartEnumSchemaExtension.VarNamesFromStringRepresentation;
});

Available Options

Schema Filters

The SmartEnumSchemaFilter option determines how Smart Enums are represented in the OpenAPI schema:

  • Default: Uses enum: [ key1, key2 ]
  • OneOf: Uses oneOf: [ { "title": "key1", "const": "key1" } ]
  • AnyOf: Uses anyOf: [ { "title": "key1", "const": "key1" } ]
  • AllOf: Uses allOf: [ { "title": "key1", "const": "key1" } ]
  • FromDependencyInjection: Resolves implementation of ISmartEnumSchemaFilter from dependency injection

Schema Extensions

The SmartEnumSchemaExtension option controls what additional information is added to the schema:

  • None (default): No additional schema extensions
  • VarNamesFromStringRepresentation: Extends the schema with x-enum-varnames using the string representation of the items
  • VarNamesFromDotnetIdentifiers: Extends the schema with x-enum-varnames using the .NET identifiers of the items
  • FromDependencyInjection: Resolves implementation of ISmartEnumSchemaExtension from dependency injection

Framework Integration

Smart Enums seamlessly integrate with popular .NET frameworks and libraries. This section covers integration with JSON serialization, MessagePack, ASP.NET Core, and Entity Framework Core.

JSON Serialization

Smart Enums support both major JSON serialization libraries in .NET:

Required Packages

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

You have two options for enabling JSON serialization:

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 ThinktectureJsonConverterFactory with System.Text.Json
  • Use ThinktectureNewtonsoftJsonConverterFactory 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 ThinktectureJsonConverterFactory()));
               })

An example for minimal apis:

var builder = WebApplication.CreateBuilder();

builder.Services
       .ConfigureHttpJsonOptions(options => options.SerializerOptions
                                                   .Converters
                                                   .Add(new ThinktectureJsonConverterFactory()));

The code for Newtonsoft.Json is almost identical:

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

MessagePack Serialization

MessagePack is a fast and compact binary serialization format. Smart Enums provide full support for MessagePack serialization.

Required Package

Thinktecture.Runtime.Extensions.MessagePack

You have two options for enabling MessagePack serialization:

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 ThinktectureMessageFormatterResolver 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 "ThinktectureMessageFormatterResolver"
var resolver = CompositeResolver.Create(ThinktectureMessageFormatterResolver.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);

ASP.NET Core Integration

For seamless integration with ASP.NET Core we need both, JSON serialization and model binding support.

Tip: See Convert from/to non-key type to customize model binding behavior by implementing string conversion methods.

Minimal Api

The parameter binding of Minimal APIs is not as capable as 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>) if the key implements IParsable<T>, 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 (i.e. a bool) whether the parameter could be bound or not.

ASP.NET Core MVC (Controllers)

Required Package Thinktecture.Runtime.Extensions.AspNetCore

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

Model Binding Configuration Options

The ThinktectureModelBinderProvider supports several configuration options to customize how Smart Enums are handled during model binding:

// Configure with custom options
var provider = new ThinktectureModelBinderProvider(
    skipBindingFromBody: true    // Default: true
);
  • trimStringBasedEnums: When true (default), automatically trims whitespace from string values before doing lookup.
  • skipBindingFromBody: When true (default), skips model binding for values coming from request body, allowing the JSON serializer to handle the conversion instead.

Entity Framework Core Integration

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.

Choose the package that matches your EF Core version:

Thinktecture.Runtime.Extensions.EntityFrameworkCore6
Thinktecture.Runtime.Extensions.EntityFrameworkCore7
Thinktecture.Runtime.Extensions.EntityFrameworkCore8
Thinktecture.Runtime.Extensions.EntityFrameworkCore9

You have three options for setting up EF Core integration:

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 6, EF Core 7, EF Core 8 or EF Core 9 and use the extension method AddThinktectureValueConverters to register the value converters for you.

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

      modelBuilder.AddThinktectureValueConverters(validateOnWrite: true);
   }
}

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

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

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

You can also configure value converters at the entity level using the AddThinktectureValueConverters extension method on EntityTypeBuilder. This approach allows you to apply value converters only to specific entities.

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

      modelBuilder.Entity<Product>(builder =>
      {
         builder.HasKey(p => p.Id);
         
         // Apply value converters to all Smart Enums and Value Objects in this entity
         builder.AddThinktectureValueConverters();
      });
   }
}

You can customize the conversion behavior with additional parameters:

builder.AddThinktectureValueConverters(
    validateOnWrite: true,                // Validate values before writing to database
    useConstructorForRead: true,          // Use constructor instead of factory method when reading
    addConvertersForOwnedTypes: true,     // Apply to owned entities recursively
    configureEnumsAndKeyedValueObjects: property =>
    {
        if (property.ClrType == typeof(ProductType))
            property.SetMaxLength(20);
    });

This method is also available for owned entities through the OwnedNavigationBuilder:

modelBuilder.Entity<Order>(builder =>
{
    builder.OwnsOne(o => o.ShippingDetails, detailsBuilder =>
    {
        // Apply value converters to all Smart Enums and Value Objects in this owned entity
        detailsBuilder.AddThinktectureValueConverters();
    });
});

Method overload for ComplexPropertyBuilder:

In EF Core 8+, you can also use the AddThinktectureValueConverters extension method with ComplexPropertyBuilder to apply value converters to complex types:

modelBuilder.Entity<Product>(builder =>
{
   builder.ComplexProperty(p => p.Details, detailsBuilder =>
   {
      // Apply value converters to all Smart Enums and Value Objects in this complex type
      detailsBuilder.AddThinktectureValueConverters(validateOnWrite: true);
   });
});

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

The other option is to use the extension method UseThinktectureValueConverters for the DbContextOptionsBuilder.

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

Performance Optimizations

FrozenDictionary in .NET 8+

When running on .NET 8 or higher, Smart Enums automatically use FrozenDictionary for internal lookups, providing better performance compared to regular dictionaries.

ReadOnlySpan Support in .NET 9+

String-based Smart Enums in .NET 9+ support ReadOnlySpan<char> operations for improved performance:

[SmartEnum<string>]
public partial class ProductType
{
    public static readonly ProductType Groceries = new("Groceries");
    
    // In .NET 9+, you can use ReadOnlySpan<char> for lookups
    public static bool TryGet(ReadOnlySpan<char> key, out ProductType? item)
    {
        ...
    }
}

Logging

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.

Shipping Method

A common e-commerce requirement is handling different shipping methods, each with their own pricing rules and delivery estimates. Smart Enums can elegantly model this without conditional logic:

[SmartEnum<string>]
public partial class ShippingMethod
{
   public static readonly ShippingMethod Standard = new(
      "STANDARD",
      basePrice: 5.99m,
      weightMultiplier: 0.5m,
      estimatedDays: 5,
      requiresSignature: false);

   public static readonly ShippingMethod Express = new(
      "EXPRESS",
      basePrice: 15.99m,
      weightMultiplier: 0.75m,
      estimatedDays: 2,
      requiresSignature: true);

   public static readonly ShippingMethod NextDay = new(
      "NEXT_DAY",
      basePrice: 29.99m,
      weightMultiplier: 1.0m,
      estimatedDays: 1,
      requiresSignature: true);

   private readonly decimal _basePrice;
   private readonly decimal _weightMultiplier;
   private readonly int _estimatedDays;

   public bool RequiresSignature { get; }

   public decimal CalculatePrice(decimal orderWeight)
   {
      return _basePrice + (orderWeight * _weightMultiplier);
   }

   public DateTime GetEstimatedDeliveryDate()
   {
      return DateTime.Today.AddDays(_estimatedDays);
   }
}

Usage example:

public class OrderProcessor
{
    public OrderSummary ProcessOrder(Order order, ShippingMethod shipping)
    {
        var shippingCost = shipping.CalculatePrice(order.Weight);
        var deliveryDate = shipping.GetEstimatedDeliveryDate();
        
        return new OrderSummary
        {
            OrderTotal = order.SubTotal + shippingCost,
            EstimatedDelivery = deliveryDate,
            RequiresSignature = shipping.RequiresSignature,
            ShippingCost = shippingCost
        };
    }
}

This example demonstrates how Smart Enums can eliminate conditional logic by properly modeling domain concepts:

  1. Each shipping method encapsulates its pricing rules through properties
  2. Business logic is simplified by using declarative properties instead of switch statements
  3. Adding new shipping methods only requires defining their properties

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 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
   [UseDelegateFromConstructor]
   public partial DateTime GetDateTime(CsvReader 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.VolumeIndex);
   var dateTime = type.GetDateTime(csvReader);

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

Discriminator in a JSON Converter

Discriminated union Jurisdiction requires a custom JSON converter for serialization of polymorphic types. The converter needs to know the type of the concrete object to be deserialized. This can be achieved by using a Smart Enum as a discriminator.

public partial class JurisdictionJsonConverter : JsonConverter<Jurisdiction>
{
   public override Jurisdiction? Read(
      ref Utf8JsonReader reader,
      Type typeToConvert,
      JsonSerializerOptions options)
   {
      if (!reader.Read()    // read StartObject
          || !reader.Read() // read PropertyName
          || !Discriminator.TryGet(reader.GetString(), out var discriminator))
         throw new JsonException();

      var jurisdiction = discriminator.ReadJurisdiction(ref reader, options);

      if (!reader.Read()) // read EndObject
         throw new JsonException();

      return jurisdiction;
   }

   public override void Write(
      Utf8JsonWriter writer,
      Jurisdiction value,
      JsonSerializerOptions options)
   {
      value.Switch(
         (writer, options),
         country: static (state, country) =>
            WriteJurisdiction(state.writer, state.options, country, Discriminator.Country),
         federalState: static (state, federalState) =>
            WriteJurisdiction(state.writer, state.options, federalState, Discriminator.FederalState),
         district: static (state, district) =>
            WriteJurisdiction(state.writer, state.options, district, Discriminator.District),
         unknown: static (state, unknown) =>
            WriteJurisdiction(state.writer, state.options, unknown, Discriminator.Unknown)
      );
   }

   private static void WriteJurisdiction<T>(
      Utf8JsonWriter writer,
      JsonSerializerOptions options,
      T jurisdiction,
      string discriminator
   )
      where T : Jurisdiction
   {
      writer.WriteStartObject();

      writer.WriteString("$type", discriminator);

      writer.WritePropertyName(options.PropertyNamingPolicy?.ConvertName("value") ?? "value");
      JsonSerializer.Serialize(writer, jurisdiction, options);

      writer.WriteEndObject();
   }

   [SmartEnum<string>]
   internal partial class Discriminator
   {
      public static readonly Discriminator Country = new("Country", ReadJurisdiction<Jurisdiction.Country>);
      public static readonly Discriminator FederalState = new("FederalState", ReadJurisdiction<Jurisdiction.FederalState>);
      public static readonly Discriminator District = new("District", ReadJurisdiction<Jurisdiction.District>);
      public static readonly Discriminator Unknown = new("Unknown", ReadJurisdiction<Jurisdiction.Unknown>);

      [UseDelegateFromConstructor]
      public partial Jurisdiction? ReadJurisdiction(ref Utf8JsonReader reader, JsonSerializerOptions options);

      private static Jurisdiction? ReadJurisdiction<T>(
         ref Utf8JsonReader reader,
         JsonSerializerOptions options)
         where T : Jurisdiction
      {
         if (!reader.Read() || !reader.Read()) // read PropertyName and value
            throw new JsonException();

         return JsonSerializer.Deserialize<T>(ref reader, options);
      }
   }
}

Usage

// Use JsonConverterAttribute or add the converter to JsonSerializerOptions
[Union]
[JsonConverter(typeof(JurisdictionJsonConverter))]
public abstract partial class Jurisdiction
{
    ...
}

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

var json = JsonSerializer.Serialize<Jurisdiction>(district);
logger.Information(json); //  {"$type":"District","value":"District 42"}

var deserializedJurisdiction = JsonSerializer.Deserialize<Jurisdiction>(json);

// Deserialized jurisdiction: District 42 (District)
logger.Information("Deserialized jurisdiction: {Jurisdiction} ({Type})",
                   deserializedJurisdiction,
                   deserializedJurisdiction?.GetType().Name);

Dispatcher in a Web API

In a web API, we can use Smart Enums to dispatch requests to different implementations or map the enum item to some internals. Similar behavior can be achieved with keyed-services for total flexibility, but ...

  • Flexibility is not always needed
  • Providing of all available options is not the responsibility of the dependency injection
  • Tunneling through the DI container is another indirection which raises the complexity
  • The keys of keyed-services should not be used inside DTOs or as route parameters directly (security, whitelisting)
// Smart enum representing DTO for notification channel types.
[SmartEnum<string>(KeyMemberName = "Name")]
public abstract partial class NotificationChannelTypeDto
{
   public static readonly NotificationChannelTypeDto Email = new NotificationTypeDto<EmailNotificationSender>("email");
   public static readonly NotificationChannelTypeDto Sms = new NotificationTypeDto<SmsNotificationSender>("sms");

   // Add further properties and methods as needed.
   
   public abstract INotificationSender GetNotificationSender(IServiceProvider serviceProvider);

   // Generic derived class that captures the implementation type.
   // Having concrete (generic) type, we get a compile time type check (in comparison to "typeof(EmailNotificationSender)")
   // and we could do advanced stuff inside the derived class if we want to.
   private sealed class NotificationTypeDto<TImplementation> : NotificationChannelTypeDto
      where TImplementation : class, INotificationSender
   {
      public NotificationTypeDto(string key)
         : base(key)
      {
      }

      public override INotificationSender GetNotificationSender(IServiceProvider serviceProvider)
      {
         return serviceProvider.GetRequiredService<TImplementation>();
      }
   }
}

// Supporting interface and classes

public interface INotificationSender
{
   Task SendAsync(string message);
}

public class EmailNotificationSender(ILogger<EmailNotificationSender> logger) : INotificationSender
{
   public Task SendAsync(string message)
   {
      logger.LogInformation("Sending email: {Message}", message);
      return Task.CompletedTask;
   }
}

public class SmsNotificationSender(ILogger<SmsNotificationSender> logger) : INotificationSender
{
   public Task SendAsync(string message)
   {
      logger.LogInformation("Sending sms: {Message}", message);
      return Task.CompletedTask;
   }
}

ASP.NET Core controller or minimal API provide 2 endpoints. One endpoint returns all available channels, the other one sends a notification using the selected channel.

The first endpoint responds with ["email","sms"].

// Web API Controller
[HttpGet("notification/channels")]
public IActionResult GetAvailableChannels()
{
   var channels = NotificationChannelTypeDto.Items.Select(c => c.Name);
   return Ok(channels);
}

// Minimal API
app.MapGet("notification/channels", () =>
{
   var channels = NotificationChannelTypeDto.Items.Select(c => c.Name);
   return Results.Ok(channels);
});

The NotificationChannelTypeDto is automatically validated and bound to the route parameter {type}.

For request POST notification/channels/email and body "Test email" the second endpoints emits a log message Sending email: Test email.

// Web API Controller
[HttpPost("notification/channels/{type}")]
public async Task<IActionResult> SendNotificationAsync(
   NotificationChannelTypeDto type,
   [FromBody] string message,
   [FromServices] IServiceProvider serviceProvider)
{
   var notificationSender = type.GetNotificationSender(serviceProvider);
   await notificationSender.SendAsync(message);

   return Ok();
}

// Minimal API
app.MapPost("notification/channels/{type}",
            async (
               NotificationChannelTypeDto type,
               [FromBody] string message,
               [FromServices] IServiceProvider serviceProvider) =>
            {
               var notificationSender = type.GetNotificationSender(serviceProvider);
               await notificationSender.SendAsync(message);
            });
Clone this wiki locally