-
Notifications
You must be signed in to change notification settings - Fork 1
Home
This library provides some interfaces, classes, Roslyn Source Generators, Roslyn Analyzers and Roslyn CodeFixes for implementation of Smart Enums, Value Objects and Discriminated Unions.
- Smart Enums
- Value Objects
- Discriminated Unions
- Convenience methods and classes
- 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
- Version 6:
- C# 11 (or higher) for generated code
- SDK 7.0.102 (or higher) for building projects
- Version 5:
- C# 9 (or higher) for generated code
- SDK 6.0.300 (or higher) for building projects
Install: Install-Package Thinktecture.Runtime.Extensions
Documentation: Smart Enums
Features:
- Roslyn Analyzers and CodeFixes help the developers to implement the Smart Enums correctly
- Allows iteration over all items
- Allows custom properties and methods
- Switch-case
- Provides appropriate constructor, based on the specified properties/fields
- Provides means for lookup, cast and type conversion from key-type to Smart Enum and vice versa
- Provides proper implementation of
Equals
,GetHashCode
,ToString
and equality comparison via==
and!=
- Provides implementation of
IComparable
,IComparable<T>
,IFormattable
,IParsable<T>
and comparison operators<
,<=
,>
,>=
(if applicable to the underlying type) - Choice between always-valid Smart Enum and maybe-valid one
- Makes use of abstract static members
- Derived types can be generic
- Allows custom validation of constructor arguments
- Allows changing the property name
Key
- Allows custom key comparer
-
JSON support (
System.Text.Json
andNewtonsoft.Json
) - Support for Minimal Web Api Parameter Binding and ASP.NET Core Model Binding
-
Entity Framework Core support (
ValueConverter
) -
MessagePack support (
IMessagePackFormatter
)
Definition of a 2 Smart Enums without any custom properties and methods. All other features mentioned above are generated by the Roslyn Source Generators in the background.
// Smart Enum with a string as the underlying type
[SmartEnum<string>]
public sealed partial class ProductType
{
public static readonly ProductType Groceries = new("Groceries");
public static readonly ProductType Housewares = new("Housewares");
}
// Smart Enum with an int as the underlying type
[SmartEnum<int>]
public sealed partial class ProductGroup
{
public static readonly ProductGroup Apple = new(1);
public static readonly ProductGroup Orange = new(2);
}
Behind the scenes a Roslyn Source Generator, which comes with the library, generates additional code. Some of the features that are now available are ...
// a private constructor which takes the key and additional members (if we had any)
[SmartEnum<string>]
public sealed partial class ProductType
{
public static readonly ProductType Groceries = new("Groceries");
...
------------
// a property for iteration over all items
IReadOnlyList<ProductType> allTypes = ProductType.Items;
------------
// getting the item with specific name, i.e. its key
// throw UnknownEnumIdentifierException if the provided key doesn't match to any item
ProductType productType = ProductType.Get("Groceries");
// Alternatively, using an explicit cast (behaves the same as with Get)
ProductType productType = (ProductType)"Groceries";
------------
// the same as above but returns a bool instead of throwing an exception (dictionary-style)
bool found = ProductType.TryGet("Groceries", out ProductType productType);
------------
// similar to TryGet but accepts `IFormatProvider` and returns a ValidationResult instead of a boolean.
ValidationResult? validationResult = ProductType.Validate("Groceries", null, out productType);
if (validationResult == ValidationResult.Success)
{
logger.Information("Product type {Type} found with Validate", productType);
}
else
{
logger.Warning("Failed to fetch the product type with Validate. Validation result: {ValidationResult}", validationResult.ErrorMessage);
}
------------
// implicit conversion to the type of the key
string key = ProductType.Groceries; // "Groceries"
------------
// Equality comparison with 'Equals'
// which compares the keys using default or custom 'IEqualityComparer<T>'
bool equal = ProductType.Groceries.Equals(ProductType.Groceries);
------------
// Equality comparison with '==' and '!='
bool equal = ProductType.Groceries == ProductType.Groceries;
bool notEqual = ProductType.Groceries != ProductType.Groceries;
------------
// Hash code
int hashCode = ProductType.Groceries.GetHashCode();
------------
// 'ToString' implementation
string key = ProductType.Groceries.ToString(); // "Groceries"
------------
ILogger logger = ...;
// Switch-case (Action)
productType.Switch(ProductType.Groceries, () => logger.Information("Switch with Action: Groceries"),
ProductType.Housewares, () => logger.Information("Switch with Action: Housewares"));
// Switch-case with parameter (Action<TParam>) to prevent closures
productType.Switch(logger,
ProductType.Groceries, static l => l.Information("Switch with Action: Groceries"),
ProductType.Housewares, static l => l.Information("Switch with Action: Housewares"));
// Switch case returning a value (Func<TResult>)
var returnValue = productType.Switch(ProductType.Groceries, static () => "Switch with Func<T>: Groceries",
ProductType.Housewares, static () => "Switch with Func<T>: Housewares");
// Switch case with parameter returning a value (Func<TParam, TResult>) to prevent closures
returnValue = productType.Switch(logger,
ProductType.Groceries, static l => "Switch with Func<T>: Groceries",
ProductType.Housewares, static l => "Switch with Func<T>: Housewares");
------------
// Implements IParsable<T> which is especially helpful with minimal web apis.
// This feature can be disabled if it doesn't make sense (see EnumGenerationAttribute).
bool parsed = ProductType.TryParse("Groceries", null, out var parsedProductType);
------------
// Implements IFormattable if the underlyng type (like int) is an IFormattable itself.
// This feature can be disabled if it doesn't make sense (see EnumGenerationAttribute).
var formatted = ProductGroup.Apple.ToString("000", CultureInfo.InvariantCulture); // 001
------------
// Implements IComparable and IComparable<T> if the underlyng type (like int) is an IComparable itself.
// This feature can be disabled if it doesn't make sense (see EnumGenerationAttribute).
var comparison = ProductGroup.Apple.CompareTo(ProductGroup.Orange); // -1
// Implements comparison operators (<,<=,>,>=) if the underlyng type (like int) has comparison operators itself.
// This feature can be disabled if it doesn't make sense (see EnumGenerationAttribute).
var isBigger = ProductGroup.Apple > ProductGroup.Orange;
Definition of a new Smart Enum with 1 custom property RequiresFoodVendorLicense
and 1 method Do
with different behaviors for different enum items.
[SmartEnum<string>]
public partial class ProductType
{
public static readonly ProductType Groceries = new("Groceries", requiresFoodVendorLicense: true);
public static readonly ProductType Housewares = new HousewaresProductType();
public bool RequiresFoodVendorLicense { get; }
public virtual void Do()
{
// do default stuff
}
private class HousewaresProductType : ProductType
{
public HousewaresProductType()
: base("Housewares", requiresFoodVendorLicense: false)
{
}
public override void Do()
{
// do special stuff
}
}
}
Install: Install-Package Thinktecture.Runtime.Extensions
Documentation: Value Objects
Features:
- Roslyn Analyzers and CodeFixes help the developers to implement the Value Objects correctly
- Allows custom properties and methods
- Provides appropriate factory methods for creation of new value objects based on the specified properties/fields
- Allows custom validation of constructor and factory method arguments
- Additional features for simple Value Objects (1 "key"-property/field) and complex Value Objects (2 properties/fields or more)
- Simple Value Objects: allows cast and type conversion from key-type to Value Object and vice versa
- Simple Value Objects: provides an implementation of
IFormattable
if the key-property/field is anIFormattable
- Provides proper implementation of
Equals
,GetHashCode
,ToString
and equality comparison via==
and!=
- Provides implementation of
IComparable
,IComparable<T>
,IFormattable
,IParsable<T>
and comparison operators<
,<=
,>
,>=
- Allows custom equality comparison and custom comparer
- Handling of null and empty strings
-
JSON support (
System.Text.Json
andNewtonsoft.Json
) - Support for Minimal Web Api Parameter Binding and ASP.NET Core Model Binding
-
Entity Framework Core support (
ValueConverter
) -
MessagePack support (
IMessagePackFormatter
)
Definition of 2 value objects, one with 1 string
property Value
and the other with an int
.
[ValueObject]
public sealed partial class ProductName
{
public string Value { get; }
}
[ValueObject]
public sealed partial class Amount
{
private readonly int _value;
}
After the implementation of the ProductName
, a Roslyn source generator kicks in and implements the rest. Following API is available from now on.
// Factory method for creation of new instances.
// Throws ValidationException if the validation fails
ProductName bread = ProductName.Create("Bread");
// Alternatively, using an explicit cast (behaves the same as with Create)
ProductName bread = (ProductName)"Bread"; // is the same as calling "ProductName.Create"
-----------
// the same as above but returns a bool instead of throwing an exception (dictionary-style)
bool created = ProductName.TryCreate("Milk", out ProductName milk);
-----------
// similar to TryCreate but accepts `IFormatProvider` and returns a ValidationResult instead of a boolean.
ValidationResult? validationResult = ProductName.Validate("Milk", null, out var milk);
if (validationResult == ValidationResult.Success)
{
logger.Information("Product name {Name} created", milk);
}
else
{
logger.Warning("Failed to create product name. Validation result: {ValidationResult}", validationResult.ErrorMessage);
}
-----------
// implicit conversion to the type of the key member
string valueOfTheProductName = bread; // "Bread"
-----------
// Equality comparison with 'Equals'
// which compares the key members using default or custom 'IEqualityComparer<T>'
bool equal = bread.Equals(bread);
-----------
// Equality comparison with '==' and '!='
bool equal = bread == bread;
bool notEqual = bread != bread;
-----------
// Hash code
int hashCode = bread.GetHashCode();
-----------
// 'ToString' implementation
string value = bread.ToString(); // "Bread"
------------
// Implements IParsable<T> which is especially helpful with minimal web apis.
// This feature can be disabled if it doesn't make sense (see ValueObjectAttribute).
bool success = ProductName.TryParse("New product name", null, out var productName);
------------
// Implements "IFormattable" if the key member is an "IFormattable".
// This feature can be disabled if it doesn't make sense (see ValueObjectAttribute).
var amount = Amount.Create(42);
string formattedValue = amount.ToString("000", CultureInfo.InvariantCulture); // "042"
------------
// Implements "IComparable<ProductName>" if the key member is an "IComparable"
// This feature can be disabled if it doesn't make sense (see ValueObjectAttribute).
var amount = Amount.Create(1);
var otherAmount = Amount.Create(2);
var comparison = amount.CompareTo(otherAmount); // -1
// Implements comparison operators (<,<=,>,>=) if the key member has comparison operators itself.
// This feature can be disabled if it doesn't make sense (see ValueObjectAttribute).
var isBigger = amount > otherAmount;
------------
// Implements addition / subtraction / multiplication / division if the key member supports operators
// This feature can be disabled if it doesn't make sense (see ValueObjectAttribute).
var sum = amount + otherAmount;
Definition of a complex value object with 2 properties and a custom validation of the arguments.
[ValueObject]
public partial class Boundary
{
public decimal Lower { get; }
public decimal Upper { get; }
static partial void ValidateFactoryArguments(ref ValidationResult? validationResult, ref decimal lower, ref decimal upper)
{
if (lower <= upper)
return;
validationResult = new ValidationResult($"Lower boundary '{lower}' must be less than upper boundary '{upper}'");
}
}
Features:
* Roslyn Analyzers and CodeFixes help the developers to implement the Discriminated Unions correctly
* Provides proper implementation of Equals
, GetHashCode
, ToString
and equality comparison via ==
and !=
* Switch-Case/Map
* Renaming of properties
* Definition of nullable reference types
Definition of a basic union with 2 types:
[Union<string, int>]
public partial class TextOrNumber;
// Up to 5 types
[Union<string, int, bool, Guid, char>]
public partial class MyUnion;
Behind the scenes a Roslyn Source Generator generates additional code. Some of the features that are now available are ...
// Implicit conversion from one of the defined generics.
TextOrNumber textOrNumberFromString = "text";
TextOrNumber textOrNumberFromInt = 42;
// Check the type of the value.
// By default, the properties are named using the name of the type (`String`, `Int32`)
bool isText = textOrNumberFromString.IsString;
bool isNumber = textOrNumberFromString.IsInt32;
// Getting the typed value.
// Throws "InvalidOperationException" if the current value doesn't match the calling property.
// By default, the properties are named using the name of the type (`String`, `Int32`)
string text = textOrNumberFromString.AsString;
int number = textOrNumberFromInt.AsInt32;
// Alternative approach is to use explicit cast.
// Behavior is identical to methods "As..."
string text = (string)textOrNumberFromString;
int number = (int)textOrNumberFromInt;
// Getting the value as object, i.e. untyped.
object value = textOrNumberFromString.Value;
// Implementation of Equals, GetHashCode and ToString
// PLEASE NOTE: Strings are compared using "StringComparison.OrdinalIgnoreCase" by default! (configurable)
bool equals = textOrNumberFromInt.Equals(textOrNumberFromString);
int hashCode = textOrNumberFromInt.GetHashCode();
string toString = textOrNumberFromInt.ToString();
// Equality comparison operators
bool equal = textOrNumberFromInt == textOrNumberFromString;
bool notEqual = textOrNumberFromInt != textOrNumberFromString;
There are multiple overloads of switch-cases: with Action
, Func<T>
and concrete values.
To prevent closures, you can pass a value to method Switch
, which is going to be passed to provided callback (Action
/Func<T>
).
By default, the names of the method arguments are named after the type specified by UnionAttribute<T1, T2>
.
Reserved C# keywords (like string
) must string with @
(like @string
, @default
, etc.).
// With "Action"
textOrNumberFromString.Switch(@string: s => logger.Information("[Switch] String Action: {Text}", s),
int32: i => logger.Information("[Switch] Int Action: {Number}", i));
// With "Action". Logger is passed as additional parameter to prevent closures.
textOrNumberFromString.Switch(logger,
@string: static (l, s) => l.Information("[Switch] String Action with logger: {Text}", s),
int32: static (l, i) => l.Information("[Switch] Int Action with logger: {Number}", i));
// With "Func<T>"
var switchResponse = textOrNumberFromInt.Switch(@string: static s => $"[Switch] String Func: {s}",
int32: static i => $"[Switch] Int Func: {i}");
// With "Func<T>" and additional argument to prevent closures.
var switchResponseWithContext = textOrNumberFromInt.Switch(123.45,
@string: static (value, s) => $"[Switch] String Func with value: {ctx} | {s}",
int32: static (value, i) => $"[Switch] Int Func with value: {ctx} | {i}");
// Use `Map` instead of `Switch` to return concrete values directly.
var mapResponse = textOrNumberFromString.Map(@string: "[Map] Mapped string",
int32: "[Map] Mapped int");
Use T1Name
/T2Name
of the UnionAttribute
to get more meaningful names.
[Union<string, int>(T1Name = "Text",
T2Name = "Number")]
public partial class TextOrNumber;
The properties and method arguments are renamed accordingly:
bool isText = textOrNumberFromString.IsText;
bool isNumber = textOrNumberFromString.IsNumber;
string text = textOrNumberFromString.AsText;
int number = textOrNumberFromInt.AsNumber;
textOrNumberFromString.Switch(text: s => logger.Information("[Switch] String Action: {Text}", s),
number: i => logger.Information("[Switch] Int Action: {Number}", i));