Skip to content

Smart Enums

Pawel Gerr edited this page Mar 2, 2026 · 72 revisions

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.

This guide covers the basics. For advanced topics, see the companion pages:

Articles

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.

Getting Started

Installation

Prerequisites

  • .NET SDK: Version 8.0.416 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.EntityFrameworkCore8
Thinktecture.Runtime.Extensions.EntityFrameworkCore9
Thinktecture.Runtime.Extensions.EntityFrameworkCore10
Entity Framework Core support (versions 8-10)

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. Keyless Smart Enums: A Smart Enum without an underlying type for scenarios where no underlying key value is required
  3. Type Safety: Unlike regular enums, Smart Enums (by default) prevent creation of invalid enum items
  4. Rich Behavior: Add properties and methods to your enum items
  5. Pattern Matching: Built-in Switch/Map methods are exhaustive, i.e. no more forgetting to handle an item

Choosing Your Key Type

Key Type Best For Example
string Human-readable values, APIs, display text OrderStatus, Country
int Database persistence, sequential values Priority, ErrorCode
Guid Distributed systems, unique identifiers CorrelationId
(keyless) Behavior-only enums, no serialization needed NotificationDispatcher

Default recommendation: Use string unless you have a specific reason not to. String keys are the most versatile -- they work naturally with APIs, JSON, and user-facing scenarios.

What You Implement

Requirements

  • Install NuGet package: Thinktecture.Runtime.Extensions
  • Your class must be declared as partial
  • Apply [SmartEnum] (keyless) or [SmartEnum<TKey>] (keyed) attribute
  • Enum items must be public static readonly fields

Quick Start: Order Status

[SmartEnum<string>]                       // 1. Apply the SmartEnum attribute with key type
public partial class OrderStatus          // 2. Class must be partial
{
   public static readonly OrderStatus Pending = new("Pending");       // 3. Items are public static readonly
   public static readonly OrderStatus Processing = new("Processing");
   public static readonly OrderStatus Completed = new("Completed");
   public static readonly OrderStatus Cancelled = new("Cancelled");
}

What you get for free:

  • Private constructor (prevents arbitrary instances)
  • Get(string key) / TryGet(string key, out OrderStatus?) for lookup
  • Items property for all defined values
  • Equality operators (==, !=) and Equals/GetHashCode
  • ToString() returns the key
  • Implicit conversion to key type, explicit conversion from key type
  • TypeConverter support
  • Exhaustive Switch/Map pattern matching

Additional interfaces (IParsable<T>, IComparable<T>, IFormattable, ISpanParsable<T>) are generated when the key type supports them. Framework integration (JSON, EF Core, ASP.NET Core, MessagePack, OpenAPI) requires additional packages. See What is implemented for you below for full details.

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 Electronics = new("Electronics");
   public static readonly ProductType Clothing = new("Clothing");
}

2. Integer-based Smart Enum

// Use int as the key type - useful for persistence or natural ordering
[SmartEnum<int>]
public partial class Priority
{
   public static readonly Priority Low = new(1);
   public static readonly Priority Medium = new(2);
   public static readonly Priority High = new(3);
}

3. Keyless Smart Enum

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

Integration Note: Keyless Smart Enums cannot be serialized or used with model binding out of the box because they have no key value to convert. To enable framework integration, add [ObjectFactory<string>] to provide a string-based conversion mechanism.

What Is Implemented for You

Basic Operations

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

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

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

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

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

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

// IComparable (available when the key type implements IComparable<T>)
int comparison = Priority.Medium.CompareTo(Priority.High);
bool isHigher = Priority.High > Priority.Low;  // Comparison operators

Type Conversion and Equality

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

// Equality comparison - uses reference equality (items are singletons)
bool areEqual = ProductType.Electronics.Equals(ProductType.Electronics);
bool areEqualOp = ProductType.Electronics == ProductType.Electronics;  // Operator overloading
bool notEqual = ProductType.Electronics != ProductType.Clothing;

// GetHashCode - delegates to the key using the configured equality comparer
// (default: OrdinalIgnoreCase for strings, default comparer for other types)
int hashCode = ProductType.Electronics.GetHashCode();

// ToString - delegates to the key's ToString()
string name = ProductType.Electronics.ToString();  // Returns "Electronics"

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

Pattern Matching with Switch/Map

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

Both Switch and Map come in multiple overloads:

  • Switch (void) accepts an Action per member — use it for side effects with no return value.
  • Switch (with return value) accepts a Func<TResult> per member — use it to compute and return a result.
  • Map accepts a value of TResult per member directly (no lambdas) — a concise shorthand when each member maps to a constant.

IDE Tip: Place the cursor on a Switch, Map, SwitchPartially, or MapPartially call and use the light bulb (Quick Actions) to auto-generate all missing arguments with the correct lambda signatures.

ProductType productType = ProductType.Electronics;

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

// Transform enum values into different types
string department = productType.Switch(
    electronics: () => "Consumer Technology",
    clothing: () => "Fashion and Apparel"
);

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

For optimal performance, all Switch/Map methods offer state-passing overloads that prevent closures.

ILogger logger = ...;

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

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

productType.Switch(state,
    electronics: static state => state.Logger.LogInformation("Processing electronics order {OrderId}", state.OrderId),
    clothing: static state => state.Logger.LogInformation("Processing clothing order {OrderId}", state.OrderId)
);

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 Electronics = new("Electronics");
    public static readonly ProductType Clothing = new("Clothing");
}

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}"),
    electronics: () => Console.WriteLine("Special handling for electronics")
);

// Omitting default handler is possible with void-returning SwitchPartially but not with value-returning SwitchPartially/MapPartially
productType.SwitchPartially(
    electronics: () => Console.WriteLine("Only handle electronics specifically")
);

// MapPartially with values
string result = productType.MapPartially(
    @default: "Standard department",
    electronics: "Consumer Technology"
);

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),
    electronics: static l => l.LogInformation("Electronics specific logic")
);

Troubleshooting: Return Type Inference in Switch

When using Switch with a return value, all lambdas must return the same type TResult. If they return different-but-compatible types, the compiler cannot infer TResult and marks the entire Switch call as an error — making it hard to spot which lambda is the problem.

For example, this will not compile because List<int> and int[] are different types, even though both are assignable to IReadOnlyList<int>:

// ERROR: The entire Switch call is flagged red
IReadOnlyList<int> ids = productType.Switch(
    electronics: () => new List<int> { 1, 2, 3 },
    clothing: () => new int[] { 4, 5, 6 }
);

Fix: Specify the generic type parameter explicitly. This way, only the lambda with the incompatible return type will be flagged:

// OK: Explicit TResult tells the compiler what to expect
IReadOnlyList<int> ids = productType.Switch<IReadOnlyList<int>>(
    electronics: () => new List<int> { 1, 2, 3 },
    clothing: () => new int[] { 4, 5, 6 }
);

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

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 than 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<string>]
public partial class ProductType
{
   public static readonly ProductType Electronics = new("Electronics", ProductCategory.Goods);
   public static readonly ProductType Clothing = new("Clothing", ProductCategory.Goods);
   public static readonly ProductType Consulting = new("Consulting", ProductCategory.Services);

   public ProductCategory Category { get; }
}

How it works: When you declare instance properties (or fields) in a Smart Enum, the source generator automatically includes them as parameters in the generated private constructor -- alongside the key parameter (if keyed). It also generates the assignments in the constructor body, so every member is guaranteed to be initialized. You never need to write or maintain the constructor yourself; just declare the property and pass the value when creating the item. If you add, remove, or reorder properties, the constructor is regenerated accordingly.

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 Electronics = new("Electronics");
   public static readonly ProductType Clothing = new("Clothing");

   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 Electronics = new("Electronics", DoForElectronics);
   public static readonly ProductType Clothing = new("Clothing", DoForClothing);

   [UseDelegateFromConstructor]
   public partial void Do();

   private static void DoForElectronics()
   {
      // Implementation for Electronics
   }

   private static void DoForClothing()
   {
      // Implementation for Clothing
   }
}

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

To customize the name of the generated delegate type, use the DelegateName property:

[UseDelegateFromConstructor(DelegateName = "DoHandler")]
public partial void Do();

Option 2: using delegates

[SmartEnum<string>]
public partial class ProductType
{
   public static readonly ProductType Electronics = new("Electronics", DoForElectronics);
   public static readonly ProductType Clothing = new("Clothing", Empty.Action); // Thinktecture.Empty.Action is a cached no-op Action delegate

   public readonly Action Do;

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

Option 3: inheritance

Derived classes must be inner classes (nested inside the Smart Enum type). First-level derived inner classes must be private, while deeper-level derived inner classes (nested inside another derived class) must be public. Derived classes should be sealed unless they themselves have further derived types.

[SmartEnum<string>]
public partial class ProductType
{
   public static readonly ProductType Electronics = new("Electronics");
   public static readonly ProductType Clothing = new ClothingProductType();

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

   private sealed class ClothingProductType : ProductType
   {
      public ClothingProductType()
         : base("Clothing")
      {
      }

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

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 SmartEnumWithBaseClass : SomeBaseClass
{
   public static readonly SmartEnumWithBaseClass Item1 = new("item 1", 42);
}

The source generator creates a constructor that accepts parameters for both the Smart Enum and its base class. In the example above, new("item 1", 42) passes "item 1" as the Smart Enum's string key and 42 to the base class constructor parameter int value.

If the Smart Enum defines its own instance properties, they appear between the key and the base class parameters:

[SmartEnum<string>]
public partial class SmartEnumWithBaseClass : SomeBaseClass
{
   public string Description { get; }

   public static readonly SmartEnumWithBaseClass Item1 = new("item 1", "A description", 42);
}

Here, the generated constructor signature is (string key, string description, int value) — Smart Enum parameters first, then base class parameters.

Troubleshooting: Nested Smart Enums

Smart Enums can be nested inside other types. When you do this, all enclosing types must also be declared as partial — this is a general .NET requirement, not specific to Smart Enums. Missing partial on a container type leads to compiler errors.

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

Further Topics

For detailed configuration and integration guides, see:

  • Customization — Key member generation, validation, custom equality/comparer, comparison operators, conversion operators, IParsable/ISpanParsable, IFormattable, ToString, ObjectFactory conversion, IgnoreMember, etc.
  • Framework Integration — JSON (System.Text.Json, Newtonsoft.Json), MessagePack, ASP.NET Core, OpenAPI/Swashbuckle, Entity Framework Core
  • Performance — FrozenDictionary (.NET 8+), ReadOnlySpan (.NET 9+)
  • Object Factories — Custom conversion via ObjectFactory, serialization, model binding, EF Core integration
  • Source Generator Configuration — Logging, JetBrains annotations

Best Practices

Smart Enums are most effective when each item carries its own data and behavior. The patterns below highlight common mistakes and their recommended alternatives.

Store per-item data in fields, not in conditionals

The primary advantage of Smart Enums over plain enums is that each item is a real object that can hold its own data and behavior. When you add if/switch statements that branch on this, you lose that advantage and end up with the same maintenance burden as a plain enum.

Anti-pattern — conditional logic:

[SmartEnum<string>]
public partial class ShippingMethod
{
   public static readonly ShippingMethod Standard = new("STANDARD");
   public static readonly ShippingMethod Express = new("EXPRESS");

   // ⚠️ Adding a new item requires updating every method with another branch.
   public decimal CalculatePrice(decimal orderWeight)
   {
      if (this == Standard) return 5.99m + (orderWeight * 0.5m);
      if (this == Express) return 15.99m + (orderWeight * 0.75m);

      throw new InvalidOperationException($"Unknown shipping method: {this}");
   }
}

Recommended — per-item fields:

Pass values as constructor parameters and store them in fields. Each item carries its own pricing rules, and adding a new shipping method requires zero changes to existing code. See the Shipping Method example below.

Use Lazy<T> for circular references between items

Static fields are initialized in declaration order. When Pending is constructed, Processing does not exist yet, so you cannot reference it directly. Lazy<T> defers evaluation until first access, when all items are available.

Anti-pattern — boolean chains:

[SmartEnum<string>]
public partial class OrderStatus
{
   public static readonly OrderStatus Pending = new("Pending");
   public static readonly OrderStatus Processing = new("Processing");
   public static readonly OrderStatus Shipped = new("Shipped");

   // ⚠️ Every new status requires updating the conditions inside this method.
   public bool CanTransitionTo(OrderStatus next)
   {
      if (this == Pending) return next == Processing;
      if (this == Processing) return next == Shipped;

      return false;
   }
}

Recommended — Lazy<T> with collection expressions:

Store allowed transitions as a Lazy<IReadOnlyList<OrderStatus>> constructor parameter. Each item declares its own valid transitions, and Lazy<T> ensures the references resolve correctly regardless of declaration order. See the Order Status with State Transitions example below.

Real-World Use Cases and Ideas

The examples below show how Smart Enums can solve common design problems.

Order Status with State Transitions

Encapsulating state machine rules directly in the Smart Enum ensures transitions are validated wherever OrderStatus is used.

[SmartEnum<string>]
public partial class OrderStatus
{
   public static readonly OrderStatus Pending = new("Pending", new(() => [Processing, Cancelled]));
   public static readonly OrderStatus Processing = new("Processing", new(() => [Shipped, Cancelled]));
   public static readonly OrderStatus Shipped = new("Shipped", new(() => [Delivered]));
   public static readonly OrderStatus Delivered = new("Delivered", new(() => []));
   public static readonly OrderStatus Cancelled = new("Cancelled", new(() => []));

   private readonly Lazy<IReadOnlyList<OrderStatus>> _nextStates;

   public bool CanTransitionTo(OrderStatus next) => _nextStates.Value.Contains(next);

   public OrderStatus TransitionTo(OrderStatus next)
   {
      if (!CanTransitionTo(next))
         throw new InvalidOperationException($"Cannot transition from '{this}' to '{next}'.");

      return next;
   }
}

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;

   public int EstimatedDays { get; }
   public bool RequiresSignature { get; }

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

Usage example:

public class OrderProcessor(TimeProvider timeProvider)
{
    public OrderSummary ProcessOrder(Order order, ShippingMethod shipping)
    {
        var shippingCost = shipping.CalculatePrice(order.Weight);
        var deliveryDate = timeProvider.GetLocalNow().AddDays(shipping.EstimatedDays);

        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.

Note: The examples in this section use CsvHelper for CSV parsing.

Regular C#-enum

With a regular C#-enum the importer must use switch-case or if-else to branch on the enum value for every aspect that varies between types — column indices, datetime format strings, and so on. Instead of each type encapsulating its own parsing rules, the logic for all types is spread across the same method body in a single, growing switch block.

This leads to subtle duplication. For example, the "daily" datetime format ("yyyyMMdd hh:mm") appears in both the Daily and Monthly branches because Monthly reuses it for 3-column CSVs. If that format ever changes, you have to find and update every copy — miss one, and you get a silent runtime bug.

Extensibility is equally fragile. Adding a new type such as Yearly requires remembering to update every switch-case block in the importer. A forgotten branch compiles without complaint and only surfaces at runtime as an ArgumentOutOfRangeException. The compiler cannot warn about missing branches, so correctness depends entirely on developer discipline and thorough code review.

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

   Console.WriteLine($"CSV ({type}): Article-Id={articleId}, DateTime={dateTime}, Volume={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
         throw new JsonException();

      var discriminator = JsonSerializer.Deserialize<Discriminator>(ref reader, options) ?? 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);
Console.WriteLine(json); //  {"$type":"District","value":"District 42"}

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

// Deserialized jurisdiction: District 42 (District)
Console.WriteLine($"Deserialized jurisdiction: {deserializedJurisdiction} ({deserializedJurisdiction?.GetType().Name})");

Dispatcher in a Web API

Imagine a notification service with multiple channels -- email, SMS, and potentially more in the future. You need an API that lists all available channels and dispatches sends to the right implementation. A Smart Enum can act as both the DTO (carrying the channel key through model binding) and the dispatcher (resolving the correct sender from DI), keeping the routing logic centralized and type-safe.

Similar behavior can be achieved with keyed services for total flexibility, but Smart Enums offer several advantages:

  • Flexibility is not always needed
  • Listing all available options is not the DI container's responsibility
  • Tunneling through the DI container adds indirection and complexity
  • The keys of keyed services should not be used inside DTOs or as route parameters directly, as this bypasses validation and whitelisting

Step 1 -- Service interface and implementations

Start with the interface and one implementation per channel.

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

Step 2 -- Smart Enum definition

Each enum item maps a string key ("email", "sms") to a concrete implementation type. The generic derived class NotificationTypeDto<TImplementation> captures the implementation type at compile time -- this is safer than passing raw Type objects and lets each item resolve its own sender from DI.

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

   public abstract INotificationSender GetNotificationSender(IServiceProvider serviceProvider);

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

Step 3 -- DI registration

Register the notification senders with the DI container.

builder.Services.AddTransient<EmailNotificationSender>();
builder.Services.AddTransient<SmsNotificationSender>();

Step 4 -- API endpoints

GET -- list available channels

The generated Items property provides all defined enum values. With the JSON serialization package installed, each Smart Enum serializes to its key automatically, so listing available channels is a one-liner. This endpoint responds with ["email","sms"].

// Web API Controller
[HttpGet("notification/channels")]
public IActionResult GetAvailableChannels()
{
   return Ok(NotificationChannelTypeDto.Items);
}

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

POST -- send a notification

Because NotificationChannelTypeDto implements IParsable<T> (generated by the source generator), ASP.NET automatically model-binds and validates the {type} route parameter. Invalid values like "fax" are rejected before the action runs. The matched enum item then dispatches to the correct sender via GetNotificationSender.

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

Example: POST notification/channels/email with body "Test email" produces the log message Sending email: Test email.

Clone this wiki locally