DUnion 1.0.0

dotnet add package DUnion --version 1.0.0
NuGet\Install-Package DUnion -Version 1.0.0
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="DUnion" Version="1.0.0">
  <PrivateAssets>all</PrivateAssets>
  <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
For projects that support PackageReference, copy this XML node into the project file to reference the package.
paket add DUnion --version 1.0.0
#r "nuget: DUnion, 1.0.0"
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
// Install DUnion as a Cake Addin
#addin nuget:?package=DUnion&version=1.0.0

// Install DUnion as a Cake Tool
#tool nuget:?package=DUnion&version=1.0.0

DUnion

Easy source generator for creating custom discriminated unions.

Define a new discriminated union

[DUnion]
public static class CreateUserResult
{
    public readonly record struct Success(User User);
    public readonly record struct NameInUse(Guid UserId);
    public readonly record struct NameTooLong(int MaxLength);
    public readonly record struct NameTooShort(int MinLength);
}

Use the generated discriminated union

public CreateUserResult CreateUser(User user) 
{
    if (user.Name is null or { Length: < 5 }) 
    {
        return new CreateUserResult.NameTooShort(5);
    }
    
    if (user.Name.Length > 30) 
    {
        return new CreateUserResult.NameTooLong(30);
    }

    var existingUser = userStore.FindByName(user.Name);
    if (existingUser is not null)
    {
        return new CreateUserResult.NameInUse(existingUser.Id);
    }

    var createdUser = userStore.Add(user);
    return new CreateUserResult.Success(createdUser);
}

public IActionResult HandleSignUpRequest(User user) 
{
    return CreateUser(user)
        .Match(
            caseSuccess: success => Ok(success.User.Id),
            caseNameInUse: error => BadRequest($"The name is already in use by user {error.UserId}."),
            @default: () => BadRequest()
        )
}

Supports generics

// Looks like rust! https://doc.rust-lang.org/std/option/
[DUnion]
public static class Option
{
    public readonly record struct Some<T>(T Value);
    public readonly record struct None();
}

public Option<double> Divide(double numerator, double denominator) 
{
    if (denominator == 0)
    {
        return new Option.None();
    }
    else
    {
        return new Option.Some<double>(numerator / denominator);
    }
}

Divide(2.0, 3.0)
    .Switch(
        caseSome: some => Console.WriteLine($"Result: {some.Value}"),
        caseNone: none => Console.WriteLine("Cannot divide by 0")
    );

Multiple generics are also supported

By default, type parameters with the same name across cases will be merged into the same type parameter on the union, but you can customize this by using the [DUnionGeneric] attribute.

// Also looks a bit like rust! https://doc.rust-lang.org/std/result/
[DUnion]
public static class Result
{
    public readonly record struct Ok<TOk>(TOk Value);
    public readonly record struct Err<TErr>(TErr Error);
}

Or, if you want to use the same name for the type parameter on the cases

[DUnion]
public static class Result
{
    public readonly record struct Ok<[DUnionGeneric("TOk")]T>(T Value);
    public readonly record struct Err<[DUnionGeneric("TErr")]T>(T Error);
}

Then you can use it like this:


public enum Version 
{
    Version1,
    Version2
}

public Result<Version, string> ParseVersion(int[] header) 
{
    switch (header) 
    {
        case []: return new Result.Err<string>("Invalid header length");
        case [1]: return new Result.Ok<Version>(Version.Version1);
        case [2]: return new Result.Ok<Version>(Version.Version2);
        default: return new Result.Err<string>("Invalid version");
    }
}

ParseVersion([1, 2, 3, 4])
    .Switch(
        caseOk: ok => Console.WriteLine($"Working with version: {ok.Value}"),
        caseErr: err => Console.WriteLine($"Error parsing header: {err.Error}")
    );

Unions can be extended

You can add methods onto the union type itself to add custom helper methods, making using the union easier.

public readonly record struct JsonValue
{
    public readonly record struct String(string Value);
    public readonly record struct Number(double Value);
    public readonly record struct Boolean(bool Value);
    public readonly record struct Null();
    public readonly record struct Array(IReadOnlyList<JsonValue> Values);
    public readonly record struct Object(IReadOnlyDictionary<string, JsonValue> Properties);

    public string Stringify() 
    {
        return Match(
            caseString: str => Escape(str.Value),
            caseBoolean: bool => bool.Value.ToString(),
            caseNull: _ => "null",
            caseArray: arr => $"[{string.Join(",", arr.Values.Select(v => v.Stringify()))}]",
            caseObject: obj => $"{{{string.Join(",", obj.Properties.Select(p => $"{Escape(p.Key)}: {p.Value.Stringify()}"))}}}"
        )
    }

    private static string Escape(string value) 
    {
        return "\"...\"";
    }
}

Using in multiple projects

In most situations, you will be fine to add this source generator to any of your projects, however it does come with a bit of duplication if you do so. Each place where you add this package will have a set of internal attributes added, namely DUnion.DUnionAttribute, DUnion.DUnionCaseAttribute, DUnion.DUnionGenericAttribute, and DUnion.DUnionExcludeAttribute. These might therefore be duplicated many times and slightly inflate your build output. Theres also an issue with the [InternalsVisibleTo] attribute. If two projects have the source generator installed, and one has its internals visible to the other, then the build will fail due to ambiguous references to the attributes.

To solve all these issues, you can install the DUnion.Attributes package, and add the following to your .csproj files:

<PropertyGroup>
    <DefineConstants>DUNION_OMIT_ATTRIBUTES</DefineConstants>
</PropertyGroup>

Everything should work just fine after that!

Union members

The generated unions have some members defined on them to allow you to interact with the case they wrap:

Constructors

The union type will automatically contain a constructor for each of the cases it encompasses. These constructors can be used to wrap a case so that it can be used wherever the union type is required. Typically you wont need to use the constructors as there are implicit conversions from the cases to the union, but theyre there nonetheless!

  • public MyUnion({Case} value)

Switch

The Switch method is intended to mimic the c# switch statement, meaning you supply a number of handlers for each case you wish to accept, and optionally supply a default case, which can be null. To help reduce situations where a newly added case is missed, there are two overloads of the Switch method:

  • The Switch method with a @default parameter has all other parameters marked as optional. This is useful if you only ever want to handle some of the cases, and are confident that you will not need to handle any potential future ones.

  • The Switch method without a @default parameter requires that all possible cases have an argument supplied, although you are allowed to supply null if you do not wish to handle a specific case. This overload is useful for when you want to ensure that any future cases that may be added to the union are not forgotten.

[DUnion]
public readonly record struct AccountType
{
    public readonly record struct User(string Email);
    public readonly record struct Admin(string Email);
    public readonly record struct Service(string Name, AccountType Owner);
    public readonly record struct System(Guid Id);
}

AccountType account = GetAccountType(id);


account.Switch(
    caseUser: user => { ... }, // Called if the account is of type AccountType.User
    caseAdmin: user => { ... }, // Called if the account is of type AccountType.Admin
    caseService: null // accounts of type AccountType.Service are ignored
    // Error: value for caseSystem is not supplied
)

account.Switch(
    caseUser: user => { ... }, // Called if the account is of type AccountType.User
    caseAdmin: user => { ... }, // Called if the account is of type AccountType.Admin
    caseService: null, // accounts of type AccountType.Service are ignored
    @default: null // accounts of type AccountType.System and any other ones added in the future are ignored
)

The name of the Switch method can be changed by setting the SwitchName property on the [DUnion] attribute.

[DUnion(SwitchName = "MyPreferredSwitchName")]
public readonly record struct MyUnion
{
    ...
}

Match

The Match method is intended to mimic the c# switch expression, meaning you supply a number of handlers for each case you wish to accept, and optionally supply a default case. One of these handlers will be called, and its result will be returned. To help reduce situations where a newly added case is missed, there are two overloads of the Match method:

  • The Match method with a @default parameter has all other parameters marked as optional. This is useful if you only ever want to handle some of the cases, and are confident that you will not need to handle any potential future ones.

  • The Match method without a @default parameter requires that all possible cases have an argument supplied. This overload is useful for when you want to ensure that any future cases that may be added to the union are not forgotten.

[DUnion]
public readonly record struct AccountType
{
    public readonly record struct User(string Email);
    public readonly record struct Admin(string Email);
    public readonly record struct Service(string Name, AccountType Owner);
    public readonly record struct System(Guid Id);
}

AccountType account = GetAccountType(id);

account.Match(
    caseUser: user => { return ... }, // Called if the account is of type AccountType.User
    caseAdmin: user => { return ... }, // Called if the account is of type AccountType.Admin
    caseService: null // Error: ArgumentNullException
    // Error: value for caseSystem is not supplied
)

account.Match(
    caseUser: user => { return ... }, // Called if the account is of type AccountType.User
    caseAdmin: user => { return ... }, // Called if the account is of type AccountType.Admin
    @default: () => { return ... } // accounts of type AccountType.System, AccountType.Service and any other ones added in the future will cause the default to be called
)

The name of the Match method can be changed by setting the MatchName property on the [DUnion] attribute.

[DUnion(MatchName = "MyPreferredMatchName")]
public readonly record struct MyUnion
{
    ...
}

Is{Case}

The Is{Case} method is intended to mimic the x is Case value expression. It returns true and sets out value to the value of the case if the current union represents the specified case; otherwise false and default(Case) will be used.

[DUnion]
public static class Option
{
    public readonly record struct Some<T>(T Value);
    public readonly record struct None();
}

Option<int> result = GetResult();
if (result.IsSome(out var some)) 
{
    // Do something with `some`
}

if (result.IsNone(out var none))
{
    // Do something with `none`
}

The name of the Is{Case} method can be changed by setting the IsCaseName property on the [DUnionCase] attribute.

[DUnion]
public readonly record struct MyUnion
{
    [DUnionCase(IsCaseName = "IsSuccess")]
    public readonly record struct Ok();
}

As{Case}OrDefault

The As{Case}OrDefault method is intended to mimic the x as Case expression. It returns the value of the case if the union represents the specified case type; otherwise a default value will be returned.

There are three overloads for As{Case}OrDefault, allowing you to specify what should be used as the default value in the case.

  • No arguments will use default(Case) as the default return value.
  • A Func<Case> argument will use the result of invoking the delegate as the default return value.
  • A Case argument will use that value as the default return value.
[DUnion]
public static class Option
{
    public record Some<T>(T Value);
    public record None();
}

Option<int> result = GetResult();
var nullOrSome = result.AsSomeOrDefault();
var alwaysSome = result.AsSomeOrDefault(new Option.Some<T>(0));
var alsoAlwaysSome = result.AsSomeOrDefault(() => new Option.Some<T>(0));

var nullOrNone = result.AsNoneOrDefault();
var alwaysNone = result.AsNoneOrDefault(new Option.None());
var alsoAlwaysNone = result.AsNoneOrDefault(() => new Option.None());

The name of the As{Case}OrDefault method can be changed by setting the AsCaseOrDefault property on the [DUnionCase] attribute.

[DUnion]
public readonly record struct MyUnion
{
    [DUnionCase(AsCaseOrDefault = "AsSuccessOrDefault")]
    public readonly record struct Ok();
}

IEquatable<Union>

All unions are equatable to themselves. For two unions to be considered equal, both the type of their case, and the value of their case must be equal. The following methods are implemented which relate to this:

  • static bool Equals(MyUnion left, MyUnion right)
  • bool Equals(MyUnion other)
  • bool IEquatable<MyUnion>.Equals(MyUnion other)
  • override bool Equals(object? other)
  • static bool operator ==(MyUnion left, MyUnion right)
  • static bool operator !=(MyUnion left, MyUnion right)
  • override int GetHashCode()

Casting

Unions can also be converted to their cases via casting, and vice versa. Going from a case to a union is an implicit cast, while going from a union to a case is explicit and may throw an exception if the cast is not valid. The following methods are implemented which relate to this:

  • static implicit operator MyUnion({Case} value)
  • static explicit operator {Case}(MyUnion value)

NOTE: Conversions to and from an interface cannot be defined, so if a case is an interface you cannot cast between it and the union, and vice versa.

Fields

There are two private readonly fields located on the union instances. These fields generally should not be used for anything, but you can expose them through some readonly properties if you wish. I would advise not attepting to set these values yourself, for reasons detailed below.

byte _discriminator

This holds a number indicating which type of case the union is currently wrapping. The mapping from the value of this field to the case type is not stored anywhere, so you should not rely on the value of this. If the order in which you define the cases changes, the meaning of the values of this field will also change. This field may also be a ushort instead of a byte if there are more than 254 cases.

The only value whos meaning is built in is 0. A value of 0 means this union instance has not been constructed properly. Ordinarily this can only happen if the union is a struct and is the default value.

[DUnion]
public readonly struct class Option
{
    public readonly record struct Some(string Value);
    public readonly record struct None();

    public byte Discriminator => _discriminator;
}

Option value = default;
value.Discriminator == 0; // true

The name of the _discriminator field can be changed by setting the DiscriminatorName property on the [DUnion] attribute. This is mainly to allow mitigation of potential name collisions.

[DUnion(DiscriminatorName = "_someOtherDiscriminatorName")]
public readonly record struct MyUnion
{
    ...
}

object? _value

This holds the current case the union is wrapping. You can access this value if you want to be able to access the type in a non-type-safe way. It will be your responsibility to perform any type checks on this before casting.

The name of the _value field can be changed by setting the ValueName property on the [DUnion] attribute. This is mainly to allow mitigation of potential name collisions.

[DUnion(ValueName = "_someOtherValueName")]
public readonly record struct MyUnion
{
    ...
}

Type safety

Due to the way c# works, under the hood all cases are stored in the object? _value field in the union. Converting to or from a strongly typed case to object? takes time, especially if the case is a value type like a struct or enum. To squeeze as much speed out of the union, there is an opt-in way to leverage the System.Runtime.CompilerServices.Unsafe class. This class allows us to skip a lot of the "slow" type checks when converting from object? to the strongly typed cases. Under normal usage of the unions, this is a safe process as all types are strongly checked before writing to the _value field. This means it is safe to enable the usage of the Unsafe class in almost all situations.

UseUnsafe is turned off by default simply to reduce the chance of things being broken without realising, as it effectively turns off some normally unneeded checks. If you are having performance issues, and have identified that this will help alleviate them, and you have checked it is safe to do so, then feel free to turn this feature on at a per-union level.

If, however, any method is used to modify or set the _value or _discriminator fields yourself, then you must also maintain these checks yourself to ensure that the values at runtime are correctly set.

[DUnion(UseUnsafe = true)]
public static class Option
{
    public record Some<T>(T Value);
    public record None();
}

public partial class Option<T> 
{
    public Option(T value)
    {
        // Dangerous: UseUnsafe is enabled, but the values of _value and _discriminator might not align any more! If the order of Some and None got swapped, this would be incorrect.
        this._value = new Option.Some<T>(value);
        this._discriminator = 1; 
    }

    public Option(T value) : this(new Option.Some<T>(value)) 
    {
        // Safe: Setting of _value and _discriminator is delegated to the source generated code, so their relationship will be maintained.
    }
}

Option<int> union = new Option.Some<int>(123);
// Dangerous: UseUnsafe is enabled, but the values of _value and _discriminator might not align any more! If the order of Some and None got swapped, this would be incorrect.
typeof(Option<int>)
    .GetField("_value", BindingFlags.NonPublic | BindingFlags.Instance)!
    .SetValue(new Option.None());
typeof(Option<int>)
    .GetField("_discriminator", BindingFlags.NonPublic | BindingFlags.Instance)!
    .SetValue(2);

There are no supported framework assets in this package.

Learn more about Target Frameworks and .NET Standard.

  • .NETStandard 2.0

    • No dependencies.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last updated
1.0.0 151 4/14/2024