Skip to content

Smart Enums Beyond Traditional Enumerations

Pawel Gerr edited this page Jul 5, 2025 · 1 revision

Smart Enums: Beyond Traditional Enumerations in .NET

Introduction

Representing a fixed set of related constants or options is a common requirement in software development. C# provides the enum keyword for this purpose, often used for things like status codes, categories, or types. While basic enums are useful for simple integer mappings, developers frequently encounter scenarios where more capability is needed – associating behavior with values, using non-numeric keys, or ensuring stronger type safety.

These limitations can lead to maintenance challenges and potential issues in complex systems. This article explores a powerful pattern designed to address these shortcomings: Smart Enums. We'll examine the difficulties with traditional C# enums and see how the Smart Enum pattern provides a more robust, flexible, and type-safe alternative through the library Thinktecture.Runtime.Extensions.

Providing solutions via a .NET library

The library Thinktecture.Runtime.Extensions was created to address a fundamental challenge in modern C# development: providing robust, type-safe domain modeling without excessive boilerplate code. One of the challenges is to find a way to "attach" additional properties or behavior to enumerations, something that traditional C# enums can't easily accommodate. While the Smart Enum pattern wasn't new, implementing it manually for each use case required considerable boilerplate code and introduced potential for inconsistencies.

The library was initially developed using reflection to provide a more streamlined implementation of Smart Enums. Though this approach worked, it still required some boilerplate code and lacked flexibility in terms of customization options. A significant evolution came with the introduction of Roslyn Source Generators in .NET 5 (2020). This technology opened new possibilities for the library, enabling compile-time code generation with much greater flexibility. Shortly after source generators became available, the library was completely rewritten to leverage this approach.

The switch to source generators made the library much more flexible and powerful. It allowed for more extensive features like compile-time validation, enhanced type conversion capabilities, and seamless framework integrations. For customers who had been using the reflection-based implementation since late 2018, the migration to the source generator version was quite straightforward – in most cases, it actually involved deleting boilerplate code that was now being automatically generated.

Roslyn Source Generators are a feature of the .NET Compiler Platform (Roslyn) that allow the developers to generate code at compile time. The source generators can read existing C# code and other files, analyze them, and then produce new C# code that is added to the compilation.

⚠️Smart Enums (along with Value Objects and Discriminated Unions) are powerful tools in our development arsenal, but they should be applied with an understanding of their constraints. While many traditional enums would benefit from being converted to Smart Enums, there are technical limitations to consider. For instance, Smart Enums can't be used in attributes that require constant values (like [MyAttribute(MyEnum = MyEnum.Value1)]), as Smart Enum instances are not constants. As with any pattern, the decision of where and when to use Smart Enums should balance their benefits against these practical considerations.

The Challenges with Traditional C# Enums

While C# enum is convenient for basic cases, its limitations become apparent as application complexity grows. Let's look at some common challenges using ProductType as an example.

public enum ProductType
{
    Electronics = 1,
    Clothing = 2,
    Books = 3
}

Limited Underlying Types

Traditional enums are restricted to underlying numeric types (int, short, byte, etc.). This is problematic when the natural representation of your constants isn't numeric, such as using standard string codes or names:

// Need separate mapping logic to get the string code or description
public static string GetProductTypeCode(ProductType type)
{
    return type switch
    {
        ProductType.Electronics => "ELEC",
        ProductType.Clothing => "CLTH",
        ProductType.Books => "BOOK",
        _ => throw new ArgumentOutOfRangeException(nameof(type))
    };
}

Using integers forces indirect mapping and often requires additional logic to convert between the enum value and its intended string representation.

Lack of Type Safety

One of the most significant issue is the lack of true type safety. You can cast any integer value to an enum type, even if that value doesn't correspond to a defined member:

// Neither compiler nor runtime error, even though 42 is not a valid ProductType
ProductType invalidType = (ProductType)42;

// Checks like Enum.IsDefined are necessary but often forgotten
bool isDefined = Enum.IsDefined(typeof(ProductType), invalidType);

This requires defensive coding throughout the application (e.g., using Enum.IsDefined or switch statements with default cases throwing exceptions) to handle potentially invalid enum values, which can easily be missed.

Inability to Associate Data

Enums cannot directly hold additional data related to each member. If you need to associate properties (like a display name or a tax rate), you must manage this information externally, typically using switch statements or attributes:

// External logic for property, e.g., tax rate
public decimal GetTaxRate(ProductType type)
{
    return type switch
    {
        ProductType.Electronics => 0.19m,
        ProductType.Clothing => 0.19m,
        ProductType.Books => 0.07m,
        _ => throw new ArgumentOutOfRangeException(nameof(type))
    };
}

This scattering of related data makes the code harder to maintain.

Inability to Associate Behavior

Similarly, enums cannot encapsulate behavior. If specific actions depend on the enum value, this logic must also live outside the enum definition, often implemented using extension methods:

// Extension methods are a common workaround to add behavior
public static class ProductTypeExtensions
{
    public static bool IsEligibleForDiscount(this ProductType type)
    {
        // Logic related to ProductType lives outside its definition
        return type == ProductType.Clothing
            || type == ProductType.Books;
    }
}

This separation of behavior can lead to code that is less cohesive.

Maintenance Burden

The combination of these limitations creates a significant maintenance burden. When a new enum member is added or an existing one changes, developers must meticulously find and update all related external logic (switch statements, dictionaries, extension methods). Missing even one location can lead to inconsistent behavior or runtime errors.

public enum ProductType
{
    // ...
    Groceries = 4  // New member added
}

// Must update ALL switch statements across the codebase
public decimal GetTaxRate(ProductType type)
{
    return type switch
    {
        // ...
        // Forgot to handle Groceries! Runtime exception will occur
        _ => throw new ArgumentOutOfRangeException(nameof(type))
    };
}

This maintenance burden increases with each new property or behavior associated with the enum, making the code more error-prone and harder to evolve over time.

What Are Smart Enums?

The Smart Enum pattern addresses these challenges by representing enumerations as classes rather than primitive types. A Smart Enum is essentially a class that defines a fixed set of named, static instances of itself.

Key characteristics and benefits of the Smart Enum pattern include:

  • Type Safety: Because they are classes, invalid values cannot be created or assigned accidentally (e.g., by casting arbitrary numbers). Only the predefined instances are valid.
  • Encapsulation: Data and behavior related to each specific enum value can be encapsulated directly within the class definition as properties and methods of the instances.
  • Flexibility: They are not limited to numeric underlying values. The identifying value for each instance can be a string, GUID, or any other comparable type.
  • Expressiveness: Code becomes more object-oriented and self-documenting, as related concepts are grouped together.
  • Maintainability: Adding a new value involves adding a new static instance and its associated data/behavior in one place, reducing the risk of inconsistencies.

While the concept isn't new, modern tools like Roslyn Source Generators make implementing this pattern highly practical in C#.

Solving the Problem with Smart Enums

Manually implementing the Smart Enum pattern involves considerable boilerplate code. The library Thinktecture.Runtime.Extensions leverages source generators to automate this, allowing you to define robust Smart Enums with minimal code.

There are two main kinds provided by the library:

Keyed Smart Enums

These use an underlying value (the "key") to identify each instance. The key can be of any type.

The Smart Enum must be a partial class and be marked with the SmartEnumAttribute<string> to let the library generate necessary code via Roslyn Source Generator. The generated code implements basic features like private constructor, retrieval methods Get/TryGet, a collection containing all defined items, equality comparison, GetHashCode, ToString, etc..

// Define a Smart Enum using string keys
[SmartEnum<string>]
public partial class ProductType
{
   // Each static readonly field of type ProductType represents a valid instance
   // The constructor argument is the key value
   public static readonly ProductType Electronics = new("Electronics");
   public static readonly ProductType Clothing = new("Clothing");
   public static readonly ProductType Books = new("Books");

   // The source generator creates the necessary infrastructure
}

The generic parameter, like string in SmartEnum<string> is the type of the underlying value, and will be called "key type" or "key member type" in this and following articles.

Keyless Smart Enums

These are useful when you only need distinct instances and don't require a key member. You can still associate other data with each instance.

// Define a keyless Smart Enum
[SmartEnum]
public partial class LogLevel
{
   // Constructor takes any additional properties needed
   public static readonly LogLevel Verbose = new(ConsoleColor.Gray);
   public static readonly LogLevel Debug = new(ConsoleColor.Gray);
   public static readonly LogLevel Info = new(ConsoleColor.White);
   public static readonly LogLevel Warning = new(ConsoleColor.Yellow);
   public static readonly LogLevel Error = new(ConsoleColor.Red);

   // Associated data property
   public ConsoleColor ConsoleColor { get; }
}

Solving Traditional Enum Problems

Let's revisit the ProductType example, now adding a TaxRate property, demonstrating how the library solves the earlier limitations:

// Use a string key and add a TaxRate property
[SmartEnum<string>]
public partial class ProductType
{
    // The generated constructor now expects the key (string) and the TaxRate (decimal)
    public static readonly ProductType Electronics = new("Electronics", 0.19m);
    public static readonly ProductType Clothing = new("Clothing", 0.19m);
    public static readonly ProductType Books = new("Books", 0.07m);

    // Custom property associated with each instance
    public decimal TaxRate { get; }

    // Custom method associated with each instance
    public decimal CalculateTax(decimal price)
    {
        // Behavior lives directly with the data
        return price * TaxRate;
    }
}

Some of the basic feature of the ProductType Smart Enum:

// Retrieval of the an instance by its key (throws if not found)
var productType = ProductType.Get("Electronics");

// Exception-free retrieval
bool found = ProductType.TryGet("Electronics", out var productType);

// Direct access to properties and methods
decimal taxRate = productType.TaxRate;
decimal tax = productType.CalculateTax(100.00m);

// Equality comparison
if (productType == ProductType.Electronics)
{
}

// Iteration over all defined items
foreach (var productType in ProductType.Items)
{
}

As shown in this example, the Smart Enum pattern directly addresses all the limitations of traditional enums we discussed earlier. Not only can we use string keys and encapsulate both data and behavior with the relevant instances, but the library also handles all the boilerplate infrastructure like equality comparison, collections, and retrieval methods.

Why Use a Library? The Manual Alternative

While understanding the mechanics is useful, manually implementing the Smart Enum pattern for every required type is laborious and error-prone. Consider this partial implementation of ProductType, which despite being significantly simplified and missing many necessary features, already demonstrates the considerable amount of boilerplate code required:

public class ProductType : IEquatable<ProductType>
{
    // Access to key and items
    public string Key { get; }
    public static IReadOnlyList<ProductType> Items { get; }

    // Private constructor - crucial for the pattern
    private ProductType(string key) { /* ... */ }

    // Basic retrieval methods
    public static ProductType Get(string key) { /* ... */ }
    public static bool TryGet(string key, out ProductType? result) { /* ... */ }

    // Equality comparison, hash code and string representation
    public bool Equals(ProductType? other) { /* ... */ }
    public override bool Equals(object? obj) { /* ... */ }
    public override int GetHashCode() { /* ... */ }
    public override string ToString() => Key;

    // Convenience methods
    public static bool operator ==(ProductType? left, ProductType? right) { /* ... */ }
    public static bool operator !=(ProductType? left, ProductType? right) { /* ... */ }

    // Depending on the key type and requirements:
    // Implicit/explicit conversion operators, IConvertible, IFormattable,
    // serialization support, framework support (ASP.NET Core, EF Core)
    // thread-safety, pattern matching support, testing
}

This manual code, while functional for basic cases, lacks many features provided out-of-the-box by Thinktecture.Runtime.Extensions: serialization, framework integrations (ASP.NET Core model binding, EF Core value converters), optimized collections, generated Switch methods for pattern matching, and more. Maintaining this boilerplate across many Smart Enum types becomes a significant overhead.

Advanced Usage: Encapsulating Complex Behavior

Smart Enums excel at encapsulating not just data, but also complex behavior, further reducing the need for conditional logic in consuming code. Let's model different payment methods, each with its own processing logic.

[SmartEnum<string>]
public partial class PaymentMethod
{
    // Define instances with key and processing action
    public static readonly PaymentMethod CreditCard = new("CC", ProcessCreditCardPayment);
    public static readonly PaymentMethod BankTransfer = new("BT", ProcessBankTransferPayment);

    // Delegates the call to different implementations
    [UseDelegateFromConstructor]
    public partial void ProcessPayment(decimal amount);

    // Static methods defining the specific behavior for each instance
    private static void ProcessCreditCardPayment(decimal amount)
    {
        // Logic specific to credit card processing...
    }

    private static void ProcessBankTransferPayment(decimal amount)
    {
        // Logic specific to bank transfer processing...
    }
}

The UseDelegateFromConstructorAttribute instructs the library:

  • To expect a delegate (Action<decimal> in this case) in the constructor.
  • To store this delegate in a private field for each instance.
  • To implement the partial method ProcessPayment by invoking the stored delegate.

Modeling different behaviors through delegation is one solution, the other is through polymorphism, which is best for more complex solutions.

// Consuming code
// ⚠️ Using a primitive type ('decimal') for simplicity. See Bonus section for improvement.
public record Payment(
    decimal Amount,
    PaymentMethod Method)
{
    // Use the PaymentMethod's method
    public void Process()
    {
        Method.ProcessPayment(Amount);
    }
}

// Usage example
var payment = new Payment(
    Amount: 99.99m,
    Method: PaymentMethod.CreditCard);

// No switch statement needed.
// The specific action (ProcessCreditCardPayment or ProcessBankTransferPayment)
// is invoked based on the 'PaymentMethod' instance.
payment.Process();

This example highlights how Smart Enums facilitate behavior specialization through delegation:

  • Each PaymentMethod instance holds its specific processing logic via the delegate.
  • The consuming code doesn't need if/else or switch statements to determine how to process the payment; it calls paymentMethod.ProcessPayment(), and the correct implementation associated with the specific PaymentMethod instance is executed.
  • Adding a new payment method (e.g., SEPA) involves adding a new static field and its corresponding processing function. The consuming code requires no changes.

Bonus: Synergy with Value Objects

In our Value Objects article, we discussed how value objects help address "primitive obsession" by creating specific, type-safe, and self-validating types for concepts often represented by primitives (like decimal for money or string for codes). Smart Enums and value objects complement each other very well.

Let's refine the payment example by introducing a value object for Amount.

[ValueObject<decimal>]
public partial struct Amount
{
    // Basic validation: amount must be positive
    static partial void ValidateFactoryArguments(
        ref ValidationError? validationError,
        ref decimal value)
    {
        if (value <= 0)
            validationError = new ValidationError("Amount must be positive.");
    }
}

// The PaymentMethod uses Amount instead of decimal
[SmartEnum<string>]
public partial class PaymentMethod
{
    // ...

    [UseDelegateFromConstructor]
    public partial void ProcessPayment(Amount amount);
}

The value object Amount is a struct with an always positive value of type decimal.

// Consuming code
public record Payment(
    Amount Amount,
    PaymentMethod Method)
{
    // Processing logic now operates on "Amount"
    public void Process()
    {
        Method.ProcessPayment(Amount);
    }
}

var payment = new Payment(
    Amount.Create(99.99m), // Validation happens at creation time
    PaymentMethod.CreditCard);

payment.Process();

Combining Smart Enums and value objects yields a powerful synergy that enhances the domain model with built-in type safety and validation, eliminating invalid states. This combination creates code that closely mirrors domain language through specific types like Amount, and PaymentMethod, while encapsulating both data and behavior in cohesive units. The result is a rich domain model that enforces business rules automatically and reduces the cognitive overhead needed to understand your code.

Summary

Smart Enums offer a superior alternative to traditional C# enums when dealing with fixed sets of constants that require associated data, behavior, or non-numeric keys. They provide type safety, encapsulate logic effectively, and improve code maintainability and expressiveness.

The library Thinktecture.Runtime.Extensions significantly simplify the implementation of the Smart Enum pattern by using source generators, eliminating boilerplate code and providing robust features like serialization and framework integration. By adopting Smart Enums, you can build more resilient, understandable, and maintainable applications.

In the next article, we'll push beyond the basics to explore advanced Smart Enum patterns. We'll discover how to implement behavior using inheritance, leverage compile-time safety with pattern matching, and integrate Smart Enums within larger architectural patterns. Through practical examples we'll demonstrate how these techniques can transform scattered conditional logic into cohesive domain models that clearly express your business rules.

Clone this wiki locally