-
Notifications
You must be signed in to change notification settings - Fork 1
Discriminated Unions
This library provides an easy way for implementation of Discriminated Unions. The library comes with some Roslyn Analyzers and Code Fixes to guide the software developers through the implementation.
- C# 11 (or higher) for generated code
- SDK 8.0.400 (or higher) for building projects
There are 2 types of unions: ad hoc union
and "regular" unions
.
The main feature of this library is to be able to rename properties to get meaningful names, i.e.
IsMyValue
instead ofIsT1
.
In the following section we will implement union TextOrNumber
which can be a string
or int
.
Create a partial
class
, struct
or ref struct
and annotate it with UnionAttribute<T1, T2>
.
// 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;
The UnionAttribute
allows 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");
In previous section the names are the same as the corresponding type. 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));
Alas, C# doesn't allow the definition of nullable reference types in generics.
// Definition of "string?" is not allowed in C#
[Union<string?, int>]
public partial class TextOrNumber;
Set T1IsNullableReferenceType
to true to instruct the source generator to generate code with string?
instead of string
.
[Union<string, int>(T1IsNullableReferenceType = true)]
public partial class TextOrNumber;
By default, the strings are compared using StringComparison.OrdinalIgnoreCase
.
Use DefaultStringComparison
to change this behavior.
[Union<string, int>(DefaultStringComparison = StringComparison.Ordinal)]
public partial class TextOrNumber;
Use UnionAttribute<T1, T2>
to set SkipToString
to true
to disable the implementation of the method ToString()
.
[Union<string, int>(SkipToString = true)]
public partial class TextOrNumber;
By default, the constructors are public
.
Use ConstructorAccessModifier
to change this behavior.
This feature is useful in conjunction with SkipImplicitConversionFromValue
for creation of unions with additional properties.
[Union<string, int>(T1Name = "Text",
T2Name = "Number",
SkipImplicitConversionFromValue = true,
ConstructorAccessModifier = UnionConstructorAccessModifier.Private)]
public partial class TextOrNumberExtended
{
public required string AdditionalProperty { get; init; }
public TextOrNumberExtended(string text, string additionalProperty)
: this(text)
{
AdditionalProperty = additionalProperty;
}
public TextOrNumberExtended(int number, string additionalProperty)
: this(number)
{
AdditionalProperty = additionalProperty;
}
}
Use SkipImplicitConversionFromValue
to instruct the source generator to NOT generate implicit conversions.
This feature is useful in conjunction with ConstructorAccessModifier
for creation of unions with additional properties.
[Union<string, int>(T1Name = "Text",
T2Name = "Number",
SkipImplicitConversionFromValue = true,
ConstructorAccessModifier = UnionConstructorAccessModifier.Private)]
public partial class TextOrNumberExtended
{
public required string AdditionalProperty { get; init; }
public TextOrNumberExtended(string text, string additionalProperty)
: this(text)
{
AdditionalProperty = additionalProperty;
}
public TextOrNumberExtended(int number, string additionalProperty)
: this(number)
{
AdditionalProperty = additionalProperty;
}
}
Both methods, Switch
and Map
, require all arguments to be defined by the developers.
Use SwitchMethods
and MapMethods
to generate additional methods which does not require all arguments to be defined.
[Union<string, int, bool, Guid, char>(
SwitchMethods = SwitchMapMethodsGeneration.DefaultWithPartialOverloads,
MapMethods = SwitchMapMethodsGeneration.DefaultWithPartialOverloads)]
public partial class MyUnion;
New methods are SwitchPartially
and MapPartially
.
MyUnion myUnion = "text";
myUnion.SwitchPartially(@default: i => logger.Information("[SwitchPartially] Default Action: {Number}", i),
@string: s => logger.Information("[SwitchPartially] String Action: {Text}", s));
var switchResponse = myUnion.SwitchPartially(@default: static s => $"[SwitchPartially] Default Func: {s}",
@string: static s => $"[SwitchPartially] String Func: {s}");
var mapPartiallyResponse = myUnion.MapPartially(@default: "[MapPartially] Mapped default",
@string: "[MapPartially] Mapped string");
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));