This library provides some interfaces, classes, Roslyn Source Generators, Roslyn Analyzers and Roslyn CodeFixes for implementation of Smart Enums, Value Objects and Discriminated Unions.
See wiki for more documentation.
- Version 8:
- C# 11 (or higher) for generated code
- SDK 8.0.400 (or higher) for building projects
- Version 7:
- C# 11 (or higher) for generated code
- SDK 7.0.401 (or higher) for building projects
Smart Enums:
Value objects:
Smart Enums provide a powerful alternative to traditional C# enums, offering type-safety, extensibility, and rich behavior. Unlike regular C# enums which are limited to numeric values and lack extensibility, Smart Enums can:
- Use any type as the underlying type (e.g., strings, integers) or none at all
- Include additional fields, properties and behavior
- Use polymorphism to define custom behavior for each value
- Prevent creation of invalid values
- Integrate seamlessly with JSON serializers, MessagePack, Entity Framework Core and ASP.NET Core
Install: Install-Package Thinktecture.Runtime.Extensions
Documentation: Smart Enums
Some of the Key Features are:
- Choice between always-valid and maybe-valid Smart Enum
- Reflection-free iteration over all items
- Fast lookup/conversion from underlying type to Smart Enum and vice versa
- Allows custom properties and methods
- Exhaustive pattern matching with
Switch
/Map
methods - Provides appropriate constructor, based on the specified properties/fields
- Proper implementation of
Equals
,GetHashCode
,ToString
and equality operators - Provides implementation of
IComparable
,IComparable<T>
,IFormattable
,IParsable<T>
and comparison operators<
,<=
,>
,>=
(if applicable to the underlying type) - Custom comparer and equality comparer
Roslyn Analyzers and CodeFixes help the developers to implement the Smart Enums correctly
Provides support for:
- JSON (System.Text.Json and Newtonsoft)
- Minimal Api Parameter Binding and ASP.NET Core Model Binding
- Entity Framework Core
- MessagePack
Definition of a Smart Enum with custom properties and methods.
[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);
}
}
Behind the scenes a Roslyn Source Generator generates additional code. Some of the features that are now available are ...
[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
// 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");
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 - clean and concise
decimal discount = productType.Map(
groceries: 0.05m, // 5% off groceries
housewares: 0.10m // 10% off housewares
);
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)
);
Install: Install-Package Thinktecture.Runtime.Extensions
Documentation: Value Objects
Value objects help solve several common problems in software development:
-
Type Safety: Prevent mixing up different concepts that share the same primitive type
// Problem: Easy to accidentally swap parameters void ProcessOrder(int customerId, int orderId) { ... } ProcessOrder(orderId, customerId); // Compiles but wrong! // Solution: Value objects make it type-safe [ValueObject<int>] public partial struct CustomerId { } [ValueObject<int>] public partial struct OrderId { } void ProcessOrder(CustomerId customerId, OrderId orderId) { ... } ProcessOrder(orderId, customerId); // Won't compile!
-
Built-in Validation: Ensure data consistency at creation time
[ValueObject<decimal>] public partial struct Amount { static partial void ValidateFactoryArguments(ref ValidationError? validationError, ref decimal value) { if (value < 0) { validationError = new ValidationError("Amount cannot be negative"); return; } // Normalize to two decimal places value = Math.Round(value, 2); } } var amount = Amount.Create(100.50m); // Success: 100.50 var invalid = Amount.Create(-50m); // Throws ValidationException
-
Immutability: Prevent accidental modifications and ensure thread safety
-
Complex Value Objects: Encapsulate multiple related values with validation
[ComplexValueObject] public sealed partial class DateRange { public DateOnly Start { get; } public DateOnly End { get; } static partial void ValidateFactoryArguments( ref ValidationError? validationError, ref DateOnly start, ref DateOnly end) { if (end < start) { validationError = new ValidationError( $"End date '{end}' cannot be before start date '{start}'"); return; } // Ensure dates are not in the past var today = DateOnly.FromDateTime(DateTime.Today); if (start < today) { validationError = new ValidationError("Start date cannot be in the past"); return; } } public int DurationInDays => End.DayNumber - Start.DayNumber + 1; public bool Contains(DateOnly date) => date >= Start && date <= End; } // Usage var range = DateRange.Create( start: DateOnly.FromDateTime(DateTime.Today), end: DateOnly.FromDateTime(DateTime.Today.AddDays(7)) ); Console.WriteLine(range.DurationInDays); // 8 Console.WriteLine(range.Contains(range.Start)); // true
Key Features:
- Two types of value objects:
- Simple value objects (wrapper around a single value with validation)
- Complex value objects (multiple properties representing a single concept)
- Comprehensive validation support with descriptive error messages
- Framework integration:
- JSON serialization (System.Text.Json and Newtonsoft.Json)
- Entity Framework Core support
- ASP.NET Core Model Binding
- MessagePack serialization
- Rich feature set:
- Type conversion and comparison operators
- Custom equality comparison
- Proper implementation of standard interfaces (IComparable, IFormattable, etc.)
- Configurable null and empty string handling
- Development support:
- Roslyn Analyzers and CodeFixes for correct implementation
- Logging for debugging and insights
For more examples and detailed documentation, see the wiki.
Install: Install-Package Thinktecture.Runtime.Extensions
Documentation: Discriminated Unions
Discriminated unions are a powerful feature that allows a type to hold a value that could be one of several different types. They provide type safety, exhaustive pattern matching, and elegant handling of complex domain scenarios. Key benefits include:
- Type-safe representation of values that can be one of several types
- Exhaustive pattern matching ensuring all cases are handled
- Elegant modeling of domain concepts with multiple states
- Clean handling of success/failure scenarios without exceptions
The library provides two types of unions to suit different needs:
Perfect for simple scenarios where you need to combine a few types quickly. Features:
- Type-safe combination of up to 5 different types
- Implicit conversions and type checking
- Exhaustive pattern matching with Switch/Map methods
- Built-in equality comparison
- Support for class, struct, or ref struct implementations
// Quick combination of types
[Union<string, int>]
public partial class TextOrNumber;
// Create and use the union
TextOrNumber value = "Hello"; // Implicit conversion
TextOrNumber number = 42; // Works with any defined type
// Type-safe access
if (value.IsString)
{
string text = value.AsString; // Type-safe access
Console.WriteLine(text);
}
// Exhaustive pattern matching
var result = value.Switch(
@string: text => $"Text: {text}",
int32: num => $"Number: {num}"
);
// Custom property names for clarity
[Union<string, int>(T1Name = "Text", T2Name = "Number")]
public partial class BetterNamed;
// Now use .IsText, .IsNumber, .AsText, .AsNumber
Ideal for modeling domain concepts and complex hierarchies. Features:
- Inheritance-based approach for complex scenarios
- Support for both classes and records
- Integration with value objects
- Generic type support
- Exhaustive pattern matching
Perfect for modeling domain concepts:
// Model domain concepts clearly
[Union]
public partial record OrderStatus
{
public record Pending : OrderStatus;
public record Processing(DateTime StartedAt) : OrderStatus;
public record Completed(DateTime CompletedAt, string TrackingNumber) : OrderStatus;
public record Cancelled(string Reason) : OrderStatus;
}
// Generic result type for error handling
[Union]
public partial record Result<T>
{
public record Success(T Value) : Result<T>;
public record Failure(string Error) : Result<T>;
// Convenient implicit conversions
public static implicit operator Result<T>(T value) => new Success(value);
public static implicit operator Result<T>(string error) => new Failure(error);
}
// Usage
Result<int> result = await GetDataAsync();
var message = result.Switch(
success: s => $"Got value: {s.Value}",
failure: f => $"Error: {f.Error}"
);