[LDM] - Union Types #7010
Replies: 31 comments 80 replies
-
I really like this idea and like many others in this community wanted that for a long time 😄 But I'm asking a question about that sentence:
and
What could happened if in my program I reference Lib1 and Lib2. Will this be possible (I hope it will): CatOrDog c = new Cat();
DogOrCat d = c; |
Beta Was this translation helpful? Give feedback.
-
Do type unions even need proper names, or can we make do with aliases, like in C? I'm honestly more excited about sealing/closing existing enums and type hierarchies. Triggering that |
Beta Was this translation helpful? Give feedback.
-
I'm not sure whether I missed that but would it be possible to do something like this: // Inheritance
public sealed class A : B | C
{
}
// Composition
public sealed class D
{
public D(B | C bOrC)
{
}
}
var a = new A();
B b = a;
C c = a;
var d1 = new D(a);
var d2 = new D(b | c); In the following case does the keyword union really necessary? the compiler knows that animal is a union (or not?) and you also noted that tag unions can't be flags. switch (animal)
{
case union (Cat | Dog) catOrDog: ...;
} |
Beta Was this translation helpful? Give feedback.
-
This could works if the proposal (can't find it) to omit the containing type is accepted : void AddPet(union { Cat, Dog } catOrDog) { ... }
AddPet(.Cat); |
Beta Was this translation helpful? Give feedback.
-
This is awesome stuff, and a lot of it. Is the intent to explore each of the different forms of unions to land on which variation(s) offer the best bang for the buck or is there interest in bringing all of these forms of unions to the language (and runtime in some cases)? It's mentioned that common DUs like |
Beta Was this translation helpful? Give feedback.
-
What if, instead of using an interface ITypedUnion<T>
{
bool IsType { get; }
bool TryGet(out T result);
T Get();
} Then |
Beta Was this translation helpful? Give feedback.
-
I have to say this is one of the most exciting topics I've seen on the development of C# for ages! I really hope it goes somewhere good :) |
Beta Was this translation helpful? Give feedback.
-
I love this. I'd go one step further than @Richiban and say that this is in the top two most exciting C# developments in years (the other being related: pattern matching of course) A couple of thoughts, one irreverent and one serious: Firstly, regarding the line Much more seriously, I've concerns over the idea of type and tag unions being seen as different to each other. Consider the following code: record Dog;
union CatOrDog { Cat, Dog }
union CatDogOrNumber ( Cat, Dog, int )
And what happens with assignment here: CatOrDog dog1 = new Dog(); // Allowed?
CatOrDog dog2 = CatOrDog.Dog; // Or will only this work?
CatDogOrNumber cat1 = new Cat(); // Will this work?
CatDogOrNumber cat2 = new CatDogOrNumber.Cat(); // Or will it work like this?
cat2 = dog2; // Can I implicitly cast between tag and type unions? |
Beta Was this translation helpful? Give feedback.
-
Thanks for the clarifications, Matt.
Not that I'm prone to changing my mind on a whim, but in the space of an hour I've completely changed my mind on this. I'd love to see the My initial reaction to the two types of union was negative: it overcomplicates things and let's aim for one hybrid union style. But as I've thought through examples, I think they serve quite different purposes so keeping them separate makes sense. Most examples I an think of can be expressed as tag unions. The one clear example for me for type unions is for avoiding the need for interfaces when dealing with related structs, eg rather than structs Point, Circle, Rectangle all implementing |
Beta Was this translation helpful? Give feedback.
-
FWIW, I'd rather the "existing type" union (working syntax: public void M(union (string | int) arg)
{
...
} And then we could combine it with proper type aliases as a feature if you want to be able to reuse it: type UserIdentifier = union (UserId | EmailAddress);
// or, more C#-y
global using UserIdentifier = union (UserId | EmailAddress); That would save "union declarations" specifically for a declaration of new types or cases/tags: union Shape
{
Circle(int radius), Rectangle(int width, int height)
} Edit: |
Beta Was this translation helpful? Give feedback.
-
What's the current thoughts on unions and generic type constraints. Will something like this work? record class Dog;
record struct Cat;
union DogOrCat(Dog, Cat);
class Pet<T> where T : DogOrCat { ... }
var cat = new Pet<Cat>();
var dog = new Pet<Dog>(); |
Beta Was this translation helpful? Give feedback.
-
What about this case: record Cat;
record Dog;
record Fish;
union Mammal ( Cat | Dog );
union Animal ( Mammal | Dog | Fish ); // is adding both Mammal and Dog valid?
Animal animal = new Dog();
if ( animal is Mammal ) // will it return true? |
Beta Was this translation helpful? Give feedback.
-
Regarding the topic “Untyped pattern matching”, I think that they should be treated in a similar way to Nullable. That is, that you can never box a Nullable and if you do This way, if you have code like this, you don't risk having a union under the hood.
Another experiment that I've been doing in my own, but one that I know is a horrible hack, is to define a OneOf<T1, T2> interface, but assign them with
What I want to show from my example is that type unions should exist as return types and field types, but never as the actual type of an instance. There is no logic in creating a CatOrDog instance when we are dealing with individual cats and dogs, and not an instance that can turn into a cat or a dog. |
Beta Was this translation helpful? Give feedback.
-
Awesome proposal. I would like to highlight a related issue that would make this feature gangbusters: #3723 In particular, I would like to await a union rather than a Task (similar to the zio library in Scala and it's
Further, they note that the .NET Task type is really just a special case in this model:
This should really make it obvious where the direction of C# should go in terms of improving concurrent, parallel library design! |
Beta Was this translation helpful? Give feedback.
-
Turns out I ended up exploring a very similar space in #7016 My conclusion is that it's hard to make so-called "type unions" in this proposal work efficiently, meaning the fewest allocations and expensive conversions. My preference is that we would start with "tagged unions," which I think can meet our requirements for the highest perf scenarios. I would be pretty disappointed if we implemented a feature that couldn't be used in common locations because it was too costly. |
Beta Was this translation helpful? Give feedback.
-
Has there been any discussion surrounding nullability restriction/enforcement? For example if we have the following union definition: class Dog {};
class Cat {};
union DogOrCat (Dog | Cat); From my understanding, for union types to be truly closed, there has to either be an enforcement that a value is an instance of a DogOrCat value = null; If this is possible then the union union DogOrCat(Dog | Cat | null); In my mind, if you declare a union and you do not specify
Because
My initial thinking would be that the error would have to be resolved at compile time in order to have unions be truly closed, and the preceding issue could be resolved in one of the following ways:
|
Beta Was this translation helpful? Give feedback.
-
omg, take my money and give me discriminated union asap. really love to see more and more rust lang like features in c#. OneOf is great, but better to have a native support to be able to leverage Result<T,E> and Option within switch expression |
Beta Was this translation helpful? Give feedback.
-
I find nullable enable much better than |
Beta Was this translation helpful? Give feedback.
-
Will type unions support generic constraints? If I have I've just realized that this would be required to have static IEnumerable<TResult> SelectMany<TSource,TCollection,TResult> (
this IEnumerable<TSource> source,
Func<TSource,IEnumerable<TCollection>> collectionSelector,
Func<TSource,TCollection,TResult> resultSelector); becomes static Result<IEnumerable<TResult>,(TCollectionEx|TResultEx)> SelectMany<TSource,TCollection,TResult,TCollectionEx,TResultEx> (
this IEnumerable<TSource> source,
Func<TSource,Result<IEnumerable<TCollection>,TCollectionEx>> collectionSelector,
Func<TSource,TCollection,Result<TResult,TResultEx>> resultSelector)
where TCollectionEx : Exception, TResultEx : Exception; allowing you to track which exceptions can be returned via the |
Beta Was this translation helpful? Give feedback.
-
I sat down several months ago and tried to write an example C# code snippet based on some of the JavaScript audio processing API's I was looking at. I'm not a language designer and approached the syntax with the idea, "can I re-use the existing C# pattern matching syntax to introduce 'union/discriminated union types'". What I wanted was a way to use the syntax used in pattern matching to also be the syntax used for declaration. Here is what I wrote as a rough/naive example, based on the idea that Type should be thought of as List. void Main() {
var sample1 = new Sample1<pattern1>{
AudioStream = 22.5f,
};
sample1.PlayStream();
}
public interface IAudioStream {
void Play();
}
// Declare new union types sample 1
public Type pattern1 = bool or IAudioStream or float;
// Declare complex union types
public Type pattern2 =
bool B or
IAudioStream Stream or
X where X: float or double;
public class Sample1<T> {
public class Sample1<T> where T: bool or IAudioStream {
}
// AudioStream : object
private object _audioStream = false;
public object AudioStream
{
get => _audioStream;
set
{
if (value is bool or IAudioStream or T) // if (value is pattern1)
_audioStream = value;
else
throw new Exception($"Invalid type assignment: {value.GetType().Name}");
}
}
// AudioStream2
public T AudioStream2 where T: A or bool or IAudioStream { get; set; }
// AudioStream3
private T _audioStream3 where T: A or bool or IAudioStream;
public TAudioStream3 where T: A or bool or IAudioStream {
get => _audioStream3;
set {
_audioStream3 = value;
}
}
public void PlayStream()
{
if (this.AudioStream is IAudioStream s)
{
s.Play();
}
}
} |
Beta Was this translation helpful? Give feedback.
-
Would it be possible that when the compiler sees an union type the if (variable is SomeType) statement narrows the type of the variable in the if block. I know that it normally doesn't work because of legacy code with overloads but the code with unions will be new code, maybe that can sneak in |
Beta Was this translation helpful? Give feedback.
-
Rust enum can be considered as the best implementation of union that is implementable in C#. Which doesn't allocate any extra memory and doesn't allocate on the heap.
Here, So my preference would be, |
Beta Was this translation helpful? Give feedback.
-
Unions would be really nice 👍 Coming from F# I miss them daily when writing C#. |
Beta Was this translation helpful? Give feedback.
-
Glad to see that this is finally getting some attention! Would it be possible to get some runtime support (analogous to |
Beta Was this translation helpful? Give feedback.
-
Would this proposal support something like typescript's Exclude? Just thinking it would be useful to be able to remove one type from an existing union. |
Beta Was this translation helpful? Give feedback.
-
Is also considered produce/consume model using openapi OneOf? |
Beta Was this translation helpful? Give feedback.
-
This is such a long-awaited feature of C# for me. Thank you for proposing this. In fact, I've had multiple "union" implementations to do this since 2015 or so - but they never had the elegance of being part of the language. The latest incarnation of my clunky solution is a source-generator which pretty much generates exactly what is proposed [here] (https://github.com/dotnet/csharplang/blob/main/proposals/TypeUnions.md#implementation). If anyone is interested, the source generator is here |
Beta Was this translation helpful? Give feedback.
-
I've read through the latest type union proposal. I like the multi-category approach to provide viable solutions for different scenarios. I have faced the exact situation of "I wish I could use unions in my graphics pipeline". There are some words and suggestions, mostly for Ad Hoc unions:
Honestly I'm a fan of generics. However, using dedicated generic type can cause more and more problem when using as generic argument into another generic type. This can only be solved with full runtime support. It's currently not critical for union types, but we may have to deal with it for |
Beta Was this translation helpful? Give feedback.
-
|
Beta Was this translation helpful? Give feedback.
-
There is now a proposal for Type Unions as mentioned by @johnazariah, here: I've created a separate discussion for it here: |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
-
Union Types
Union types are types where instances represent a single case from a closed set of possible cases.
Each case may carry along additional parameters/data.
Goals for C#
State of the Union
There are many existing types that are understood by the language but are not technically closed,
or are closed but are not understood by the language.
Neither can be used with pattern matching today.
Examples of types that C# understands but are not closed
An enum is similar to a union type, as the enum defines the set of possible cases
and (ignoring flags enums) an instance represents just one of the known cases.
Unfortunately, even though the declaration of an enum is closed, variables of enum type can contain any
integer value available to the underlying type and thus are not closed.
Any C# type hierarchy is similar to a union type, as an abstract base can be considered the union type,
and the derived types the individual cases.
Yet, these hierarchies are not closed either as they can only be defined with an unsealed base type.
Examples of types in the wild that are closed, but C# does not understand them.
The types (or similar) are commonly defined and used in functional languages, and exist in many
3rd party libraries for functional style programming in C#.
Option<T>
-- a type that either has either some value or none.Result<T>
-- a type that either has a success with a value or failure with an error message.OneOf<T1, T2, ..>
-- a type that can contain an instance of one of a closed set of other types.Kinds of unions
Type Union
A type union is a union type where the cases are represented by one or more other types.
Each instance of the type union must be an instance of one of the sub types.
OneOf<T1, T2, ...>
is a type union.An instance of a
OneOf
can contain an instance of one of the type parameter types.Option<T>
is a type union if the implementation depends on and exposes the typesSome<T>
andNone
.Result<T>
is a type union if the implementation depends on and exposes the typesSuccess<T>
andFailure
.Tag Union
A tag union is a union type with one or more cases identified by tags and not by types.
Option<T>
is a tag union if the implementation exposes factories and tests forSome
andNone
, but not as types.Result<T>
is a tag union if the implementation exposes factories and tests forSuccess
andFailure
, but not as types.Named Unions
A named union is any union type with an identifiable name that can be used as a type expression like any normal named type in C#.
Likewise, an unnamed union is any union type without an identifiable name,
yet can be instead described by type expression using syntax in a way similar to how C# tuples have syntax but not names.
It is technically possible for both type unions and tag unions to exist in both named and unnamed forms but may not for practicality reasons.
All examples so far have been named, since there does not yet exist a way for them to not have names.
All named union types with different names refer to different types,
while all unnamed union types with the same declaration refer to the same type.
Consuming Existing Union Types
Existing union types are types that already exist or could have been written today,
that could be considered union types but are not yet understood by C# or are not technically closed.
Closing the open types
Enums
The problem with enums not being exhaustive can be solved by allowing them to be
sealed
, and by following these set of rules.flags
enum.Type Hierarchies
It is possible today to make a closed hierarchy by starting with an abstract base type,
defining all the derived types as nested types within the base type and making the base type's constructor private.
This eliminates the possibility of additional unknown derived types at runtime.
These types of hierarchies could be automatically recognized as exhaustive without additional syntax changes.
An alternative to requiring all case types to be nested, is to instead require an attribute on the type that describes the set of derived types.
This won't guarantee that there will not be additional derived types at runtime, but it may allow the language to have the type behave as if it were,
generating error message for derived types not included in the set and having backup default exception throwing cases auto-generated for all pattern matches.
Describing existing closed union types to C#
In order for C# to understand that an existing type is a union type and use it for pattern matching it must be able to:
Identify the closed set of cases for the type, whether tag cases or type cases.
The compiler would need to know the set of cases to know when a pattern match has been exhausted.
Identify which case an instance of the union type is in.
This would be used whenever C# is doing a type test.
For tag cases, the C# language would have to allow specifying the name of tags instead of just types.
Access the type case instance or tag case parameters (if any).
For tag cases, the position pattern syntax would be used.
Solutions for this could come in the form of:
and makes it possible to identity them.
Assuming [1] is not the solution, the following requirements have a variety of solutions.
Recognizing the set of cases
OneOf<T1, T2, ...>
describes its type cases as type parameters.Still, in order to know that the type describes its type cases this way, the type would have to either be known intrinsically by the compiler
or have a separate attribute or marker interface to inform the compiler that the type cases exist in the type parameters.
UnionTypes(typeof(T1), typeof(T2), ...)
IsXXX
for each case XXX.Testing for cases
Unlike identifying cases, testing for cases requires an actual API on the union type.
IsXXX
for each tag case.IsType<T>
, or implement an interface that does.Accessing type case instances or tag case parameters.
bool TryGet<T>(out var T value)
to both test and return the instance if it matches the type,or a method like
T Get<T>()
accessing the instance with potentially throwing exception when the type does not match.bool TryGetXXX(out P1 parameter1, out P2 parameter2)
.This would be similar-to/same-as Neal's Active Patterns pattern.
C# Union Types
In addition to consuming existing types as union types for the purpose of pattern matching,
it is desirable to have a dedicated C# syntax for describing union types
that automatically produce common boilerplate code and patterns
that have deeper integration and support in C#.
A working syntax for union types
For purposes of discussion of custom union types, this working syntax has been provided.
It is not meant as a proposed syntax for custom union types in C#.
Type Unions:
union [<name> [<type-parameters>]] '(' <type1> ['|' <type2> ...] ')'
Examples:
a.
union (string | int)
b.
union StringOrInt (string | int)
c.
union Option<T> (Some<T> | None)
d.
union Result<T> (Success<T> | Failure)
Tag Unions:
union [<name>] '{' <tag1> [(<parameter1> [',' <parameter2> ...])] [',' <tag2> ...] '}'
Examples:
a.
union { Yes, No, Maybe }
b.
union YesNoMaybe { Yes, No, Maybe }
c.
union Option<T> { Some(T value), None }
d.
union Result<T> { Success(T value), Failure(string message) }
Note: unnamed union types are possible to describe with this syntax.
Construction
Type unions and tag unions are constructed in different ways.
A type union can be constructed via assignment or coercion from a case type instance.
A tag union must be constructed from a factory on the union type.
These factories exist for each tag case.
Factories for tag cases without parameters are represented by a property.
Testing and Accessing Cases
Type Cases
Type unions expose standard generic methods for testing and accessing types.
Tag Cases
Tag unions expose standard pattern methods for testing and accessing parameters.
GetXXX()
methods for tag cases with a single parameter returns that one parameter value.GetXXX()
methods for tag cases with multiple parameters return a tuple with named elements.Tag cases without parameters have no
Get
orTryGet
methods.Type Union Subsets, Assignment and Coercion
Since type unions are declared with a known set of individually addressable types,
it is possible to form additional type unions with a subset of the same types shared by another type union.
Types
Animal
andCatOrDog
are not the same type, but the can represent some of the same type cases.Given this knowledge, it is possible to have the language know how to convert between them.
Implicit coercion
A union type can be implicitly coerced to another type union with the same or a superset of the same set of type cases.
Explicit coercion
A type union with some of the same type cases as another type union can be explicitly coerced.
Of course, this can lead to a runtime exception if the animal is a
Bird
.Coercion API
A type union provides coercion/conversion from other type unions by providing a generic
From
method that void boxing.Whenever implicit coercion is identified or explicit coercion is written out via casting, the compiler
implements the coercion by invoking the union type's
From
method.Unnamed type unions
It is useful and practical to allow some union types to be referred to via syntax instead of by a type name.
This allows for flexibility in referring to infrequent unions of types without requiring the invention of a name.
Unnamed types have the following qualities:
Two declarations of the same unnamed union type refer to the same type at runtime,
in a similar way to how two anonymous types with the same structure have the same type at runtime.
Two declarations of similar unnamed type unions that only differ in order of the declared types
refer to the same type at runtime.
Unnamed type union example:
In this example,
Cat
andDog
are types already defined.A single API can easily be constrained to either of these types without inventing a name or hierarchy for them to share.
Because you can have instances of both types constructed without reference to the union itself, small unnamed type unions are generally useful.
Unnamed tag union example:
This declaration while technically possible, may not be useful, since it is not possible to have an instance of either
Cat
orDog
outside ofthe tag union that describes them. Unnamed tag unions are generally more difficult to use than named tag unions.
Even declaring a value of an unnamed tag union is difficult, because everywhere you refer to it you must fully specify the union.
Interdependent type cases
A type union should be described with a set of case types that are all unrelated to each other.
A type union having a case type that is a derived type of another case type may lead to unexpected behavior.
This can either be solved by making it an error at compile-time or by fully defining the behavior.
However, due to possible usage of generics, it may not be possible to always catch these problems at compile time,
so, either type unions must be burdened with additional runtime checks or there should be no
runtime checks for this at all and instead rely on defined behavior.
Example:
Possible defined behaviors are:
Which behavior is possible hinges entirely on the implementation of the type union, so a choice
here instructs what implementations are possible.
Implementation Choices
Tag Unions
The tag union contains an enum field denoting the case and additional fields to represent optional parameters.
This can be optimized to share parameter fields with common types, similar to how F# generates union types.
The tag union is really just a type union and defines individual types to represent case and parameters.
A reference type variant of the above choices.
Type Unions
The type union is implemented using a family of generic struct wrapper types like
OneOf<T1, T2, ..>
.a. The type contains a single object field and struct types are boxed.
b. The type contains multiple fields, one for each type.
c. The type relies on new runtime technology to overlay types in memory.
d. The same one of the previous choices, plus the type contains an enum field denoting the case.
The type union is implemented using a custom code generated struct type specific to the case types specified.
a. The type contains a single object field and struct types are boxed.
b. The type contains multiple fields, one for each type.
c. The type relies on new runtime technology to overlay types in memory.
d. Same as one of the previous choices, plus the type contains an enum field denoting the case.
e. The type has an enum field denoting the case, an object field to hold all reference case types
with a special rule for case types that are record structs
allowing them to be decomposed into separate parameter fields within the union type that can be
shared across different cases.
The type union is erased to the type object, and all struct types are boxed.
Information about the set of case types is contained in attributes associated with parameters
similar to how tuple element names are described in metadata.
A reference type variant of the above choices.
Hybrid Unions
With an implementation choice like 2e, it is possible to have a single implementation design serve both type unions and tag unions.
Yet, because the implementation is similar, it would be possible to declare a union type that has both type cases and tag cases.
This may be an alternative to having type unions with singleton type cases.
Improving existing union types outside of pattern matching
Assignment and Coercion
Existing type unions may allow for coercion/conversion between unions of similar type cases,
but they are only checked for correctness at runtime
because defining constraints for when this is possible within the C# type system is impractical.
Any existing type union that allows for coercion must implement a factory, constructor or coercion operators that
consumes a different kind of type union and make runtime checks against the allowed type cases.
C# support for existing type unions can improve on this by adding runtime checks for compatibility when coercion/conversion is used.
The union type itself would still use its own runtime checks regardless, but the API for doing this would need to be standardized
in a fashion similar to the other API's used for pattern matching.
For example, the type union could be required to declare a factory that consumes a common interface for type unions.
In this way, any type union instance can be coerced to another type union at runtime,
but also checked for compatibility at compile time.
An non-boxing version
Note: this will not work for tag unions, because even though two different tag unions may have the same named tag,
there is no good way to know that these two tags from two different unions mean the same thing.
Other Topics
Pattern matching with other type unions
It is possible to use pattern matching syntax today to match on multiple types.
However, someone might now match using a union type expecting similar behavior:
Matching on a union type should be treated as first checking for the union type itself (if the source type is object),
and then attempt matching on the individual types described by the union and converting the result to the specified union type if matched.
Untyped pattern matching
Sometimes the static source type known at the time of pattern matching is only the type object.
This may mean that a union type that ends up getting boxed, will not be recognized as a union type during pattern matching,
and thus type or tag case checks will not be performed, instead only type tests against the (probably boxed) union type.
In order to get at the content of the union type at all, the union type itself would have to be matched first.
But since there are multiple possible type unions that could contain cat or dog, you would potentially have to
check them all.
Not knowing the actual union type is problematic.
To solve this we can introduce a common interface for type unions:
If all type unions implement this type, including existing type unions, then instead of the public API
of specific types, a single type test against ITypeUnion can be made.
Of course, this may lead to an additional problem. If you don't know the source is a type union, it might not be.
Without any additional hints, the compiler must always generate a
ITypeUnion
case for all untyped pattern matching.Note: this would not be a problem if the implementation of type unions was either erasure, or the runtime added support for union wrappers to
unwrap when boxed similarly to how Nullable is unwrapped when boxed.
Kicking the Tires
A project exists on GitHub that provides an environment for exploring possible implementations of union types via a source generator.
This is only for the experimenting with the code gen for the types. It does not include any new language syntax or features.
Also, it takes some work to get running.
https://github.com/mattwar/UnionTypes
Beta Was this translation helpful? Give feedback.
All reactions