Back to Csharplang

Union Interfaces

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

latest9.1 KB
Original Source

Union Interfaces

IUnion

Summary

Union types implement the interface IUnion that makes it easy to identify and interact with unions at runtime when a value that might be a union is weakly typed or represented as a type parameter.

csharp
object value = ...;

if (value is IUnion union) 
{
    // don't write union wrapper
    Write(union.Value);
}
else 
{
    Write(value);
}

Motivation

To enable library or utility code that is unaware of application specific union types to become union aware and interact with union values without using reflection.

To provide a means for the compiler to identify union types and access the value of the union with certainty.

Detailed Design

csharp
public interface IUnion
{
    // The value of the union or null
    object? Value { get; }
}
  • The IUnion interface is all that is necessary for a type to be considered a union by the language.
  • The Value property of the interface is targeted by the compiler to implement pattern matching.
  • Nominal Type Unions generated by the compiler implement this interface.

Example

csharp
public struct MyUnion : IUnion
{
    public MyUnion(Case1 value) { this.Value = value; }
    public MyUnion(Case2 value) { this.Value = value; }

    // implements IUnion.Value
    public object Value { get; }
}

IUnion<TUnion>

Summary

Unions may also implement the IUnion<TUnion> to provides a means to construct union instances at runtime when the union type is a constrained type parameter.

csharp
TUnion ReadUnion<TUnion>() where TUnion : IUnion<TUnion>
{
    object val = ReadValue();

    // wrap the value in the union
    if (TUnion.TryCreate(val, out var union)) 
        return union;

    throw ...;
}

Motivation

To enable library or utility code that is unaware of application specific union types to become union aware and construct union instances from case values at runtime.

Detailed Design

csharp
public interface IUnion<TUnion> : IUnion
    where TUnion : IUnion<TUnion>
{
    // Creates a union from a value
    static abstract bool TryCreate(object? value, [NotNullWhen(true)] out TUnion union);
}
  • Nominal Type Unions generated by the compiler implement this interface.
  • Maybe named IUnionTryCreate instead?

Example

csharp
// lowered MyUnion
public struct MyUnion : IUnion<MyUnion>
{
    public Union(Case1 value) { this.Value = value; }
    public Union(Case2 value) { this.Value = value; }

    // IUnion.Value
    public object? Value { get; }

    // IUnion<MyUnion>.TryCreate
    public static bool TryCreate(object? value, [NotNullWhen(true)] out MyUnion union)
    {
        // handle all known case types and null
        switch (value)
        {   
            case Case1 value1:
                union = new MyUnion(case1);
                return true;
            case Case2 value2:
                union = new MyUnion(value2);
                return true;
            case null when _canBeNull:                
                union = default;
                return true;
            default:
                union = default!;
                return false;
        }
    }

    // precompute if this union can be created with a null value.
    private static readonly bool _canBeNull = 
        default(Case1) == null || default(Case2) == null;
}
  • Note: it is also valuable for a union type to provide a public means to construct another instance when the union type is fully understood, but the value is not.
csharp
object? value = ReadValue();

// if not type-safe certain that value is a case type, use TryCreate.
if (MyUnion.TryCreate(value, out MyUnion union))
{
    ...
}

Additional Interfaces (Not Approved)

Summary

Additional interfaces expose the remaining methods and properties that the compiler is designed to look for without requiring the union to adopt these specific signatures as its public API.

For example, the following union-like type is not compatible as is. It uses the term Value to refer to a case and it does not expose public constructors.

csharp
public record Error(int code);

public struct Result<T>
{
    public bool IsValue => ...;
    public bool IsError => ...;
    public T Value => ...;
    public Error Error => ...;
    public static Result<T> FromValue(T value) {...}
    public static Result<T> FromError(Error error) {...}
}

...
Result<int> result = ...;
if (result is Value(var v)) {...}  // error: not a union

Without completely redesigning the type and impacting all the current users, the type can be brought forward by implementing union interfaces explicitly.

csharp
public record Error(int code);

public struct Result<T> 
    : IUnion,
      IUnionCreate<Result<T>, T>,
      IUnionCreate<Result<T>, Error>,
      IUnionGetValue<T>,
      IUnionGetValue<Error>
{
    public bool IsValue => ...;
    public bool IsError => ...;
    public T Value => ...;
    public Error Error => ...;
    public static Result<T> FromValue(T value) {...}
    public static Result<T> FromError(Error error) {...}

    // incompatible with existing API
    object? IUnion.Value => 
        IsValue ? this.Value 
        : IsError ? this.Error
        : null;

    // custom union pattern to avoid boxing 
    // would be confusing terminology given existing API
    bool IUnionCreate<Result<T>, Value<T>>.Create(T value) => FromValue(value);
    bool IUnionCreate<Result<T>, Error>.Create(Error value) => FromError(value);
    bool IUnionHasValue.HasValue => IsValue || IsError;
    bool IUnionTryGetValue<Value<T>>.TryGetValue([NotNullWhen(true)] out T value) => ...;
    bool IUnionTryGetValue<Error>.TryGetValue([NotNullWhen(true)] out Error error) => ...;
}

...
Result<int> result = ...;
if (result is Value(var v)) {...}  // works!

Motivation

Many existing first and third party types already exist that are unions, but do not today conform to the patterns the compiler is looking for to identify and interact with them correctly. It is already possible to create custom union types that implement the IUnion and IUnion<T> interfaces without exposing the interface methods on the union itself. The compiler can easily identify these types as union and call through to the interface methods directly (using constrained calls) to guarantee

Detailed Design

csharp
public interface IUnionCreate<TUnion, TCase> 
    where TUnion : IUnionCreate<TUnion, TCase>
{
    static abstract TUnion Create(TCase value);
}

public interface IUnionHasValue
{
    bool HasValue { get; }
}

public interface IUnionTryGetValue<TCase> : IUnionHasValue
{
    bool TryGetValue([NotNullWhen(true)] out TCase value);
}
  • The compiler will target the IUnionCreate's Create methods if available, when an appropriate accessible constructor is not. The compiler will also use the IUnionCreate implementations to identify the set of case types.
  • The compiler will translate null pattern tests to the IUnionHasValue.HasValue property.
  • The compiler will translate type pattern matches to the IUnionTryGetValue.TryGetValue method if available and applicable, instead of accessing via IUnion.Value property.
  • Note: it is not required to implement these interfaces if the union type's public API contains the equivalent signatures.

Known Issues

  • Multiple implementations of generic interfaces with type parameter arguments produce an error today. However, this kind of usage will not become more ambiguous than the type would already be if instantiated with multiple uses of the same type argument.

    csharp
    public struct FatUnion<T1, T2>
    : IUnion,
      IUnionTryGetValue<T1>,  // error, may become ambiguous at runtime
      IUnionTryGetValue<T2>
    {
        private readonly int _kind;
        private readonly T1 _value1;
        private readonly T2 _value2;
    
        public FatUnion(T1 value) { _kind = 1; _value1 = value; }
        public FatUnion(T1 value) { _kind = 2; _value2 = value; }
    
        bool IUnionHasValue.HasValue => _kind != 0;
        bool IUnionTryGetValue<T1>.TryGetValue(out T1 value) {...}
        bool IUnionTryGetValue<T2>.TryGetValue(out T2 value) {...}
    }
    

Alternatives

Instead of requiring union types to implement multiple variations of the same generic interface, a family of similar interfaces could exist for each arity of cases.

csharp
public interface IUnionCreate<TUnion, T1, T2>
    where TUnion : IUnionCreate<TUnion, T1, T2>
{
    static abstract TUnion Create(T1 value);
    static abstract TUnion Create(T2 value);
}

public interface IUnionCreate<TUnion, T1, T2, T3>
    where TUnion : IUnionCreate<TUnion, T1, T2, T3>
{
    static abstract TUnion Create(T1 value);
    static abstract TUnion Create(T2 value);
    static abstract TUnion Create(T3 value);
}

public interface IUnionTryGetValue<T1, T2> : IUnionHasValue
{
    bool TryGetValue([NotNullWhen(true)] out T1 value);
    bool TryGetValue([NotNullWhen(true)] out T2 value);
}

public interface IUnionTryGetValue<T1, T2, T3> : IUnionHasValue
{
    bool TryGetValue([NotNullWhen(true)] out T1 value);
    bool TryGetValue([NotNullWhen(true)] out T2 value);
    bool TryGetValue([NotNullWhen(true)] out T3 value);
}
  • How many of these would be defined? What happens to custom unions that have a large number of cases?