meetings/working-groups/discriminated-unions/pre-unification-proposals/union-interfaces.md
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.
object value = ...;
if (value is IUnion union)
{
// don't write union wrapper
Write(union.Value);
}
else
{
Write(value);
}
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.
public interface IUnion
{
// The value of the union or null
object? Value { get; }
}
IUnion interface is all that is necessary for a type to be considered a union by the language.Value property of the interface is targeted by the compiler to implement pattern matching.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; }
}
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.
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 ...;
}
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.
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);
}
IUnionTryCreate instead?// 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;
}
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 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.
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.
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!
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
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);
}
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.
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) {...}
}
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.
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);
}