This library provides some interfaces, classes, Roslyn Source Generators, Roslyn Analyzers and Roslyn CodeFixes for implementation of Smart Enums, Value Objects and Discriminated Unions.
- Requirements
- Migrations
- Smart Enum
- Value Objects
- Discriminated Unions (requires version 8.x.x)
See wiki for more documentation.
Smart Enums:
Value objects:
- 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
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/Map
- 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 and maybe-valid Smart Enum
- Smart Enum can also be keyless, i.e. without a key member
- Makes use of abstract static members
- Derived types can be generic
- Allows custom validation of constructor arguments
- Allows changing the key member name, kind and access modifier, which holds the underlying value - thanks to Roslyn Source Generator
- Allows custom key equality comparer and custom comparer
- JSON support (
System.Text.Json
andNewtonsoft.Json
) - Support for Minimal Api Parameter Binding and ASP.NET Core Model Binding
- Entity Framework Core support (
ValueConverter
) - MessagePack support (
IMessagePackFormatter
) - Logging for debugging or getting insights
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);
}
// Smart Enum without identifier (keyless)
[SmartEnum]
public sealed partial class SalesCsvImporterType
{
public static readonly SalesCsvImporterType Daily = new(articleIdIndex: 0, volumeIndex: 2);
public static readonly SalesCsvImporterType Monthly = new(articleIdIndex: 2, volumeIndex: 0);
public int ArticleIdIndex { get; }
public int VolumeIndex { get; }
}
Behind the scenes a Roslyn Source Generator generates additional code. Some of the features that are now available are ...
// A private constructor which takes the key "Groceries" 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.
// Throws UnknownEnumIdentifierException if the provided key doesn't belong to any item
ProductType productType = ProductType.Get("Groceries");
// Alternatively, using an explicit cast (behaves the same as "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 ValidationError instead of a boolean.
ValidationError? validationError = ProductType.Validate("Groceries", null, out ProductType? productType);
if (validationError is null)
{
logger.Information("Product type {Type} found with Validate", productType);
}
else
{
logger.Warning("Failed to fetch the product type with Validate. Validation error: {validationError}", validationError.ToString());
}
------------
// implicit conversion to the type of the key
string key = ProductType.Groceries; // "Groceries"
------------
// Equality comparison
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 (with "Action")
productType.Switch(groceries: () => logger.Information("Switch with Action: Groceries"),
housewares: () => logger.Information("Switch with Action: Housewares"));
// Switch-case with parameter (Action<TParam>) to prevent closures
productType.Switch(logger,
groceries: static l => l.Information("Switch with Action: Groceries"),
housewares: static l => l.Information("Switch with Action: Housewares"));
// Switch case returning a value (Func<TResult>)
var returnValue = productType.Switch(groceries: static () => "Switch with Func<T>: Groceries",
housewares: static () => "Switch with Func<T>: Housewares");
// Switch case with parameter returning a value (Func<TParam, TResult>) to prevent closures
var returnValue = productType.Switch(logger,
groceries: static l => "Switch with Func<T>: Groceries",
housewares: static l => "Switch with Func<T>: Housewares");
// Map an item to another instance
returnValue = productType.Map(groceries: "Map: Groceries",
housewares: "Map: Housewares");
------------
// Implements IParsable<T> which is especially helpful with minimal apis.
bool parsed = ProductType.TryParse("Groceries", null, out ProductType? parsedProductType);
------------
// Implements IFormattable if the underlyng type (like int) is an IFormattable itself.
var formatted = ProductGroup.Fruits.ToString("000", CultureInfo.InvariantCulture); // 001
------------
// Implements IComparable and IComparable<T> if the key member type (like int) is an IComparable itself.
var comparison = ProductGroup.Fruits.CompareTo(ProductGroup.Vegetables); // -1
// Implements comparison operators (<,<=,>,>=) if the underlyng type (like int) has comparison operators itself.
var isBigger = ProductGroup.Fruits > ProductGroup.Vegetables;
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 sealed 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
- Choice between Simple Value Objects and Complex Value Objects
- Allows custom fields, properties and methods
- Provides appropriate factory methods for creation of new value objects based on the specified properties/fields
- Factory methods can be renamed
- Allows custom validation of constructor and factory method arguments
- Allows custom type to pass validation error(s)
- [Simple Value Objects only] Allows cast and type conversion from key-member type to Value Object and vice versa
- [Simple Value Objects only] Provides an implementation of
IFormattable
if the key member 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
- Configurable handling of null and empty strings
- JSON support (
System.Text.Json
andNewtonsoft.Json
) - Support for Minimal Api Parameter Binding and ASP.NET Core Model Binding
- Entity Framework Core support (
ValueConverter
) - MessagePack support (
IMessagePackFormatter
) - Logging for debugging or getting insights
A simple value object has 1 field/property only, i.e., it is kind of wrapper for another (primitive) type. The main use case is to prevent creation of values/instances which are considered invalid according to some rules. In DDD (domain-driven design), working with primitive types, like string, directly is called primitive obsession and should be avoided.
Most simple value objects with a key member of type string
and another one (which is a struct) with an int
.
[ValueObject<string>]
public sealed partial class ProductName
{
}
[ValueObject<int>]
public readonly partial struct Amount
{
}
After the implementation of a value object, 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 (if we had any)
ProductName apple = ProductName.Create("Apple");
// Alternatively, using an explicit cast, which behaves the same way as calling "ProductName.Create"
ProductName apple = (ProductName)"Apple";
-----------
// The same as above but returns a bool instead of throwing an exception (dictionary-style)
bool created = ProductName.TryCreate("Chocolate", out ProductName? chocolate);
-----------
// Similar to TryCreate but returns a ValidationError instead of a boolean.
ValidationError? validationError = ProductName.Validate("Chocolate", null, out var chocolate);
if (validationError is null)
{
logger.Information("Product name {Name} created", chocolate);
}
else
{
logger.Warning("Failed to create product name. Validation result: {validationError}", validationError.ToString());
}
-----------
// Implicit conversion to the type of the key member
string valueOfTheProductName = apple; // "Apple"
-----------
// Equality comparison compares the key member using default comparer by default.
// Key members of type `string` are compared with 'StringComparer.OrdinalIgnoreCase' by default.
bool equal = apple.Equals(apple);
-----------
// Equality comparison operators: '==' and '!='
bool equal = apple == apple;
bool notEqual = apple != apple;
-----------
// Hash code: combined hash code of type and key member.
// Strings are using 'StringComparer.OrdinalIgnoreCase' by default.
int hashCode = apple.GetHashCode();
-----------
// 'ToString' implementation return the string representation of the key member
string value = apple.ToString(); // "Apple"
------------
// Implements IParsable<T> which is especially helpful with minimal apis.
bool success = ProductName.TryParse("New product name", null, out var productName);
ProductName productName = ProductName.Parse("New product name", null);
------------
// Implements "IFormattable" if the key member is an "IFormattable".
Amount amount = Amount.Create(42);
string formattedValue = amount.ToString("000", CultureInfo.InvariantCulture); // "042"
------------
// Implements "IComparable<ProductName>" if the key member is an "IComparable",
// or if custom comparer is provided.
Amount amount = Amount.Create(1);
Amount otherAmount = Amount.Create(2);
int comparison = amount.CompareTo(otherAmount); // -1
------------
// Implements comparison operators (<,<=,>,>=) if the key member has comparison operators itself.
bool isBigger = amount > otherAmount;
// Implements comparison operators to compare the value object with an instance of key-member-type,
// if "ComparisonOperators" is set "OperatorsGeneration.DefaultWithKeyTypeOverloads"
bool isBigger = amount > 2;
------------
// Implements addition / subtraction / multiplication / division if the key member supports corresponding operators
Amount sum = amount + otherAmount;
// Implements operators that accept an instance of key-member-type,
// if the "OperatorsGeneration" is set "DefaultWithKeyTypeOverloads"
Amount sum = amount + 2;
------------
// Provides a static default value "Empty" (similar to "Guid.Empty"), if the value object is a struct
Amount defaultValue = Amount.Empty; // same as "Amount defaultValue = default;"
A complex value object is an immutable class
or a readonly struct
with a ComplexValueObjectAttribute
. Complex value object usually has multiple readonly fields/properties.
A simple example would be a Boundary
with 2 properties. One property is the lower boundary and the other is the upper boundary. Yet again, we skip the validation at the moment.
[ComplexValueObject]
public sealed partial class Boundary
{
public decimal Lower { get; }
public decimal Upper { get; }
}
The rest is implemented by a Roslyn source generator, providing the following API:
// Factory method for creation of new instances.
// Throws ValidationException if the validation fails (if we had any)
Boundary boundary = Boundary.Create(lower: 1, upper: 2);
-----------
// the same as above but returns a bool instead of throwing an exception (dictionary-style)
bool created = Boundary.TryCreate(lower: 1, upper: 2, out Boundary? boundary);
-----------
// similar to TryCreate but returns a ValidationError instead of a boolean.
ValidationError? validationError = Boundary.Validate(lower: 1, upper: 2, out Boundary? boundary);
if (validationError is null)
{
logger.Information("Boundary {Boundary} created", boundary);
}
else
{
logger.Warning("Failed to create boundary. Validation result: {validationError}", validationError.ToString());
}
-----------
// Equality comparison compares the members using default or custom comparers.
// Strings are compared with 'StringComparer.OrdinalIgnoreCase' by default.
bool equal = boundary.Equals(boundary);
-----------
// Equality comparison with '==' and '!='
bool equal = boundary == boundary;
bool notEqual = boundary != boundary;
-----------
// Hash code of the members according default or custom comparers
int hashCode = boundary.GetHashCode();
-----------
// 'ToString' implementation
string value = boundary.ToString(); // "{ Lower = 1, Upper = 2 }"
Install: Install-Package Thinktecture.Runtime.Extensions
(requires version 8.x.x)
Documentation: Discriminated Unions
There are 2 types of unions: ad hoc union
and "regular" unions
Features:
- Roslyn Analyzers and CodeFixes help the developers to implement the 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 using a class
, a struct
or ref struct
:
// class
[Union<string, int>]
public partial class TextOrNumber;
// struct
[Union<string, int>]
public partial struct TextOrNumber;
// ref struct
[Union<string, int>]
public ref partial struct 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));
Features:
- Roslyn Analyzers and CodeFixes help the developers to implement the unions correctly
- Can be a
class
orrecord
- Switch-Case/Map
- Supports generics
- Derived types can be simple classes or something complex like a value object.
Simple union using a class and a value object:
[Union]
public partial class Animal
{
[ValueObject<string>]
public partial class Dog : Animal;
public sealed class Cat : Animal;
}
Similar example as above but with records
:
[Union]
public partial record AnimalRecord
{
public sealed record Dog(string Name) : AnimalRecord;
public sealed record Cat(string Name) : AnimalRecord;
}
A union type (i.e. the base class) with a property:
[Union]
public partial class Animal
{
public string Name { get; }
private Animal(string name)
{
Name = name;
}
public sealed class Dog(string Name) : Animal(Name);
public sealed class Cat(string Name) : Animal(Name);
}
A record
with a generic:
[Union]
public partial record Result<T>
{
public record Success(T Value) : Result<T>;
public record Failure(string Error) : Result<T>;
public static implicit operator Result<T>(T value) => new Success(value);
public static implicit operator Result<T>(string error) => new Failure(error);
}
One of the main purposes for a regular union is their exhaustiveness, i.e. all member types are accounted for in a switch/map:
Animal animal = new Animal.Dog("Milo");
animal.Switch(dog: d => logger.Information("Dog: {Dog}", d),
cat: c => logger.Information("Cat: {Cat}", c));
var result = animal.Map(dog: "Dog",
cat: "Cat");
Use flags SwitchMethods
and MapMethods
for generation of SwitchPartially
/MapPartially
:
[Union(SwitchMethods = SwitchMapMethodsGeneration.DefaultWithPartialOverloads,
MapMethods = SwitchMapMethodsGeneration.DefaultWithPartialOverloads)]
public partial record AnimalRecord
{
public sealed record Dog(string Name) : AnimalRecord;
public sealed record Cat(string Name) : AnimalRecord;
}
---------------------------
Animal animal = new Animal.Dog("Milo");
animal.SwitchPartially(@default: a => logger.Information("Default: {Animal}", a),
cat: c => logger.Information("Cat: {Cat}", c.Name));