Back to Csharplang

Custom Unions

meetings/working-groups/discriminated-unions/pre-unification-proposals/custom-unions.md

latest3.6 KB
Original Source

Custom Unions

Summary

Nominal Type Unions allow the compiler to generate union types that have special behavior when consumed.

This proposal specifies a pattern that a class or struct declaration can follow in order to get the same special behavior when consumed.

Motivation

The declaration syntax for nominal unions is intended to cover most green-field situations where people want to specify a union. However, for some scenarios the generated outcome is not optimal or even usable:

  • There is already an existing widely consumed library type representing a "union", and the author wants to imbue it with "union powers" without breaking existing usage.
  • For e.g. performance, architectural or interop reasons, the contents of the union need to be stored differently than the default boxed object field used in compiler-generated unions.

Specification

A type is considered a "custom union type" if it implements the IUnion interface. Every constructor on the type that is at least as accessible as the type and takes exactly one parameter contributes the type of that parameter as a case type of the custom union type.

The consumption of such a type as a custom union type is enabled in the following ways:

  • Implicit conversion: There is an implicit union conversion from each case type to the custom union type. It is implemented by calling the corresponding constructor.
  • Pattern matching: Patterns applied to a value of the custom union type (other than always-succeeding patterns such as _ and var) are instead applied to the IUnion.Value property.
  • Exhaustiveness: Switch expressions covering all the case types of the custom union type are considered exhaustive.

Example

Say the following type already exists:

csharp
public sealed class Result<T>
{
    internal Result(object? outcome) => (Value, Error) = outcome switch
    {
        Exception error => (default!, error),
        T value => (value, null),
        null when default(T) is null => (default!, null);
        _ => throw new InvalidOperationException(...);
    };
    
    public Result(T value) => (Value, Error) = (value, null);
    public Result(Exception error) => (Value, Error) = (default!, error);
    
    public T Value { get; }
    public Exception? Error { get; }
    public bool Succeeded => Error is null;
}

It can be made a union type simply by implementing the IUnion interface:

csharp
public sealed class Result<T> : IUnion
{
    object? IUnion.Value => Error ?? Value;
    ... // Existing members
}

Note that in this example the existing type already has a public Value property with a different meaning than the one on the IUnion interface, so IUnion.Value gets implemented explicitly, and that's the one the compiler will consume for pattern matching purposes.

Note also that the type is only considered to have two case types, T and Exception, even though it has a third single-parameter constructor. That's because the object? constructor is less accessible than the type itself and doesn't count.

Drawbacks

Not every existing type may be enhanced in a non-breaking way to become a custom union type using these rules. For instance, it may not be able to expose the right set of constructors to establish the desired set of case types - e.g. it relies on factory methods for creating values. It is possible that we need to refine or enhance the mechanism by which a type is interpreted as a custom union type.