Skip to content

[API Proposal]: Enhanced Enumeration Classes (Vnum) for .NET #119567

@skywithin

Description

@skywithin

Background and motivation

Summary
This proposal introduces the Vnum (Value-backed enumeration) concept to the .NET framework as a more powerful alternative to traditional enums. The Vnum pattern provides strongly-typed enumeration-like constructs with additional metadata, better serialization support, and enhanced functionality while maintaining performance through intelligent caching.

Motivation
Traditional C# enums have several limitations:

  1. Limited Metadata: Enums can only store numeric values and string names, making it difficult to associate additional properties like descriptions, display names, or business logic.
  2. Serialization Challenges: Enum serialization often requires custom converters, especially when dealing with string-based APIs or databases.
  3. No Business Logic: Enums cannot contain methods or properties that encapsulate domain-specific behavior.
  4. Type Safety Issues: Enum values can be cast to integers, potentially leading to invalid states.
  5. Limited Extensibility: Adding new properties or methods to enums requires workarounds or separate classes.

The Vnum pattern addresses these limitations by providing:

  • Rich metadata support (descriptions, codes, custom properties)
  • Built-in serialization support
  • Encapsulated business logic
  • Type safety through sealed instances
  • Performance optimization through reflection caching

API Proposal

namespace System;

/// <summary>
/// Provides a base class for creating enumeration-like constructs 
/// with strongly-typed values and display codes.
/// </summary>
public abstract class Vnum : IEquatable<Vnum>
{
    /// <summary>
    /// Gets the numeric value of the Vnum item.
    /// </summary>
    public int Value { get; }

    /// <summary>
    /// Gets the string code of the Vnum item.
    /// </summary>
    public string Code { get; }

    protected Vnum() { } // Required for reflection
    protected Vnum(int value, string code)
    {
        Value = value;
        Code = code ?? throw new ArgumentNullException(nameof(code));
    }

    /// <summary>
    /// Returns the display code of the Vnum item.
    /// </summary>
    public override string ToString() => Code;

    /// <summary>
    /// Retrieves all Vnum instances of a given type.
    /// </summary>
    public static IEnumerable<T> GetAll<T>(Func<T, bool>? predicate = null) where T : Vnum, new();

    /// <summary>
    /// Retrieves a Vnum instance by its numeric value.
    /// </summary>
    public static T FromValue<T>(int value) where T : Vnum, new();

    /// <summary>
    /// Attempts to retrieve a Vnum instance by its numeric value.
    /// </summary>
    public static bool TryFromValue<T>(int value, out T vnum) where T : Vnum, new();

    /// <summary>
    /// Retrieves a Vnum instance by its code.
    /// </summary>
    public static T FromCode<T>(string code, bool ignoreCase = false) where T : Vnum, new();

    /// <summary>
    /// Attempts to retrieve a Vnum instance by its code.
    /// </summary>
    public static bool TryFromCode<T>(string code, bool ignoreCase, out T vnum) where T : Vnum, new();

    // Equality and hashing implementations
    public override bool Equals(object? obj);
    public bool Equals(Vnum? other);
    public override int GetHashCode();
}

/// <summary>
/// Provides a base class for creating enumeration-like constructs 
/// that are strongly-typed to a specific enum type.
/// </summary>
public abstract class Vnum<TEnum> : Vnum where TEnum : struct, Enum
{
    /// <summary>
    /// Gets the enumeration value of the Vnum item.
    /// </summary>
    public TEnum Id => (TEnum)Enum.ToObject(typeof(TEnum), Value);

    protected Vnum() : base() { }
    protected Vnum(int value, string code) : base(value, code) { }
    protected Vnum(TEnum value, string code) : base(Convert.ToInt32(value), code) { }

    /// <summary>
    /// Retrieves a Vnum instance by its enum value.
    /// </summary>
    public static TVnum FromEnum<TVnum, TEnum>(TEnum value)
        where TVnum : Vnum<TEnum>, new()
        where TEnum : struct, Enum;

    /// <summary>
    /// Attempts to retrieve a Vnum instance by its enum value.
    /// </summary>
    public static bool TryFromEnum<TVnum, TEnum>(TEnum value, out TVnum vnum)
        where TVnum : Vnum<TEnum>, new()
        where TEnum : struct, Enum;
}

API Usage

// Basic Vnum Implementation:
public enum StatusId
{
    Active = 10,
    Inactive = 20,
    Closed = 95,
    Archived = 99
}

public class Status : Vnum<StatusId>
{
    public string Description { get; }

    public Status() { }
    private Status(StatusId id, string code, string description) : base(id, code)
    {
        Description = description;
    }

    public static readonly Status Active = 
        new(StatusId.Active, "Active", "Currently active and available");
    
    public static readonly Status Inactive = 
        new(StatusId.Inactive, "Inactive", "Not currently in use but can be reactivated");
    
    public static readonly Status Closed = 
        new(StatusId.Closed, "Closed", "Permanently disabled and cannot be reactivated");
    
    public static readonly Status Archived = 
        new(StatusId.Archived, "Archived", "Record retained for audit/history only");
}

//Advanced Vnum with Business Logic:
public enum JobStatusId
{
    Active = 10,
    Fulfilled = 11,
    Closed = 12,
    Blocked = 13
}

public class JobStatus : Vnum<JobStatusId>
{
    public bool CanAcceptOffers { get; }
    public bool IsClosed { get; }
    public string Description { get; }

    public JobStatus() { }
    private JobStatus(JobStatusId id, string code, bool canAcceptOffers, bool isClosed, string description) 
        : base(id, code)
    {
        CanAcceptOffers = canAcceptOffers;
        IsClosed = isClosed;
        Description = description;
    }

    public static readonly JobStatus Active = 
        new(JobStatusId.Active, "Active", true, false, "Job is active and accepting offers");
    
    public static readonly JobStatus Fulfilled = 
        new(JobStatusId.Fulfilled, "Fulfilled", false, true, "Job has been fulfilled");
    
    public static readonly JobStatus Closed = 
        new(JobStatusId.Closed, "Closed", false, true, "Job has been closed");
    
    public static readonly JobStatus Blocked = 
        new(JobStatusId.Blocked, "Blocked", false, true, "Job is blocked by authority");
}

//Usage in Code:
// Getting all instances
var allStatuses = Vnum.GetAll<Status>();
var closedStatuses = Vnum.GetAll<Status>(s => s.IsClosed);

// Finding by value
var activeStatus = Vnum.FromValue<Status>(10);

// Finding by code
var closedStatus = Vnum.FromCode<Status>("Closed");

// Finding by enum
var inactiveStatus = Vnum.FromEnum<Status, StatusId>(StatusId.Inactive);

// Safe parsing
if (Vnum.TryFromCode<Status>("Active", out var status))
{
    Console.WriteLine($"Status: {status.Code}, Description: {status.Description}");
}

// Business logic usage
if (jobStatus.CanAcceptOffers)
{
    // Process new offers
}

// Serialization (works with System.Text.Json)
var json = JsonSerializer.Serialize(activeStatus);
var deserialized = JsonSerializer.Deserialize<Status>(json);

Alternative Designs

using System.Collections.Concurrent;
using System.Reflection;

namespace System;

public abstract class Vnum<TEnum> : Vnum where TEnum : struct, Enum
{
    /// <summary>
    /// Gets the enumeration value of the Vnum item.
    /// </summary>
    public TEnum Id => (TEnum)Enum.ToObject(typeof(TEnum), Value);

    protected Vnum() : base() { }
    protected Vnum(int value, string code) : base(value, code) { }
    protected Vnum(TEnum value, string code) : base(Convert.ToInt32(value), code) { }
}

/// <summary>
/// Provides a base class for creating enumeration-like constructs 
/// with strongly-typed values and display codes.
/// </summary>
public abstract class Vnum : IEquatable<Vnum>
{
    /// <summary>
    /// A thread-safe cache that stores arrays of <see cref="Vnum"/> instances for each derived type.
    /// This cache is used to improve performance by avoiding repeated reflection operations when retrieving
    /// all instances of a specific type (e.g., via <see cref="GetAll(Type)"/>). 
    /// 
    /// Once populated, subsequent calls for the same type will retrieve the cached array instead of 
    /// invoking reflection, which can be computationally expensive. The cache uses a 
    /// <see cref="ConcurrentDictionary{TKey, TValue}"/> to ensure thread safety in multi-threaded environments.
    /// 
    /// Note: This cache is static, meaning it's shared across all instances of <see cref="Vnum"/>.
    /// </summary>
    private static readonly ConcurrentDictionary<Type, object[]> _cache = new();

    /// <summary>
    /// Gets the numeric value of the Vnum item.
    /// </summary>
    public int Value { get; }

    /// <summary>
    /// Gets the string code of the Vnum item.
    /// </summary>
    public string Code { get; } = null!;

    protected Vnum() { } // Required for reflection
    protected Vnum(int value, string code)
    {
        Value = value;
        Code = code ?? throw new ArgumentNullException(nameof(code));
    }

    /// <summary>
    /// Returns the display code of the Vnum item.
    /// </summary>
    public override string ToString() => Code;

    /// <summary>
    /// Retrieves all Vnum instances of a given type <typeparamref name="T"/>.
    /// </summary>
    public static IEnumerable<T> GetAll<T>(Func<T, bool>? predicate = null) where T : Vnum, new()
    {
        var all = GetAll(typeof(T)).Cast<T>();
        return predicate is null ? all : all.Where(predicate);
    }

    /// <summary>
    /// Retrieves all Vnum instances of the specified <see cref="Vnum"/> type.
    /// </summary>
    private static object[] GetAll(Type type)
    {
        //1. Validate the type parameter.
        if (type is null)
        {
            throw new ArgumentNullException(
                paramName: nameof(type), 
                message: "Type cannot be null");
        }

        if (!typeof(Vnum).IsAssignableFrom(type))
        {
            throw new ArgumentException(
                message: $"Type '{type.Name}' is not a valid {nameof(Vnum)} type", 
                paramName: nameof(type));
        }

        //2. Check if the requested Vnum instances for type are already in _cache.
        //3. If not cached, use reflection to get all public, static fields on type that represent Vnum instances.
        //4. Store and return the results as an array.
        return _cache.GetOrAdd(
            key: type,
            t => t.GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly)
                  .Where(f => t.IsAssignableFrom(f.FieldType))
                  .Select(f => f.GetValue(null)!)
                  .ToArray());
    }

    /// <summary>
    /// Retrieves a Vnum instance by its numeric value.
    /// </summary>
    public static T FromValue<T>(int value) where T : Vnum, new() =>
        Parse<T, int>(
            value: value,
            description: nameof(value),
            predicate: item => item.Value == value);

    /// <summary>
    /// Attempts to retrieve a Vnum instance by its numeric value.
    /// </summary>
    public static bool TryFromValue<T>(int value, out T vnum) where T : Vnum, new() =>
        TryParse(() => FromValue<T>(value), out vnum);

    /// <summary>
    /// Retrieves a Vnum instance by its enum value.
    /// </summary>
    public static TVnum FromEnum<TVnum, TEnum>(TEnum value)
        where TVnum : Vnum<TEnum>, new()
        where TEnum : struct, Enum
        => FromValue<TVnum>(Convert.ToInt32(value));

    /// <summary>
    /// Attempts to retrieve a Vnum instance by its enum value.
    /// </summary>
    public static bool TryFromEnum<TVnum, TEnum>(TEnum value, out TVnum vnum)
        where TVnum : Vnum<TEnum>, new()
        where TEnum : struct, Enum
        => TryParse(() => FromValue<TVnum>(Convert.ToInt32(value)), out vnum);

    /// <summary>
    /// Retrieves a Vnum instance by its code.
    /// </summary>
    public static T FromCode<T>(string code, bool ignoreCase) where T : Vnum, new() 
    {
        Func<T, bool> predicate =
            (item) =>
                ignoreCase
                    ? item.Code.Equals(code, StringComparison.OrdinalIgnoreCase)
                    : item.Code == code;

        return Parse(
            value: code,
            description: nameof(code),
            predicate: predicate);
    }

    public static T FromCode<T>(string code) where T : Vnum, new() =>
        FromCode<T>(code, ignoreCase: false);

    /// <summary>
    /// Attempts to retrieve a Vnum instance by its code.
    /// </summary>
    public static bool TryFromCode<T>(string code, bool ignoreCase, out T vnum) where T : Vnum, new() =>
        TryParse(() => FromCode<T>(code, ignoreCase), out vnum);

    /// <summary>
    /// Attempts to retrieve a Vnum instance by its code (case sensitive).
    /// </summary>
    public static bool TryFromCode<T>(string code, out T vnum) where T : Vnum, new() =>
        TryFromCode(code, ignoreCase: false, out vnum);

    /// <summary>
    /// Parses a Vnum instance based on a specified predicate.
    /// This method is used internally to find a matching Vnum item.
    /// </summary>
    /// <exception cref="InvalidOperationException">Thrown if no matching Vnum instance is found.</exception>
    private static T Parse<T, K>(
        K value,
        string description,
        Func<T, bool> predicate) where T : Vnum, new()
    {
        var matchingItem = GetAll<T>().FirstOrDefault(predicate);

        if (matchingItem is null)
        {
            throw new InvalidOperationException($"'{value}' is not a valid {description} in {typeof(T).Name}");
        }

        return matchingItem;
    }

    private static bool TryParse<T>(Func<T> parseFunc, out T result) where T : Vnum, new()
    {
        try
        {
            result = parseFunc();
            return true;
        }
        catch (ArgumentNullException) // Narrow catch. Avoid masking catastrophic exceptions
        {
            result = null!;
            return false;
        }
        catch (InvalidOperationException)
        {
            result = null!;
            return false;
        }
    }

    /// <summary>
    /// Determines whether the specified object is equal to the current Vnum instance.
    /// </summary>
    public override bool Equals(object? obj) =>
        obj is Vnum other &&
        Equals(other);

    /// <summary>
    /// Determines whether the specified Vnum is equal to the current Vnum instance.
    /// </summary>
    public bool Equals(Vnum? other) =>
        other != null &&
        GetType().Equals(other.GetType()) &&
        Value == other.Value;

    /// <summary>
    /// Returns the hash code for the Vnum item, based on its value.
    /// </summary>
    public override int GetHashCode() => HashCode.Combine(GetType(), Value);
}

Risks

Implementation Details

  1. Performance Optimization: Uses ConcurrentDictionary<Type, object[]> for caching reflection results, ensuring thread-safe access and avoiding repeated reflection operations.
  2. Type Safety: All instances are created as static readonly fields, preventing invalid states and ensuring immutability.
  3. Serialization Support: Natural support for JSON serialization through ToString() and parsing methods.
  4. Memory Efficiency: Static instances are shared across the application, reducing memory footprint.
  5. Reflection-Based Discovery: Uses reflection to automatically discover all static instances, reducing boilerplate code.

Drawbacks

  1. Reflection Overhead: Initial discovery of instances uses reflection, though this is cached for performance.
  2. Memory Usage: Each Vnum type maintains a cache of all instances, though this is typically minimal.
  3. Learning Curve: Developers need to understand the pattern and conventions for proper implementation.
  4. Static Dependencies: All instances must be defined as static fields, which can make testing more complex.
  5. Assembly Loading: Reflection-based discovery may have implications for assembly loading in certain scenarios.

Nice to have

  1. User-friendly syntax for implementing Vnums.
  2. Compile-Time Safety: Validation to ensure enum-Vnum mappings are correct at development time.

Metadata

Metadata

Assignees

No one assigned

    Labels

    api-suggestionEarly API idea and discussion, it is NOT ready for implementationarea-System.RuntimeuntriagedNew issue has not been triaged by the area owner

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions