Skip to content

Commit cde6602

Browse files
authored
Strawman proposal for nominative union types.
Union types which have to be declared, and which do not have any members themselves. All they do is accept values of different, specified types.
1 parent e23cd15 commit cde6602

File tree

1 file changed

+227
-0
lines changed

1 file changed

+227
-0
lines changed
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
# Dart Nominal Union Types
2+
3+
Author: [email protected]<br>Version: 0.5
4+
5+
Dart has two structural union type constructs, `FutureOr` and `_?`.
6+
Adding union types to the language is a long-standing request. However, general union types, likely with accompanying intersection types (the two are hard to separate because of contravariance), is a very complicated feature to add to a type system. The subtyping rules get more complicated. The least-upper-bound computation gets trivialized (and not in a good way) when any two types has their union as the least upper bound.
7+
8+
This is a proposal for *limited* union types. The limit is that the union type is made *nominal*, it is a new type which is only a subtype of `Object` or `Object?`, it’s not assignable to any other type, including other union types, unless explicitly made a subtype of another union type.
9+
10+
## Proposal
11+
12+
### Syntax (strawman)
13+
14+
A union type declaration has a form like:
15+
16+
```dart
17+
typedef F<T> = A | B | C<T>;
18+
```
19+
20+
(We just add `('|' type)*` to the end of the new `typedef` syntax.)
21+
22+
When used with multiple types, it introduces a new nominal type (which is why the use of `typedef` may be a bad idea, but let’s keep it as a strawman).
23+
24+
### Semantics
25+
26+
#### Static
27+
28+
##### New type, subtyping
29+
30+
A declaration `typedef F<X1 extend B1, Xn extends Bn> = T1 | .. | Tn;` introduces a new nominal type `F`.
31+
32+
The type `F` is, trivially, a supertype of `Never` and a subtype of `Object?`, and a super/sub-type of itself (subtying is reflexive).
33+
34+
It’s a subtype of `Object` if all the elements types are subtypes of `Object`. _(We say that the union types itself is nullable or non-nullable in those cases.)_
35+
36+
If `F` is nullable, then `F?` is equivalent to `F` (mutual subtypes), otherwise `F?` is a proper supertype of `F`.
37+
38+
The type is a supertype of each of its union element types (`Foo` is a supertype of `A` and `B` here.).
39+
40+
If the union type is generic, different instantiations can be subtypes of each other. The type parameters vary by their occurrences, like for a type alias. For example `typedef Foo<T> = T Function(int) | int Function(T);` is invariant in `T` because `T` occurs both covariantly and contravariantly in the union element types. _(This might need us to introduce variance first.)_. For `typedef G<X> = List<X> | Set<X>;`, `G<int>` is a subtype of `G<num>`, because `X` occurs only covariantly, so `G` varies covariantly with `X`. _A direct use of the type variable, like `typedef U<S, T> = S | T;` counts as covariant._
41+
42+
Union types are not *structural*, so `typedef F1 = A | B;` and `typedef F2 = A | B;` introduces two *different and unrelated* supertypes of `A` and `B`.
43+
44+
The union type has *no members* other than those shared by `Object` and `Null`.
45+
46+
##### No cycles
47+
48+
A union type should not be an element of itself. Further, checking whether a value is a valid member of the union type should also not recursively require checking the same value against the same union type again.
49+
50+
It’s a compile time error if it’s possible to reach the declaration of a union type from itself by a sequence of the following steps, starting with the type’s own declaration (instantiated with fresh type variables if the declaration is generic):
51+
52+
* Given a type expression *T*.
53+
* If *T* is one of the fresh type variables introduced above, stop.
54+
* If *T* is a function type, a record type or any of the types `void`, `dynamic` or `Never`, stop.
55+
* Otherwise *T* is of the form `B<typeArgumentsOpt>`.
56+
* If `B` denotes a class or mixin declaration, stop.
57+
* If `B` denotes a type alias declaration, instantiate the declaration with provided type arguments, if available, otherwise to bounds, and use the alias’ type as a new type `S`.
58+
* If `B` denotes a union type declaration, instantiate the declaration with provided type arguments, if available, otherwise to bounds, then choose any one of the union element types of the declaration as a new type `S`.
59+
* If `B` denotes an inline class declaration, instantiate the declaration with provided type arguments, if available, otherwise to bounds, then use the representation type of that inline class as a new type `S`.
60+
* Then repeat with `T` being `S` .
61+
62+
If a union type is reachable from itself using such steps, then doing `is UnionType` will always transitively end up doing `is UnionType` on the same value again. We want to avoid that.
63+
64+
This detects and prohibits cyclic definitions, after expanding type aliases. It disallows `typedef Foo = Foo | Bar;`, `typedef F1 = F2 | int; typdef F2 = F1 | int` and `typedef U<S, T> = S | T; typedef Foo = U<Foo, int> | int;`. Implementations are free to detect the same cycles in a more efficient way.
65+
66+
This check is entirely *structural*. It can be performed before type inference, since it relies only on resolving identifiers to declarations, and substituting type arguments into types. It ensures that we can always expand a union type to a finite collection of non-union types in a finite number of steps, such that `is UnionType` can be decided by doing `is X` on each of the types in that collection. _(That’s why we need to expand inline classes to their representation type, because `is InlineClass` is implemented as `is RepresentationType`.)_
67+
68+
It’s still possible to make a union type nested inside itself dynamically, as:
69+
70+
```dart
71+
typedef U<S, T> = S | T;
72+
void main() {
73+
U<U<int, bool>, String> x;
74+
}
75+
```
76+
77+
However, this is only iterative, not recursive, which ensures that we can always keep expanding union types until we end up with a set of non-union types that are subtypes of the union type.
78+
79+
##### Type inference
80+
81+
For most purposes, the new type is just a plain type, with specific subtype relations.
82+
83+
If the union type is nullable, then `Foo?` is equivalent to `Foo`, and **NORM** will reflect that and remove the `?`.
84+
85+
Promotion happens normally on type checks of subtypes.
86+
87+
An expression of the form `e as Foo` or `e is Foo` work as normal. It can promote from `Object?`, or any supertype, to `Foo`.
88+
89+
Likewise `Foo x = …; if (x is A) …` can promote from `Foo` to `A`. This is one way to extract a useful value from a union type. So is an `as` cast if you happen to know which subtype it is.
90+
91+
Pattern matching also works: `switch (someFoo) { case A a: … case B b: ….}` can destructure `Foo`. The switch is exhaustive if the type checks cover all the union subtypes (each subtype would be exhausted by the cases of the switch). _For switch exhaustiveness, a union type is like a `sealed class` with the explicitly listed subtypes._
92+
93+
A union type is never the result of a least-upper-bound computation unless at one of its operands is that union type (and it’s a supertype of all the other types). _Union types have no link *from* their subtypes to the union type, and a single type can be a member of any number of union types. We never try to guess a union-super-type of a type._
94+
95+
You cannot implement, extend or mix-in a union type. You *can* declare extension methods on it (it’s a type), and you *can* declare inline classes with a union type as representation type.
96+
97+
##### Summary
98+
99+
Given `typedef F<T> = A | B | C<T>;`, the type `F<T>` is a supertype of `A`, `B` , `C<T>`. That means:
100+
101+
* `List<F> list = <B>[B(), B()];` is valid. A `List<B>` is-a `List<F>`.
102+
103+
* And `F f = B();` is allowed.
104+
105+
* You can also *cast* to `F`, as `B() as F`. This checks whether the value is accepted by *any* of the union types (which can be a trivial check if the static type guarantees the result.)
106+
107+
* Similarly `e is F` checks whether the value of `e` is accepted by any of `is A`, `is B`, `is C`, …
108+
109+
* You can include one union type in another:
110+
111+
```dart
112+
typedef JsonPrimitive = num | bool | String | Null;
113+
typedef Json = JsonPrimitive | List<Json> | Map<String, Json>;
114+
```
115+
116+
The subtyping is transitive in this case, `num` is a subtype if `Json`, and `JsonPrimitive` itself is also a subtype of `Json`.
117+
118+
* Union types can be indirectly recursive, as shown above. They can refer to themselves in type arguments, or function return/parameter types, but cannot be directly recursive. Something like `typedef Foo = Foo | Bar;` is *not* valid. _It should always be possible to expand each union element type to an actual non-union type, without cyclic dependencies.
119+
120+
* Generic union types can refer to type parameters, also as top-level types, like `typedef U<S, T> = S | T;`.
121+
122+
#### Runtime
123+
124+
When a subtype check is needed, whether for `e is Foo`, `e as Foo`, `try { … } on Foo { … }`, `e is List<Foo>`, or any other runtime type check against a union type, the proposed subtype is checked against *every* element type of the union to see if it is a subtype, in the source order of the declaration (presumably, it should be impossible to tell which order, so a compiler can optimize when possible).
125+
126+
In every other way, the union type is just a normal type, with the subtype relationships defined above. The union type has no other purpose than allow multiple types being treated as one.
127+
128+
## Limitations and discussions
129+
130+
### Incompatible with existing `dynamic`-using types
131+
132+
This does not allow having a simple type alias for existing JSON values,
133+
134+
```dart
135+
typedef Json = int | bool | String | Null| List<Json> | Map<String, Json>;
136+
```
137+
138+
and casting existing JSON structures to it, because you can’t cast a `Map<String, dynamic>` to `Json`. It would require the JSON parser to generate a `Map<String, Json>` to start with. Then it *would* work.
139+
140+
### No members on the type
141+
142+
We could allow the union type to declare members. The union type is a new (static) type for an existing value, just like an inline class. We could allow declaring members on union type as well. We’d probably want a different syntax then, because
143+
144+
```dart
145+
typedef Foo = A | B | C {
146+
int get kind => this is A ? 1 : this is B ? 2 : 3;
147+
}
148+
```
149+
150+
looks weird. Something like:
151+
152+
```dart
153+
union Foo implements A | B | C {
154+
int get kind => this is A ? 1 : this is B ? 2 : 3;
155+
}
156+
```
157+
158+
might look better. (Definitely work to do.)
159+
160+
### No intersection types
161+
162+
Because the union types have no members, other than those of `Object`, we don’t have to worry about how two semi-compatible members would combine.
163+
164+
In a design where we allow common supertypes of the union element types to also be supertypes of the union type itself, we would assume the API of those supertypes to be available on the union type.
165+
166+
Take:
167+
168+
```dart
169+
class B<T> {
170+
T foo(T x) => x;
171+
}
172+
class C extends B<int> {}
173+
class D extends B<double> {}
174+
typedef U = C | D;
175+
void main() {
176+
U x = ...;
177+
? r = x.foo(?);
178+
}
179+
```
180+
181+
What would the valid arguments to `U.foo` be, and what does it return?
182+
(This is actually getting us into a situation where a value can possibly implement the generic `B` with two different type arguments, which we wouldn’t allow normally, but here we know that it’s at most one of them at a time.)
183+
The usual solution would be making the return type of `U.foo` the union type of the individual return types, and the argument type the intersection types of the individual argument types, so `(int | double) foo((int & double) x)`. We cannot do that, either of them, because union types being nominal means we can’t synthesize `(int | double)` without a declaration for it. We also don’t want to.
184+
185+
So, having no members means we don’t need intersection types for the usual reasons.
186+
187+
Do we want them? Would `typedef FooBar = Foo & Bar;` defining a nominative supertype of all types that implement both `Foo` and `Bar` make sense? Probably not, because again it wouldn’t be able to have any members, and that’s really the most important part when it comes to intersection types.
188+
189+
### What about `FutureOr`?
190+
191+
The above specification was not written with `FutureOr` in mind. It would be *nice* if we could change the declaration of `FutureOr` to
192+
193+
```dart
194+
typedef FutureOr<T> = Future<T> | T;
195+
```
196+
197+
That would *mostly* work the same as today. It’s a supertype of `Future<T>` and `T`. It’s nullable if `T` is nullable. Where it differs is in `FutureOr<Future<Object>>`, which is currently equivalent to `Future<Object>` because `Future<Object>` is a supertype of both `Future<Object>` and `Future<Future<Object>>`, and we say that any supertype of both types of a union is a subtype of the union.
198+
199+
With the defined subtyping above for nominative union types, the only supertype of `FutureOr<T>` would be `Object?`, and `Object` if `T` is non-nullable.
200+
201+
It’s probably not a difference which is important in practice.
202+
203+
It’s possibly a *better* behavior than the current one. You should never need to assign a `FutureOr<Future<Object>>` directly to a `Future<Object>`, not without first checking if it’s a `Future<Future<Object>>`. (It won’t change that all the values of `FutureOr<Future<Object>>` satisfy `is Future<Object>`, but the union type doesn’t forget that it’s a union type.)
204+
205+
### What about nullable types?
206+
207+
Can we do the same to nullable types? Introduce:
208+
209+
```dart
210+
typedef Nullable<T> = T | Null;
211+
```
212+
213+
with `int?` being shorthand for `Nullable<int>`?
214+
215+
Both `T` and `Null` are subtypes of `Nullable<T>`. It’s covariant, so `Nullable<int>` is a subtype of `Nullable<num>`.
216+
217+
Where it breaks down is that the types `Nullable<Null>` and `Nullable<Never>` are *not* equivalent to `Null`. They are new types.
218+
219+
Likewise `Nullable<dynamic>` is a new types, not `dynamic` again.
220+
221+
That’s probably a bigger problem than for `FutureOr`, because we really want to normalize away types like `Null?`. We can still do that using `NORM`.
222+
223+
The alternative would be to introduce a rule, that if one of the union element types is a supertype of all the rest, then the union type is equivalent to that type (both super and subtype). I fear that might degenerate the type in some unpredictable places, and remove some of the advantage of introducing new nominative types. Then we might as well just go back to saying that a union type is a subtype of any type that all element types are subtypes of.
224+
225+
## Versions
226+
227+
* 0.5 - initial version

0 commit comments

Comments
 (0)