Skip to content

Discriminated Unions

Pawel Gerr edited this page Dec 9, 2024 · 5 revisions

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.

Requirements

  • C# 11 (or higher) for generated code
  • SDK 8.0.400 (or higher) for building projects

Getting started

Required Nuget package: Thinktecture.Runtime.Extensions

There are 2 types of unions: ad hoc union and "regular" unions.

Ad hoc unions

The main feature of this library is to be able to rename properties to get meaningful names, i.e. IsMyValue instead of IsT1.

In the following section we will implement union TextOrNumber which can be a string or int.

What you implement

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;

What is implemented for you

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");

Customizing

Renaming of properties

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));

Definition of nullable reference types

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;

Default string comparison

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;

Skip implementation of ToString

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;

Constructor access modifier

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;
   }
}

Skip implementation of implicit casts

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;
   }
}

Switch and Map with partial coverage

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");

Regular unions

Features:

  • Roslyn Analyzers and CodeFixes help the developers to implement the unions correctly
  • Can be a class or record
  • 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));
Clone this wiki locally