RhoMicro.CodeAnalysis.UnionsGenerator 2.0.0-alpha-1

This is a prerelease version of RhoMicro.CodeAnalysis.UnionsGenerator.
There is a newer version of this package available.
See the version list below for details.
dotnet add package RhoMicro.CodeAnalysis.UnionsGenerator --version 2.0.0-alpha-1                
NuGet\Install-Package RhoMicro.CodeAnalysis.UnionsGenerator -Version 2.0.0-alpha-1                
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="RhoMicro.CodeAnalysis.UnionsGenerator" Version="2.0.0-alpha-1">
  <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 RhoMicro.CodeAnalysis.UnionsGenerator --version 2.0.0-alpha-1                
#r "nuget: RhoMicro.CodeAnalysis.UnionsGenerator, 2.0.0-alpha-1"                
#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 RhoMicro.CodeAnalysis.UnionsGenerator as a Cake Addin
#addin nuget:?package=RhoMicro.CodeAnalysis.UnionsGenerator&version=2.0.0-alpha-1&prerelease

// Install RhoMicro.CodeAnalysis.UnionsGenerator as a Cake Tool
#tool nuget:?package=RhoMicro.CodeAnalysis.UnionsGenerator&version=2.0.0-alpha-1&prerelease                

Unions

Read about union types here: https://en.wikipedia.org/wiki/Union_type

Table of Contents

  1. Features
  2. Alternative Union Type Implementations
  3. Installation
  4. How To Use
  5. Contrived Example
  6. Why Not OneOf?

Features

  • generate rich examination and conversion api
  • automatic relation type detection (congruency, superset, subset, intersection)
  • generate conversion operators
  • generate meaningful api names like myUnion.IsResult or MyUnion.CreateFromResult(result)
  • generate the most efficient impementation for your usecase and optimize against boxing or size constraints

Alternative Union Type Implementations

Installation

Requirements: net7 (due to static abstract members)

Package Reference:

	<ItemGroup>
	  <PackageReference Include="RhoMicro.CodeAnalysis.UnionsGenerator" Version="2.0.0">
	    <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
	    <PrivateAssets>all</PrivateAssets>
	  </PackageReference>
	</ItemGroup>

CLI:

dotnet add package RhoMicro.CodeAnalysis.UnionsGenerator

How To Use

Annotate your union type with the UnionType attribute:

[UnionType(typeof(String))]
[UnionType(typeof(Double))]
readonly partial struct Union;

Use your union type:

Union u = "Hello, World!"; //implicitly converted
u = 32; //implicitly converted
u = false; //CS0029	Cannot implicitly convert type 'bool' to 'Union'

Available attributes and instructions:

UnionTypeAttribute
  • representableType: Instruct the generator on the kind of representable type:
[UnionType(representableType: typeof(String))]
[UnionType(representableType: typeof(Double))]
readonly partial struct Union;
  • genericRepresentableTypeName: use nameof(T) to use generic parameters as representable types (this will likely change in the future):
[UnionType(genericRepresentableTypeName:nameof(T))]
readonly partial struct Result<T>;
  • Alias: define aliae for generated members, e.g.:
Names n = "John";
if(n.IsSingleName)
{
    //...
} else if(n.IsMultipleNames)
{
    //...
}
[UnionType(typeof(List<String>), Alias = "MultipleNames")]
[UnionType(typeof(String), Alias = "SingleName")]
readonly partial struct Names;
  • Options: define miscellaneous behaviour for the represented type:
/*
Instructs the generator to emit a superset conversion operator implementation even
the representable type is a generic type parameter. By default, it is omitted because of possible
unification for certain generic arguments.
*/
[UnionType(genericRepresentableTypeName:nameof(T), Alias = "Result", Options = UnionTypeOptions.SupersetOfParameter)]
readonly partial struct Result<T>;
/*
Instructs the generator to emit a superset conversion operator implementation even
the representable type is a generic type parameter. By default, it is omitted because of possible
unification for certain generic arguments.
*/
[UnionType(typeof(Int32), Options = UnionTypeOptions.ImplicitConversionIfSolitary)]
readonly partial struct Union;
  • Storage: optimize the generated storage implementation for the representable type against boxing or size constraints: <details> <summary> Available Storage Options: Auto, Reference, Value, Field </summary>
public enum StorageOption
{
    // The generator will automatically decide on a storage strategy.

    // If the representable type is known to be a value type,
    // this will store values of that type inside a shared value type container.
    // Boxing will not occur.

    // If the representable type is known to be a reference type,
    // this will store values of that type inside a shared reference type container.

    // If the representable type is neither known to be a reference type
    // nor a value type, this option will cause values of that type to 
    // be stored inside a shared reference type container.
    // If the representable type is a generic type parameter,
    // boxing will occur for value type arguments to that parameter.
    Auto,

    // The generator will always store values of the representable type
    // inside a shared reference type container.

    // If the representable type is known to be a value type,
    // boxing will occur.

    // If the representable type is a generic type parameter,
    // boxing will occur for value type arguments to that parameter.
    Reference,

    // The generator will attempt to store values of the representable type
    // inside a value type container.

    // If the representable type is known to be a value type,
    // this will store values of that type inside a shared value type container.
    // Boxing will not occur.

    // If the representable type is known to be a reference type,
    // this will store values of that type inside a shared reference type container.
    // Boxing will not occur.

    // If the representable type is neither known to be a reference type
    // nor a value type, this option will cause values of that type to 
    // be stored inside a shared value type container.
    // If the representable type is a generic type parameter,
    // an exception of type TypeLoadException will occur for
    // reference type arguments to that parameter.
    Value,

    // The generator will attempt to store values of the representable type
    // inside a dedicated container for that type.

    // If the representable type is known to be a value type,
    // this will store values of that type inside a dedicated 
    // value type container.
    // Boxing will not occur.

    // If the representable type is known to be a reference type,
    // this will store values of that type inside a 
    // dedicated reference type container.

    // If the representable type is neither known to be a reference type
    // nor a value type, this option will cause values of that type to 
    // be stored inside a dedicated strongly typed container.
    // Boxing will not occur.
    Field
}

</details>

UnionTypeSettingsAttribute

This attribute may target either a union type or an assembly. When targeting a union type, it defines settings specific to that type. If, however, the attribute is annotating an assembly, it supplies the default settings for every union type in that assembly.

  • ConstructorAccessibility: define the accessibility of generated constructors:
public enum ConstructorAccessibilitySetting
{
    // Generated constructors should always be private, unless
    // no conversion operators are generated for the type they
    // accept. This would be the case for interface types or
    // supertypes of the target union.
    PublicIfInconvertible,
    // Generated constructors should always be private.
    Private,
    // Generated constructors should always be public
    Public
}
  • DiagnosticsLevel: define the reporting of diagnostics:
[Flags]
public enum DiagnosticsLevelSettings
{
    // Instructs the analyzer to report info diagnostics.
    Info = 0x01,
    // Instructs the analyzer to report warning diagnostics.
    Warning = 0x02,
    // Instructs the analyzer to report error diagnostics.
    Error = 0x04,
    // Instructs the analyzer to report all diagnostics.
    All = Info + Warning + Error
}
  • ToStringSetting: define how implementations of ToString should be generated:
public enum ToStringSetting
{
    // The generator will emit an implementation that returns detailed information, including:
    // - the name of the union type
    // - a list of types representable by the union type
    // - an indication of which type is being represented by the instance
    // - the value currently being represented by the instance
    Detailed,
    // The generator will not generate an implementation of ToString.
    None,
    // The generator will generate an implementation that returns the result of
    // calling ToString on the currently represented value.
    Simple
}
  • Layout: generate a layout attribute for size optimization
public enum LayoutSetting
{
    // Generate an annotation optimized for size.
    Small,
    // Do not generate any annotations.
    Auto
}
  • Generic Names: define how generic type parameter names should be generated:
// Gets or sets the name of the generic parameter for generic Is, As and factory methods. 
// Set this property in order to avoid name collisions with generic union type parameters
public String GenericTValueName { get; set; }
// Gets or sets the name of the generic parameter for the DownCast method. 
// Set this property in order to avoid name collisions with generic union type parameters
public String DowncastTypeName { get; set; }
// Gets or sets the name of the generic parameter for the Match method. 
// Set this property in order to avoid name collisions with generic union type parameters
public String MatchTypeName { get; set; }
RelationAttribute

This attribute defines a relation between the targeted union type the supplied type. The following relations are available:

  • None
  • Congruent
  • Superset
  • Subset
  • Intersection

The generator will automatically detect the relation between two union types. The only requirement is for one of the two types to be annotated with the RelationAttribute:

[UnionType(typeof(DateTime))]
[UnionType(typeof(String))]
[UnionType(typeof(Double))]
[Relation(typeof(CongruentUnion))]
[Relation(typeof(SubsetUnion))]
[Relation(typeof(SupersetUnion))]
[Relation(typeof(IntersectionUnion))]
readonly partial struct Union;

[UnionType(typeof(Double))]
[UnionType(typeof(DateTime))]
[UnionType(typeof(String))]
sealed partial class CongruentUnion;

[UnionType(typeof(DateTime))]
[UnionType(typeof(String))]
partial class SubsetUnion;

[UnionType(typeof(DateTime))]
[UnionType(typeof(String))]
[UnionType(typeof(Double))]
[UnionType(typeof(Int32))]
partial struct SupersetUnion;

[UnionType(typeof(Int16))]
[UnionType(typeof(String))]
[UnionType(typeof(Double))]
[UnionType(typeof(List<Byte>))]
partial class IntersectionUnion;

Contrived Example

In our imaginary usecase, a user shall be retrieved from the infrastructure via a name query. The following types will be found throughout the example:

sealed record User(String Name);

enum ErrorCode
{
    NotFound,
    Unauthorized
}

readonly record struct MultipleUsersError(Int32 Count);

The User type represents a user. The ErrorCode represents an error that does not contain additional information, like MultipleUsersError does. It represents multiple users having been found while only one was requested.

We define a union type to represent our imaginary query:

[UnionType(typeof(ErrorCode))]
[UnionType(typeof(MultipleUsersError))]
[UnionType(typeof(User))]
readonly partial struct GetUserResult;

Instances of GetUserResult can represent either an instance of ErrorCode, MultipleUsersError or User.

It will be used in a service façade like so:

interface IUserService
{
    GetUserResult GetUserByName(String name);
}

A repository abstracts over the underlying infrastructure:

interface IUserRepository
{
    IQueryable<User> UsersByName(String name);
}

Access violations would be communicated through the repository using the following exception type:

sealed class UnauthorizedDatabaseAccessException : Exception;

An implementation of the IUserService is provided as follows:

sealed class UserService : IUserService
{
    public UserService(IUserRepository repository) => _repository = repository;

    private readonly IUserRepository _repository;

    public GetUserResult GetUserByName(String name)
    {
        IQueryable<User> users;
        try
        {
            users = _repository.UsersByName(name);
        } catch(UnauthorizedDatabaseAccessException)
        {
            return ErrorCode.Unauthorized;
        }

        var reifiedUsers = users.ToArray();
        if(reifiedUsers.Length == 0)
        {
            return ErrorCode.NotFound;
        } else if(reifiedUsers.Length > 1)
        {
            return new MultipleUsersError(reifiedUsers.Length);
        }

        return reifiedUsers[0];
    }
}

As you can see, possible representations of GetUserResult are implicitly converted and returned by the service. Users of OneOf will be familiar with this.

On the consumer side of this api, a generated Match function helps with transforming the union instance to another type:

sealed class UserModel
{
    public UserModel(IUserService service) => _service = service;

    private readonly IUserService _service;

    public String ErrorMessage { get; private set; } = String.Empty;
    public User? User { get; private set; }
    public void SetUser(String name)
    {
        var getUserResult = _service.GetUserByName(name);
        User = getUserResult.Match(
            HandleErrorCode,
            HandleMultipleResult,
            user => user);
    }
    private User? HandleErrorCode(ErrorCode code)
    {
        ErrorMessage = code switch
        {
            ErrorCode.NotFound => "The user could not be located.",
            ErrorCode.Unauthorized => "You are not authorized to access users.",
            _ => throw new NotImplementedException()
        };
        return null;
    }
    private User? HandleMultipleResult(MultipleUsersError result)
    {
        ErrorMessage = $"{result.Count} users have been located. The name was not precise enough.";
        return null;
    }
}

Here is a list of some generated members on the GetUserResult union type (implementations have been elided):

/*Factories*/
public static GetUserResult Create(ErrorCode value);
public static GetUserResult Create(MultipleUsersResult value);
public static GetUserResult Create(User value);
public static Boolean TryCreate<TValue>(TValue value, out GetUserResult instance);
public static GetUserResult Create<TValue>(TValue value);

/*Handling Methods*/
public void Switch(
    Action<ErrorCode> onErrorCode, 
    Action<MultipleUsersResult> onMultipleUsersResult,
    Action<User> onUser);

public TResult Match<TResult>(
    Func<ErrorCode, TResult> onErrorCode,
    Func<MultipleUsersResult, TResult> onMultipleUsersResult,
    Func<User, TResult> onUser);

/*Casting & Conversion*/
public TResult DownCast<TResult>();

public Boolean Is<TValue>();

public Boolean Is(Type type);

public TValue As<TValue>();

public Boolean IsErrorCode;
public ErrorCode AsErrorCode;

public Boolean IsMultipleUsersResult;
public MultipleUsersResult AsMultipleUsersResult;

public Boolean IsUser;
public User AsUser;

public Type GetRepresentedType();

public static implicit operator GetUserResult(ErrorCode value);
public static explicit operator ErrorCode(GetUserResult union);

public static implicit operator GetUserResult(MultipleUsersResult value);
public static explicit operator MultipleUsersResult(GetUserResult union);

public static implicit operator GetUserResult(User value);
public static explicit operator User(GetUserResult union);

Why Not OneOf?

Here are some issues that I have with OneOf that this generator aims to solve:

  • the internal tag field is a byte instead of an int
  • by default, not every representable type has a dedicated field generated for it
  • generic value type unions are possible:
[UnionType(typeof(String))]
[UnionType(nameof(T), Alias = "Result")]
readonly partial struct Result<T>;

with helpful members like IsResult being generated for you.

  • type order does not matter; convert to equivalent unions with ease:
Union u = DateTime.Now;
//Output: Union(<DateTime> | Double | String){23/11/2023 17:58:58}
Console.WriteLine(u);
EquivalentUnion eu = u.DownCast<EquivalentUnion>();
//Output: EquivalentUnion(<DateTime> | Double | String){23/11/2023 17:58:58}
Console.WriteLine(eu);

[UnionType(typeof(DateTime))]
[UnionType(typeof(String))]
[UnionType(typeof(Double))]
readonly partial struct Union;

[UnionType(typeof(Double))]
[UnionType(typeof(DateTime))]
[UnionType(typeof(String))]
sealed partial class EquivalentUnion;
  • avoid OneOf invading your apis, instead rely on dedicated domain names for your union types
  • use custom generated members with meaningful names for access to union data:
var r = Result<String>.CreateFromResult("Hello, World!");
if(r.IsErrorMessage)
{
    //handle error
} else if(r.IsResult)
{
    //handle result
}

//alternatively:
r.Switch(
    onErrorMessage: m =>/*handle error*/,
    onResult: r =>/*handle result*/);

[UnionType(typeof(String), Alias = "ErrorMessage")]
[UnionType(nameof(T), Alias = "Result")]
readonly partial struct Result<T>;

Note that for generic union types, there will be no conversion operators generated for representable parameter types. That is why we are using the generated CreateFromResult factory method here.

Product Compatible and additional computed target framework versions.
.NET net7.0 is compatible.  net7.0-android was computed.  net7.0-ios was computed.  net7.0-maccatalyst was computed.  net7.0-macos was computed.  net7.0-tvos was computed.  net7.0-windows was computed.  net8.0 was computed.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

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
16.1.2 0 12/22/2024
16.1.1 42 12/21/2024
16.1.0 44 12/20/2024
16.0.4 63 12/18/2024
16.0.3 46 12/18/2024
16.0.0 38 12/18/2024
15.3.4 162 12/7/2024
15.3.3 81 12/7/2024
15.3.2 95 12/7/2024
15.3.1 92 12/7/2024
15.3.0 92 12/6/2024
15.2.4 113 12/6/2024
15.2.3 98 12/6/2024
15.1.7 1,195 7/15/2024
15.1.6 206 6/11/2024
15.1.5 424 3/26/2024
15.1.4 213 3/26/2024
15.1.3 527 3/11/2024
15.1.2 391 3/8/2024
15.1.1 442 3/4/2024
15.1.0 503 2/28/2024
15.0.1 455 2/22/2024
15.0.0 628 2/20/2024
14.0.5 618 2/19/2024
14.0.4 445 2/19/2024
14.0.3 488 2/19/2024
14.0.2 476 2/15/2024
14.0.0 469 2/15/2024
14.0.0-alpha.30 74 2/15/2024
14.0.0-alpha.29 55 2/15/2024
14.0.0-alpha.28 54 2/15/2024
14.0.0-alpha.27 51 2/15/2024
14.0.0-alpha.26 87 2/15/2024
14.0.0-alpha.25 55 2/15/2024
14.0.0-alpha.24 59 2/15/2024
14.0.0-alpha.22 54 2/15/2024
14.0.0-alpha.20 68 2/15/2024
13.0.0 836 1/8/2024
13.0.0-alpha.1064 61 2/14/2024
12.0.10 846 1/6/2024
12.0.9 660 1/5/2024
12.0.7 838 12/27/2023
12.0.6 780 12/27/2023
12.0.5 787 12/27/2023
12.0.4 778 12/27/2023
12.0.3 821 12/22/2023
12.0.2 681 12/22/2023
12.0.1 690 12/22/2023
12.0.0 914 12/22/2023
11.0.0 752 12/19/2023
9.0.1 606 12/12/2023
9.0.0 770 12/12/2023
8.0.3 908 12/11/2023
8.0.2 812 12/11/2023
8.0.0 923 12/11/2023
2.0.2 966 11/29/2023
2.0.1 836 11/29/2023
2.0.0 828 11/29/2023
2.0.0-alpha-1 375 11/29/2023