Closed blois closed 3 years ago
This comment was originally written by jjo...@google.com
Another use-case:
In the thread "Too type happy in dart:io?", mythz complained about the verbosity of setting the Content-Type of an HTTP response:
response.headers.contentType = new ContentType("application", "json", charset: "utf-8");
Instead, it would be nice to say:
response.headers.contentType = "application/json; charset=utf-8"
However, Anders noted that it's nice to have Content-Type reified as an object, so that you can access properties like so:
response.headers.contentType.mimeType
In the context described above, type-unions would allow the contentType setter to accept either a ContentType object, or a String; in the latter case, the String would be immediately parsed into a ContentType.
Any chance of this getting another look from the standards committee? I've run into several cases recently where I really want union types.
Removed this from the Later milestone. Added Oldschool-Milestone-Later label.
Removed Oldschool-Milestone-Later label.
This comment was originally written by @stevenroose
Another important use case is auto-complete in IDE's.
If a library declares a method with a dynamic return type, IDE's won't show any autocomplete options. If the method can only return two types of objects, the types could be specified using the union operator and IDE's could show auto-complete values for both types.
Example:
class MyClass { ... Map|String toJSON([bool stringify = false]) { Map jsonMap = _generateMap(); return stringify ? const JsonEncoder.convert(jsonMap) : jsonMap; } }
Isn't this relevant for the non-nullable proposal? Ceylon does this really nice thing where null
es of type Null
, so a nullable String
is declared as String?
which I believe is short hand for the union String | Null
.
I find it somewhat strange that this is not higher on the priority list of the dart team. This feature really seems like an essential to me, that should have been in dart 1.0.
@cgarciae was thinking the same thing myself, union types seem like more bang for the buck. Maybe file a bug against the nullable types DEP?
According to discussion in https://github.com/dart-lang/sdk/issues/20906 union types were (are?) implemented in the old Dart Editor behind a flag. As far as I understand it, they were being inferred by the editor and used for hints and (possibly) code completion. There was not a syntax to declare a value as having an union type, though, which is useful for documentation purposes and it is what this bug is about.
I think a great example of a union type use case is JSON:
JSON = Null | bool | num | String | List<JSON> | Map<String, JSON>
Ah, a recursive union type. No need to make things simple, eh? :)
I think that might just be too "wide" a union to actually be useful. To actually use the type, you have to check against almost every type anyway:
if (x == null) {
...
} else if (x is bool) {
...
} else if (x is num) {
...
} else if (x is String) {
...
} else if (x is List) { // This should be enough to deduce that x is List<JSON>?
...
} else { // Here you don't have to write (x is Map), that can be inferred.
...
}
That does expose an advantage to having the union type. I think we should be smart enough to know when an imprecise type test still only allows one type of the union, so:
JSON x = ..;
if (x is List) { .. x is known to be List<JSON> here ... }
We can't do that with the current dynamic
.
For smaller unions like A = B | C
, a negative B
test is as good as a positive C
test. Classical example:
var result = f(value); // Inferred type (S|Future<S>).
if (result is Future) {
// It's Future<S>
} else {
// It's S.
}
Maybe we can even be cleverer:
var x = something(); // type (List<int>|Set<int>|Map<int,int>).
if (x is Iterable) {
// x has type (List<int>|Set<int>|(Map<int,int>&Iterable<int>)), not just Iterable<int>.
if (x.contains(42)) print("Huzzah!"); // valid, all three types expose Iterable.contains.
} else {
// x has type Map<int,int> - the List and Set parts could not refute the is test.
if (x.containsKey(42)) print("Yowzer!");
}
If we have union types, I want this program to be warning free! :)
It might not be as simple as it looks, though :(
I'm really doing Boolean algebra on types. A type test creates an intersection of types:
T1 x = ...l
if (x is T2) {
// we know x is (T1&T2).
}
If T2
is a subtype of T1
then the result is just T2
. That's what we currently support.
If T1
is a subtype of T2
, the test is a tautolgy and you don't get any smarter. the result is still T1
.
If T1
and T2
are unrelated, then you just get an intersection type. That may be an empty type, and you can't generally use it for anything.
Now, if T1
is a union type S1|S2
, doing intersection should give us (S1|S2)&T2
which should be equivalent to (S1&T1)|(S2&T1)
.
The problem is that a positive is
test can't refute any type because it's generally possible to implement any two interfaces on the same object. The negative test can refute something - if you are a Map
, List
or Set
, then knowing that you are not an Iterable
definitely precludes being a List
or Set
.
The possibilities are endless :)
If we had union types, Flutter APIs that deal with text get much nicer.
Instead of this: title: new Text('Example title')
I could do: title: 'Example title'
because we could annotate title
named parameter has taking either String or Text.
Another case that might be helped by Union Types.
Today:
new Padding(
padding: new EdgeInsets.all(8.0),
child: const Card(child: const Text('Hello World!')),
)
Could be:
new Padding(
padding: 8.0,
child: const Card(child: 'Hello World!'),
)
So is this still being considered? Dart is a great language, but this is one huge thing that it's missing.
Watch https://youtu.be/9FA3brRCz2Q?t=13m41s and pay attention to FutureOr and whereType. These problems are just begging for union types. Additionally union types are a good way of dealing with null safety.
I've come from 3 years of TypeScript development over to Dart via Flutter. Many things I like about Dart, some things I don't like so much but can deal with. The lack of union types (and non-nullable types) are really loathsome. For this reason I much prefer using TypeScript. My code is shorter, more type-safe and more self-documenting. If these features make it to Dart I'd feel so much more comfortable with Flutter.
pay attention to FutureOr and whereType. These problems are just begging for union types
Well, FutureOr
has been described many times as a way to check out union types "in a sandbox". However, union types and intersection types in their general forms probably cannot be separated, and there's a huge difference between recursive union types and non-recursive ones, so it's definitely a non-trivial step to leave the sandbox (and it's safe to say that this step is not guaranteed to be taken).
I'm interested in support for algebraic data types, similar to Swift's "enum with associated values" or kotlin's sealed class
. Is this the right issue for that type of support? Or should I file a separate issue?
An algebraic data type (say, SML style) could be characterized as a tagged union, but union types as discussed here are untagged. So with the algebraic datatype Data = Int of int | Char of char;
you'd need to unwrap a given Data
to get to the int
resp. char
that it contains, but with a union type you'd just have an int
or a char
at hand already, with no wrapping. This matters in a lot of ways, so I'd consider algebraic data types to be a different topic.
However, since OO objects carry their own (type) tags already, you could claim that we don't want the wrapping, we just want to establish a guarantee that a given type T is sealed (i.e., that T has a finite and statically known set of subtypes, e.g., because they must all be declared in "this file" or something like that). You could then write code that recognizes each of the subtypes of T and does different things with them, similar to pattern matching code with an SML style datatype. I'd think that this would be yet another topic: 'Sealed classes'. ;-)
(The reason why I don't think it's just the same topic as algebraic data types in disguise is that sealed classes can be used for other purposes as well, e.g., making sure that we can reason about all implementations of a given method, because we know every one of them statically.)
Honestly I'd be happy with just the when
keyword from Kotlin to simplify if elseif elseif else
That should certainly be a separate issue (and I don't think we have an existing issue which covers that request).
Created https://github.com/dart-lang/sdk/issues/33079 to cover ADTs
Edit: This post has been updated quite a few times to reflect the current discussion status. Also see:
Instead of introducing a special syntax just for ADTs I'd like to suggest a solution inspired by TypeScript, Rust and polymorphic variants (example follows below):
|
for constructing union types (i.e. an unordered set of types)
sealed
or enum
keywordA | B | C
it's valid to pass a subset type like A | C
A
to A | B
without breaking the APItypedef
for (optionally) giving a union a name
extends
for variantsSo, typedef
+ |
+ class syntax sugar replaces Kotlin's sealed
(or Swift's enum
) and is more flexible (every individual concept this is based on is useful on its own):
// Let's define two variants (normal classes) outside of a typedef:
// Syntax sugar from dart-lang/language#1002
class Success<T>(String authToken, T data);
class UnexpectedException(Exception _exception) {
Exception get exception => _exception;
}
// Here we use the same syntax sugar (without "class" keyword)
// as above to define variants inline.
// Every variant that has parentheses is a class defined inline,
// while without parentheses you refer to existing classes.
typedef LoginResponse<T> =
| Success<T> // refers to existing class
| UnexpectedException // refers to existing class
| InvalidCredentials(int code) extends SomeOtherClass() // defines new class
| AccountBlocked(String reason); // defines new class
typedef LoginError = InvalidCredentials | AccountBlocked;
String handleLoginError(LoginError | UnexpectedException error) {
// Simplified switch syntax for exhaustive match
switch (error) {
// Note how we can unpack via the constructor's field definition list
AccountBlocked(reason) {
return reason;
}
UnexpectedException(exception) {
return exception.toString();
}
// With "_" we tell the compiler we're not interested in the "code" attribute
InvalidCredentials(_) {
// ... update UI ...
}
}
// Also a nicer Rust-inspired if-let variant that does pattern matching
if (error is AccountBlocked(reason)) {
// Note that we can directly use the unpacked reason variable
return reason;
}
return "default...";
}
We should literally match on all variants, no matter if they contain parent and child classes:
class Parent();
class Child() extends Parent();
typedef X = Parent | Child | int;
X x = ...;
switch (x) {
Parent() -> ...
Child() -> ... // Would fail at compile-time if this were missing
int -> ...
}
TODO:
Should we extend switch
(like in the examples above) or introduce a separate match
expression?
Maybe? If you want to match on the class hierarchy instead:
switch (x) {
is Parent -> ...
int -> ...
}
We could also allow any other binary operator (not just is
):
switch (someInt) {
> 10 { ... }
> 0 { ... }
default { ... }
}
We could allow switch
/ match
to be used as an expression:
String result = switch (x) {
Parent() -> 'parent'
Child() -> 'child'
int -> 'int'
}
But how do we define the result when using {}
blocks instead of ->
? Should the last statement be treated specially?
switch
/ match
could also allow matching on the next element from one or more async streams like select
on Go's channels (implementing a type-safe async any
/or
for multiple Futures).
A union type would behave like the common interfaces implemented by all of its variants. So, int | double
implements num
and you can call .round()
without matching on the type. You can also pass it to any function taking a num
argument.
The subtyping rules are based on Scala 3:
A
is always a subtype of A | B
for all A
, B
(and A | A
gets reduced to just A
).A <: C
and B <: C
then A | B <: C
|
is commutative and associative:
A | B =:= B | A
A | (B | C) =:= (A | B) | C
So, |
basically creates a flat unordered set such that (int | int) | int
is just int
and int | double
is the same as double | int
. Note that this is slightly different from TypeScript where a union type is an ordered set.
If we also add intersection types (commonly expressed with &
) the subtyping rules get extended with:
&
is distributive over |
:
A & (B | C) =:= A & B | A & C
Extensions would allow adding interfaces to existing variants of union types and maybe they could even add methods to the whole union type (i.e. add an implementation to all variants at once).
We might also want to have a feature for "require interface X for type T" where T can be a union type:
mustImplement LoginResponse<T> with Foo, Bar;
Then you'd be statically forced to implement the Foo
and Bar
interfaces (e.g. directly via inheritance/mixin or via extensions) on each of the types that belong to the union. This feature is not strictly necessary because a missing interface would still raise a compiler error when e.g. trying to access a given method, but it wouldn't raise an error if nobody ever uses a given interface. When writing a reusable package you might want to statically guarantee that the interface you're exposing actually adheres to the spec.
Similar to TypeScript, match and if-statements that check the type automatically narrow a variable's type in the matching block.
It should be possible to take the union of two generic types (S | T
).
TODO:
What if you switch
/match
on the type of S | T
? With T = S
you'd have two different code paths matching on the the same type. Should the first matching case win or should this result in a compile-time error?
How can we apply narrowing to generic types?
With overloading you could define functions like this:
void doSomething(int x) {}
void doSomething(double x) {}
int | double x = ...;
doSomething(x);
If the union type contains both a parent and child class and the overloaded function takes multiple arguments this might require multiple dispatch to work properly. Initially Dart could disallow function overloading with ambiguous union types.
Something else I really like about TypeScript is that you can use literals as types (especially strings). This way you can e.g. make APIs safe that only accept certain constants:
const KeyA = 'a';
const KeyB = 'b';
// You can even refer to a const here
typedef Key = KeyA | KeyB | 'c' | null;
void setValue(Key key, String value) {
// ...
}
With some codegen literal unions could even be used for e.g. making Flutter's rootBundle.loadString(path)
statically allow only valid paths that actually exist (the typedef of allowed paths would be generated at compile time).
switch
instead of match
in the examples, but it's still an open question which one should be usedParent | Child
would workFor the improved static checking that union types would give us, it might be useful to take a smaller step in the shape of a lint. See https://github.com/dart-lang/linter/issues/1036 for a request in this direction.
tl;dr Read the bold text rd;lt
@wkornewald, thanks for a nice description of a notion of union types!
I do think it's worth pointing out a specific distinction:
|
for constructing unions [plus properties: can combine types .. from different modules,A|C <: A|B|C
, nullableT
is simplyT|Null
)
These are properties that I'd expect from any proposal about union types, and you should be safe assuming that you'd get them.
However, sealed
is a different mechanism with a different set of properties and purposes: It's associated with a specific subgraph S of the type hierarchy, presumably declared in a single library L, and it prevents S from being extended by declaring new subtypes of anything in S anywhere outside L. So if you want to use sealed
to emulate union types it's going to be a really weak and restricted emulation.
In contrast, union types won't ever allow the compiler to know that any constraints apply to a specific subgraph of the subtype relation, so union types will never serve to improve the opportunities for inlining and other optimizations that may only be valid if you can establish a (modular!) compile-time guarantee that every possible implementation of a given method is known. Similarly, if a human being or proof system is reasoning about the behavior of a certain interface it may be very helpful to know every possible implementation of the methods under scrutiny. Union types are not just weak when it comes to establishing completeness guarantees ("we've seen them all") which are known at the declaration of a type, they're irrelevant.
You mentioned one more property, and if I understood it correctly then it's actually just one more example of a difference between a sealed set of classes and a union type:
you can define functions that expect just one variant/case instead of the whole union/enum
If you have the sealed set A, B, C
with the relations B <: A
and C <: A
then we can emulate the unions A|B|C
, B
, and C
, but not A
, A|B
, A|C
, nor B|C
. So you don't have to restrict yourself to just the whole sealed set (A|B|C
), but you also won't get all subsets. And this just reconfirms that sealedness is not unions.
So we shouldn't consider union types and sealedness as alternatives, they are simply different.
That said, I think your examples provide a nice illustration of how several independent mechanisms can interact constructively (value classes, class declaration sugar, pattern matching). That's an important motivation when each one of them is considered.
Lets take an example, File | Directory
.
Often times I dont really know what's so common about the two types I want to be possible as a param, I may not want or view them as common at all. I just happen to want X | Y here. Why should I be required to make a Z? Figuring out what Z could be called is often not trivial, just look at the above.
And even if you do come up with a name that fits both, suddenly the code doesnt look so intuitive and nice at all anymore. (did you manage to come up with inode?)
I mean think about that, I never even had a Z in mind at all. That's a future optimization. In my mind this is a boolean: X or Y. Suddenly the language requires me to step completely out of my thoughts and reason about yet another thing, namely Z that I don't even need yet or maybe never.
Simply adding the union
keyword to the language would make this clear (and less verbose than all the |
ones)
// this can be scoped inside a class, kept private by amending the _ if needed
union websocketInput { String, Blob, ArrayBuffer }
@mathieudevos I would prefer using the |
syntax since it's not only already used in TypeScript and F# (which would make for a smoother learning curve), but it also improves readability since it clearly differentiates it from the Map
en Set
literal syntax ({ }
) and can be easily read as a sentence.
I read typedef MyUnion = String | int
as "the type MyUnion
is equal to a String
OR an int
".
@mathieudevos Adding to what @jeroen-meijer (and @Meai1) said: The |
syntax allows for simple ad-hoc unions:
bool isAllowed(String | int x) { ...}
With explicit union this becomes unnecessarily verbose and requires defining an extra type:
union AllowedTypes { String, int }
bool isAllowed(AllowedTypes x) {...}
This gets worse quickly if you have many different small unions in your code which all must be given a meaningful name (you surely know how difficult that can sometimes be and how it breaks the train of thought). Also, this would have to be taken into account for autocomplete because a name like AllowedTypes
is not helpful when you have to jump to definition for each union to see what is allowed. Even if autocomplete shows the underlying types of the union (which it should, even for the typedef
+ |
solution) you still get to read the name of the union in cases where it doesn't add any value, so the code just appears more complicated than it has to be.
@eernstg I still owe you a reply after this long time. :( Unfortunately, the new discussion came up on a different issue, so I'll just link to it and maybe we can continue discussing there (or here if you prefer): https://github.com/dart-lang/language/issues/83#issuecomment-531315843
Hopefully this becomes a thing, so this wouldn't just be used as documentation on the intended usage.
Is this language still being developed?
Still waiting for optionals and union types for years now. Like major language features.
As of now, we can't parse our banking API since they return false|object on many items.
🙈
Hopefully this becomes a thing, so this wouldn't just be used as documentation on the intended usage.
The exact problem i found today, this must be part of the language
@creativecreatorormaybenot no thanks. Might as well write JS or Python and introduce bugs.
@creativecreatorormaybenot that shouldn't be a solution lol - being able to type check at compile time is much more welcomed.
@creativecreatorormaybenot Nothing is that necessary if your attitude is that everything can be "fixed" with a mountain of boilerplate. May as well go back to assembler.
just a suggestion. I try to use Object instead of dynamic whenever I can. Using dynamic you can call a non existent method and have a runtime exception. Using Object you must cast it to something before calling a method or property. And with extension methods you can easily and securely cast stuff. It works even on null as in the example below:
extension UtilExtensions on Object {
Map<String, Object> get asPair {
assert(this is! Future);
if (this != null && this is Map) {
if (this is Map<String, Object>) {
return this as Map<String, Object>;
}
return (this as Map).cast<String, Object>();
}
return null;
}
}
void main() async {
Object a;
print(a.asPair); // prints null and no exception is thrown
var b = {};
print(b.runtimeType); // JsLinkedHashMap<dynamic, dynamic>
print(b.asPair); // {}
print(b.asPair.runtimeType); // CastMap<dynamic, dynamic, String, Object>
}
I wanted to point out that union types can be worked around currently in Dart and not that union types are not useful.
This means that Dart currently supports a (bad) way to work with union types and that this means that you can build your product in Dart today.
@creativecreatorormaybenot this isn't a thread about workarounds. This shouldn't be a thing and that's all there is to it: https://github.com/dart-lang/sdk/issues/4938#issuecomment-573634221 - you shouldn't need to look at the sdk source to understand the intended use of a parameter.
You don't need to look into the sources, it's in the documentation:
A value in the map must be either a string, or an [Iterable] of strings, where the latter corresponds to multiple values for the same key.
Union types can be super expressive (I know them mainly from Python), but it's something that mainstream programming languages lived without for decades, so I understand there isn't much urgency in implementing this. (Why don't we yet have independently developed linter that would interpret comments such as the one in the sdk and typecheck on it? As I say, lack of urgency for this feature, on all involved parties in this discussion.)
Discussions of workarounds certainly belong to issue threads, I have no complaints about that. It's not exactly the sort of updates I am looking forward to here, though :P
but it's something that mainstream programming languages lived without for decades, so I understand there isn't much urgency in implementing this.
With a minute of Google I found an implementation in Algol from 1968 :laughing: Hm, radical enhancements have been made even in the last decade, any language that ignores such things will be left behind. Dart and Go are so conservative, I get that a lot of people like this. To me it's kinda boring. I'd like to see a language where you can plug in syntax and semantic extensions through configuration. That would probably give Google nightmares 😂
@jiridanek I completely agree, actually, a linter is all we really need for things like optionals (or non-nullable as the dart team calls it)
Regarding the reason for Union types.
Sometimes they can also work like optionals ("?") with Model|null (Although optionals is coming https://github.com/dart-lang/language/projects/1)
When working with APIs sometimes objects can return different data types. For example, the last banking app we made had an API with properties that returned false|Model2|Model1 on a single property; not our choice.
Really, most errors we are seeing across our 36 commercial Flutter Apps come from not being able to define optionals and union types on model definitions.
In the professional world being able to define models accurately is a must. It's one of the main ways to avoid unnecessary bugs.
That false|Model1|Model2
return value would be considered a "bad API design" in languages with static typing but without union type support. Even with that is available, the result can be hard to follow. There isn't a natural name for the union (this was discussed in prior comments), so when you think of the subsequent program execution, you have to always think about each of these eventualities separately. The types are not helping you reason about the program that much. Union types don't look to be on the horizont and I won't write my own linter for this, so I'd personally avoid writing such methods, to benefit from the type system I do have already.
The situation with optionals and union types for method arguments is different in my mind. Optionals as return types do work well in practice, and union types for arguments are essentially method overloading in Java, except without having to write extra method for each combination of types. I do miss union types a lot here.
This would make a lot of sense to increase the soundness of Dart. I still find myself resorting to dynamic too much.
Yeah Union types and iterable object would make the dart flauless.
@jiridanek yes I agree it's bad API design but in the real world sometimes you don't have a choice.
but it's something that mainstream programming languages lived without for decades, so I understand there isn't much urgency in implementing this.
With a minute of Google I found an implementation in Algol from 1968 😆 Hm, radical enhancements have been made even in the last decade, any language that ignores such things will be left behind. Dart and Go are so conservative, I get that a lot of people like this. To me it's kinda boring. I'd like to see a language where you can plug in syntax and semantic extensions through configuration. That would probably give Google nightmares 😂
Rarely are languages "left behind" on their own merit. A language is nothing without use-cases. If developing the Dart SDK and Flutter do not need these features, then Dart will not have these features.
Based on your preference as stated, I'd suggest Javascript.
As someone new to Dart and Flutter coming from Typescript and React Native this confused me right off the bat! I just wanted to type a Map<String, String | bool>
but found unions missing from the language.
It seems really odd to me that this basic feature is missing from the language. There are (IMO) plenty of extremely common use cases for it. Most commonly as was pointed out previously, JSON as typedef JSON = Null | bool | num | String | List<JSON> | Map<String, JSON>
.
All sorts of domain specific types will require unions, especially when you have to model an externally imposed type that you don't have control over.
So what are the workarounds? Just use dynamic
and manually check for correct types at runtime? That isn't a very good one as it loses type safety at design time for anything that interacts with this interface... Are there any good workarounds? Is there a consensus on and strong reason for why this seemingly common feature is not part of the language?
Currently Dart does not support function overloads based on parameter type and this makes it verbose/awkward to clone/wrap Javascript APIs which accept multiple types.
For example, WebSocket.send() can accept a String, a Blob or an ArrayBuffer.
In Dart, this can either be exposed as: WebSocket.sendString, sendBlob, sendArrayBuffer which is verbose or: WebSocket.send(Dynamic data), which is no longer a self-documenting API.
It would be great to have some variant of union types, along the lines of JSDoc: WebSocket.send(String|Blob|ArrayBuffer data);