dart-lang / language

Design of the Dart language
Other
2.61k stars 200 forks source link

Sum/union types and type matching #83

Open GregorySech opened 5 years ago

GregorySech commented 5 years ago

I'd like to import some features from functional languages like OCaML and F#. The main feature I'm proposing is the possibility of using typedef to define sum types. They can be thought of as type enumerations. This will, in some cases, reduce the need for dynamic making for sounder typing. For example json.decode would no longer return dynamic but JSON a sum type, something like:

typedef JSON is Map<String, JSON> | List<JSON> | Literal;

Sum types could power a new behaviour of switch case that resolves the type based on the typed enumerated. Kind of like assert and if does with contemporary is syntax.

typedef T is A | B | C;
...
void foo(T arg) {
  switch(arg) {
    case A: // here arg is casted to A
      break;
    case B: // here arg is casted to B
      break;
    case C: // here arg is casted to C
      break;
  }
}

A better syntax for this type of switch case might be of order, maybe something like #27.

typedef T is A | B | C;
...
void foo(T arg) {
  switch(arg) {
    case A -> ... // here arg is A
    case B -> ... // here arg is B
    case C -> ... // here arg is C
  }
}

This would be a powered down version of OCaML and F# match <arg> with as I've not included a proposition for type deconstruction, which would probably require tuples (or more in general product types) as discussed in #68.

lukepighetti commented 3 years ago

It would be great if sum type accepts values. For example:

typedef Format is 'html' | 'js' | 'json';

Format convert(Format input)
{
   // etc.
}

void main(List<String> args)
{
   Format output = convert(args[0]);
   print(output);
}

It would help me to avoid convert string to enum in cli input.

related issue: https://github.com/dart-lang/language/issues/158

venkatd commented 3 years ago

Hi all, I can give a real life example from our code that could benefit from something like this:

  String tokenToMarkup(ChatMessageBodyToken token) {
    if (token is ChatStringToken)
      return token.string!;
    else if (token is BasicUser)
      return '<user:${token.id}>';
    else if (token is BasicCard)
      return '<card:${token.id}>';
    else if (token is ChatFileToken)
      return '<file:${token.id}>';
    else if (token is ChatTimeEntryToken)
      return '<time:${token.id}>';
    else if (token is ChatLinkToken)
      return token.uri ?? '';
    else if (token is ChatDateTimeToken)
      return '<datetime:${token.datetime}>';
    else
      return '';
  }

ChatMessageBodyToken has a finite number of possible types.

Some pain points with this code:

Every time there is a code refactor around our type system, the largest source of bugs is these regions of code. Nearly all of them relate to a type that can be one of N possible things.

Another example is a "jump to" feature we have in our app. You can search for users, tasks, messages, files, and so on. The return value of the JumpTo dialog is Object. We dynamically type check it to decide how to navigate to that item.

jpmtrabbold commented 3 years ago

Hey @munificent our team have been having a blast with Dart/Flutter.

The lack of union types is really off-putting for some as typescript really has spoiled us...

Is this on you guys' roadmap at all?

munificent commented 3 years ago

Hey @munificent our team have been having a blast with Dart/Flutter.

The lack of union types is really off-putting for some as typescript really has spoiled us...

Is this on you guys' roadmap at all?

This issue is for sum types. For union types, you want this one.

The short answer is no, we aren't currently working on union types. It's still on the table, but static metaprogramming and constructor tear-offs are keeping us busy right now.

RastislavMirek commented 2 years ago

The short answer is no, we aren't currently working on union types. It's still on the table, but static metaprogramming and constructor tear-offs are keeping us busy right now.

I am starting to be afraid that core Dart team is getting a bit distracted from the mission (which, I would expect, is to be to provide pleasurable, effective & safe programming experience).

My reasoning for this worry is simple: The utility of sum / union types (even if boxed) for average user is much greater than that of, for example constructor tear-offs. This is what the language needs to improve type safety, code readability and self-documentation. It is also what people are used to use from other languages, where it has proven rather useful. I would even say that this is more important for Dart major use cases than static metaprogramming (and I understand how bold that statement is).

I understand that the resources are limited and their allocation might be driven by other factors than users’ immediate needs (e.g. strategic, long term plans etc.). Still this is a huge omission that simply should be addressed with high priority.

Levi-Lesches commented 2 years ago

I believe the main difference is on how much resources it would take to implement this. Any change to the type system is big, usually comes with lots of breaking changes, and takes a lot of time. Tear-offs are much simpler. Static metaprogramming is also huge, but it will transform the language and open up endless possibilities. Not to mention, it will let everyone be able to implement their own solutions to common problems, such as dataclasses (#314), which will save the team much more time in the long run. And remember that the team just finished null-safety, and are still dealing with some of the fallout (like #1201). Not to say this won't be useful, but I've never once used sum types, nor have I ever wanted to, while I wish for the other features almost daily. I think their time is being wisely spent.

RastislavMirek commented 2 years ago

I believe the main difference is on how much resources it would take to implement this.

This is a valid argument. Still, type system improvements often have large impact on usage and efficiency so they are worth investing to.

I've never once used sum types, nor have I ever wanted to,

This is curious... Especially in a language that does not have method overloads based on parameter type, neither extension methods that allow adding implements (interface compliance). Have you ever worked with a language that uses them extensivelly?

Maybe there is a misunderstanding - I take it that type sums are superset of type unions, which is not neccessery true, but QA seems to consider them to be. That is, the request includes typedef UnionType = Widget | WidgetBuilder; not only typedef SumType = 'case_one' | 'case_two'. While the later is arguably usefull, the former is IMO almost required, given Dart language other limitations.

Some of the areas where this would be rather usefull:

In general, I would say that Dart really lacks on the front of type system flexibility behind the likes of Swift, Kotlin or Typescript - which (given their main domains) are the direct competitors. This single feature would catch them up significantly.

jodinathan commented 2 years ago

We need this and typed maps for a better json usage.

replacing method overloads

I am not an expert, but I don't think it replaces because method overload can find the correct method to execute at build time instead of runtime.

RastislavMirek commented 2 years ago

I am not an expert, but I don't think it replaces because method overload can find the correct method to execute at build time instead of runtime.

You are correct, I added quotes around replacing to make it clear that it is not a full replacement. Still, in many usecases this would be enought or at least "better than nothing". Having one extra if in a method body is better than having new method with different name and lots of duplicated code or adding more methods to remove that duplication.

rrousselGit commented 2 years ago

As long as they are still planed, I can agree with others that this feature can be delayed a bit.

There are workarounds. While far from perfect, a few packages offer things like Option/Either classes. Or we have codegen like Freezed

But once we have big features like static meta programming, I could see this be the next big thing

munificent commented 2 years ago

Constructor tear-offs is a fairly small feature that is being worked on in parallel with static metaprogramming. (@lrhn is mostly doing tear-offs and @jakemac53 and I are working on metaprogramming). So I don't think working on constructor tear-offs impacts the velocity of static metaprogramming much.

Sum types and pattern matching is a much bigger feature and would involve more language team resources. It would be harder (but not impossible) to do it in parallel with metaprogramming.

I think our priorities are right here. Sum types and pattern matching are very close to my heart, but they don't fundamentally change how you write Dart code. Static metaprogramming can (if it works out well) address several of the largest pain points users tell us about the language: data classes, Flutter verbosity, automatic serialization. It also may impact how frameworks and libraries are designed, which means the sooner we do it, the sooner the ecosystem can start building on it.

RastislavMirek commented 2 years ago

@munificent Thank you for reaction. I am well aware of the power metaprogramming can bring. I do a lot of AI and ML work in Julia, arguably the language with the most powerful metaprogramming support out of all popular languages (stronger than e.g. LISP). And I agree this can be rather impactful feature, without knowing the extend or details of ongoing Dart implementation.

On the other hand, Dart already has good code generation support and there are packages / IDE plugins that do a good job in handling issues like serialisation and data classes generation. Metaprogramming will make that easier and of course, if done right, it can enable new usages of the language all the way to self-rewriting code that can one day solve many AI challenges - although, something like Julia might far better tool for such research.

Also, adding metaprogramming has its risks as well: It it is an advanced feature, that may decrease code undestanding and predictability because it may be too complex for some (many?) to read and write actual meta code. I have heard an opinion that it feels like magic, because it takes so much time to understand the meta code, that you opt to just trust it works. That might be too strong for me but I do consider it an advanced feature, that not all languages need (nor have) and few people actually code those meta constructs. And I believe that Dart's big advantage is its comparatively simplicity and wonderful predictability (no surprises, almost no unique syntax that is not obvious, no special/unexpected behaviours, etc., regardless what was your previous language).

Perhaps I lack the credit in the Dart/Flutter community to even question Dart core team priorities. Furthermore, I do not know your situation, non-public goals and reasons. Still, if I was the decision maker, with the information I have (no doubt incomplete), the way I would go around this would be to ensure the basis are strong (starting with the type system) before building advanced features like metaprogramming.

That being said, @munificent 's point about it having high impact on package environment (so you better get it out soon) is rather good one. And the information that constructor tear-offs is minor rather than major work is just a relief to hear.

munificent commented 2 years ago

On the other hand, Dart already has good code generation support and there are packages / IDE plugins that do a good job in handling issues like serialisation and data classes generation.

In principle, yes, you can solve it today with code gen and that's what users are doing. But I think we get pretty clear feedback that those solutions aren't ideal because users still keep asking us for language changes for data classes and serialization. I think if code gen was good enough, we wouldn't hear that.

Also, adding metaprogramming has its risks as well: It it is an advanced feature, that may decrease code undestanding and predictability because it may be too complex for some (many?) to read and write actual meta code. I have heard an opinion that it feels like magic, because it takes so much time to understand the meta code, that you opt to just trust it works.

Yes, all language changes carry risk and static metaprogramming is certainly higher risk than most. One of the primary responsibilities of the language team is making sure our changes carry their weight and are as big of an improvement for as many users as possible. It's hard, but we do our best.

That might be too strong for me but I do consider it an advanced feature, that not all languages need (nor have) and few people actually code those meta constructs.

My experience from Dart and other language is that few users use metaprogramming directly, but almost all users end up using frameworks or libraries that do metaprogramming on their behalf. Most Ruby users probably aren't monkey-patching their classes, but they are using Rails, which does. Likewise other language ecosystems. Concretely, we know that users must have metaprogramming needs for Dart because code generation is widely used despite being really difficult and hampered in many ways.

And I believe that Dart's big advantage is its comparatively simplicity and wonderful predictability (no surprises, almost no unique syntax that is not obvious, no special/unexpected behaviours, etc., regardless what was your previous language).

Yes, sacrificing simplicity and predictability is always risky.

Perhaps I lack the credit in the Dart/Flutter community to even question Dart core team priorities. Furthermore, I do not know your situation, non-public goals and reasons. Still, if I was the decision maker, with the information I have (no doubt incomplete), the way I would go around this would be to ensure the basis are strong (starting with the type system) before building advanced features like metaprogramming.

I agree that you really want to get the static type system right as early as you can. At the same time... we pretty radically changed the type system in Dart 2.0 and significantly changed it more recently with null safety. It's time-consuming (both for us and for users), but we do seem to be able to evolve the type system.

In this case, I think sum types wouldn't have much of an effect on how static metaprogramming is designed. If I thought there was a dependency there, I'd probably push to do sum types first.

RastislavMirek commented 2 years ago

@munificent All your answer are good answers. Thanks. I said all I felt might be useful. Looking forward to see what comes nexts. Please note (see my previous posts) that under sum types, I am specifically looking forward to see union types (not exactly the same thing but seems to be perceived as a subset of sum types by many in this discussion). Again, thank you kindly for comments and insights. I might be critical here but all things considered, I am happy that we switched to Flutter/Dart, so you (the team) must be doing good job most of the time.

munificent commented 2 years ago

I am specifically looking forward to see union types (not exactly the same thing but seems to be perceived as a subset of sum types by many in this discussion).

Union types and sum types are really really different things despite seeming superficially similar. Dart essentially has sum types already in the type system with the minor bit that it doesn't have a way to do exhaustiveness checking over them. But subtypes are a pretty close model for sum types.

Union types are a whole different kettle of fish and add a lot of new complexity to the type system, type inference, etc.

Nikitae57 commented 2 years ago

Any updates?

lukepighetti commented 2 years ago

Knowing very little about the 'behind the scenes' on union vs sum types, does one have an objectively better feature set compared to another?

Note: found this article

munificent commented 2 years ago

That article's a good one. The short answer is "no", there's no clearly objectively better one. Each has its strengths and weaknesses, and the ideal use cases for each are neither totally overlapping nor totally disjoint.

wkornewald commented 2 years ago

That article is good, but it's misleading because it thinks too much in terms of TypeScript's structural typing and JavaScript's APIs. When you have union types + nominal typing + inline class definition you can express sum types very nicely:

typealias Maybe<T> = Just(T) | None()
// could be the short version oft: typealias Maybe<T> = class Just(T) | class None()

You just need the right syntax to also make them a perfect replacement for sum types. The primary reason that speaks against union types is just that they're very difficult to implement for the compiler programmer.

@munificent can you show any real-world counter-example where sum types would clearly be better in a practical way?

lrhn commented 2 years ago

Sum types and union types are different, and both useful in their own ways. (In category theory, sum types is the dual of product types, aka. records/tupes, and union types is the dual of intersection types.) The distinction is mainly that sum types are tagged (they're "disjoint unions") and union types are not (plain set-like unions).

Classes, and class hierarchies, are very good at being tagged values, so they implement sum types perfectly, at least as long as your class hierarchy doesn't have any diamond structures. The thing you'd usually get with "proper" sum types is exhaustivness properties, which Dart could get for closed/sealed class hierarchies too.

Since union types are untagged, doing a union of non-disjoint type hierarchies can lead to ambiguity. That's what happens in Dart when you do

class Box<T> {
  final T? _value;
  Box(T value) : _value = value;
  Box.empty() : _value = null;
  T get value => hasValue ? _value : throw StateError("No value");
  bool get hasValue => value != null;
}
void main() {
  int? i = ... null or value ...
  var b = Box<int?>.value(i);
  print(b.hasValue); // False if `i` was `null`, even if we used the `.value` constructor!
}

Here the T? is instantiated with T = int?, which gives int??, aka int | Null | Null, aka int?. Since it's a union type, it's not possible to detect whether a null values means no value in the box, or a null value of the T type stored in the box.

Using a sum type, with disjoint unions, for nullability instead would allow you to make the distinction:

class Option<T> {
  Option.value(T value) = ValueOption<T>;
  static const Option<Never> none = NoneOption();
  bool get hasValue;
  T get value;
}
class ValueOption<T> implements Option<T> {
  final T value;
  ValueOption(this.value);
  bool get hasValue => true;
}
class NoneOption implements Option<Never> {
  const NoneOption();
  Never get value => throw StateError("No value");
  bool get hasValue => false;
}

void main() {
  Option<Option<int>> b = Option.none;
  print(b.hasValue); // false

  b = Option.value(Option.none);
  print(b.hasValue); // true
  print(b.value.hasValue); // false

  b = Option.value(Option.value(42));
  print(b.hasValue); // true
  print(b.value.hasValue); // true
  print(b.value.value); // 42
}

Sum types, being tagged, do not flatten. Union types do.

That also means that for sum types of non-disjoint values, you have to explicitly inject into the summand of choice, which makes sum types more verbose.

int??? x = null;

just "works" (or would if ??? was allowed), but you have to write one of:

Option<Option<Option<int>>> x = Option.none;
Option<Option<Option<int>>> x = Option.value(Option.none);
Option<Option<Option<int>>> x = Option.value(Option.value(Option.none));

for the sum type, to express which of the distinguishable variants of "no value" you want.

Even with helper syntax, it's still going to be more verbose because it has to distinguish the tree cases. (It can get much smaller if the language had Option as a primitive, obviously).

So, it's definitely true that union types are harder for compiler implementers to do, because sum types are already implemented through classes.

wkornewald commented 2 years ago

The distinction is mainly that sum types are tagged (they're "disjoint unions") and union types are not (plain set-like unions).

@lrhn This is somewhat misleading. Sum types force you to use tags, but with union types you're free to use tags or not, depending on what makes most sense. See my example above where I showed how to define union types with explicit tags.

lrhn commented 2 years ago

@wkornewald ACK, correct. You can have a tagged union and still satisfy the category theoretical definition of being a union type (which is reasonable, since a union can be disjoint if the domains are disjoint, so forcibly making it disjoint won't break any rules). Being a sum type is a strictly stronger requirement, where you have to tag if the types being summed are not guaranteed to be disjoint.

But, to not confuse terminology too much, I'll use "sum type" about language constructs satisfying the requirements for that (always being a disjoint union, where you can determine which part of the union the value came from) and "union type" for constructs that allows non-disjoint parts to lose the information about which side the value came from, and therefore cannot be sum types.

lukepighetti commented 2 years ago

Can we get a temperature read on the issue of adding tagged unions / enums with value as a core language feature along with pattern matching? This is my most missed feature after I spent time on other modern languages. Is this something that has internal buy in at some level? I'm guessing that some prerequisites to this feature have landed since this issue was opened.

enum MusclePaint {
  target(double),
  readiness(MuscleReadiness),
  none,
}

enum MuscleReadiness{
  fresh,
  recovering,
  fatigue,
}
leafpetersen commented 1 year ago

Connecting up the streams, there is a proposal under development for this here.

Can we get a temperature read on the issue of adding tagged unions / enums with value as a core language feature along with pattern matching?

@lukepighetti this is intended to be covered by the sealed family part of the proposal from @munificent

jodinathan commented 1 year ago

will this land with Dart 3?
can we test it?

lrhn commented 1 year ago

Yes. And yes.

Get the 3.0.0 dev-channel release of Dart. Run the code below as dart --enable-experiment=patterns,class-modifiers,sealed-class json_experiment.dart

// json_experiment.dart
import "dart:convert";

sealed class Json {}

sealed class JsonLiteral<T> extends Json {
  final T value;
  JsonLiteral(this.value);
  String toString() => "$value";
}

enum JsonBool implements JsonLiteral<bool> {
  jsonFalse._(false),
  jsonTrue._(true);

  final bool value;
  const JsonBool._(this.value);
  factory JsonBool(bool value) => value ? jsonTrue : jsonFalse;
  String toString() => value ? "true" : "false";
}

enum JsonNull implements JsonLiteral<Null> {
  jsonNull._();

  Null get value => null;
  const JsonNull._();
  factory JsonNull() => jsonNull;
  String toString() => "null";
}

sealed class JsonNum<T extends num> extends JsonLiteral<T> {
  JsonNum(super.value);
}

final class JsonInt extends JsonNum<int> {
  JsonInt(super.value);
}

final class JsonDouble extends JsonNum<int> {
  JsonDouble(super.value);
}

final class JsonString extends JsonLiteral<String> {
  JsonString(super.value);
  String toString() => jsonEncode(value);
}

final class JsonList extends Json {
  final List<Json> values;
  JsonList(this.values);
  int get length => values.length;
  Json operator [](int index) => values[index];
  String toString() => "$values";
}

final class JsonMap extends Json {
  final Map<String, Json> values;
  JsonMap(this.values);
  int get length => values.length;
  Iterable<String> get keys => values.keys;
  Json? operator [](String key) => values[key];
  String toString() => "$values";
}

void main() {
  var json = JsonMap({
    "values": JsonList([JsonNull(), JsonString("obo"), JsonInt(42)]),
    "z": JsonBool(false)
  });
  print(json);

  var buffer = StringBuffer();
  var newline = writeToXml(buffer, json);
  if (!newline) buffer.writeln();
  print(buffer);
}

bool writeToXml(StringBuffer buffer, Json json, [String indent = ""]) {
  switch (json) {
    case JsonMap(:var values):
      buffer.writeln("\n$indent<map>");
      var nextIndent = indent + "  ";
      for (var entry in values.entries) {
        buffer.write("$nextIndent<entry key=${jsonEncode(entry.key)}>");
        var newline = writeToXml(buffer, entry.value, nextIndent + "  ");
        if (newline) {
          buffer.writeln("$nextIndent</entry>");
        } else {
          buffer.writeln("</entry>");
        }
      }
      buffer.writeln("$indent</map>");
      return true;
    case JsonList(:var values):
      buffer.writeln("\n$indent<list>");
      var nextIndent = indent + "  ";
      for (var element in values) {
        buffer.write(nextIndent);
        var newline = writeToXml(buffer, element, nextIndent);
        if (!newline) buffer.writeln();
      }
      buffer.writeln("$indent</list>");
      return true;
    case JsonNull():
      buffer.write("<null/>");
    case JsonBool.jsonTrue:
      buffer.write("<true/>");
    case JsonBool.jsonFalse:
      buffer.write("<false/>");
    case JsonString(:var value):
      buffer.write("<string value=${jsonEncode(value)} />");
    case JsonInt(:var value):
      buffer.write("<int value=\"$value\"/>");
    case JsonDouble(:var value):
      buffer.write("<double value=\"$value\"/>");
    default:
      throw "unreachable: $json"; // Exhaustiveness not done yet.
  }
  return false;
}
jodinathan commented 1 year ago

@lrhn great!

what about union types?

munificent commented 1 year ago

what about union types?

(Non-discriminated) union types are a very very different feature from sum types. We don't currently have any plan for union types.

RastislavMirek commented 1 year ago

what about union types?

(Non-discriminated) union types are a very very different feature from sum types. We don't currently have any plan for union types.

Sorry to hear that. The more I use Dart the more I think it would benefit very much from union types. E.g. in data modeling when a flag can be a string or an int or when something in NoSQL DB can be a simple string but also a complex type that maps to a class. And in other scenarios. Properties of some widgets (also in SDK) would ideally be of a union type... Right now we can either make them dynamic or create an abstract class with subclasses that wrap the various cases. It is rather impractical.

jodinathan commented 1 year ago

what about union types?

(Non-discriminated) union types are a very very different feature from sum types. We don't currently have any plan for union types.

I guess I was mislead by the title here and the description in the patterns and related issue.

However, I find it weird that we won't have union types while it seems enhanced-enums fills the need for sum types.

lukepighetti commented 1 year ago

No union types is a real bummer. Sealed Classes are very much appreciated, but I view them more as an escape hatch in the absence of true union types.

spebbe commented 1 year ago

This is wonderful news – can't wait to try it out! Great job on this! 👏

munificent commented 1 year ago

Union types, enum types, and sum types are all quite different beasts. None are covered by the other. All are useful. Each is a better fit for different use cases.

Dart has enum types and will have a pretty good emulation of sum types in Dart 3.0. We may still do union types at some point, but they are the most complex of the three by far. Enums are basically syntactic sugar for what you can already do with a class and some constants. Sum types are just a class hierarchy you can already write with a little extra support for sealing the hierarchy and exhaustiveness checking.

Union types are a new form of ad hoc structural typing that interacts with the entire rest of the type system. Structural typing is nice in many ways, but it has a lot of complexity and many sharp corners. If not done carefully, it can make type inference much slower and lead to inscrutable type errors when you make a mistake in code.

RastislavMirek commented 1 year ago

Union types are a new form of ad hoc structural typing that interacts with the entire rest of the type system. Structural typing is nice in many ways, but it has a lot of complexity and many sharp corners. If not done carefully, it can make type inference much slower and lead to inscrutable type errors when you make a mistake in code.

@munificent Thank you for extensive reply, Bob. I suspect that some of the users discussing the issue here have already knew that this is no easy fix, either from previous comments or due to understanding of the nature of the problem. I do understand we are asking for a lot of work for an entire Dart team. Still, as someone who leads a team using Flutter/Dart exclusively while also lecturing about Flutter programming at my country's top university, I could not think of anything that would benefit the language more than union types and metaprogramming after Dart 3 stable release.

Of course, I have not read all the proposals and I do not have the insider's information. However, purely from the educated but critical user perspective, those 2 things would help to improve our code and experience the most. It is often an inconvenient truth in development as well as in life that (once all the low-hanging fruit has been picked) larger improvements require larger investments.

To sum up, I am happy to hear that you "may still do union types at some point". And if you do, you intend to do them well so that the type system remains just as easy to use as it is now. Thank you for all of the improvements delivered to us over the years. It is much appreciated!

Jure-BB commented 1 year ago

Another example that would benefit from unions are Flutter widget constructors that accept either parameter A or B, but not both.

Eg:

const InputDecoration({
   ...
    this.label,
    this.labelText,
   ...
    this.prefix,
    this.prefixText,
   ...
    this.suffix,
    this.suffixText,
   ...
  }) : ...
       assert(!(label != null && labelText != null), 'Declaring both label and labelText is not supported.'),
       assert(!(prefix != null && prefixText != null), 'Declaring both prefix and prefixText is not supported.'),
       assert(!(suffix != null && suffixText != null), 'Declaring both suffix and suffixText is not supported.');

With unions, this API would be clearer and there would be no need for these runtime asserts.

mateusfccp commented 1 year ago

Another example that would benefit from unions are Flutter widget constructors that accept either parameter A or B, but not both.

Eg:

const InputDecoration({
   ...
    this.label,
    this.labelText,
   ...
    this.prefix,
    this.prefixText,
   ...
    this.suffix,
    this.suffixText,
   ...
  }) : ...
       assert(!(label != null && labelText != null), 'Declaring both label and labelText is not supported.'),
       assert(!(prefix != null && prefixText != null), 'Declaring both prefix and prefixText is not supported.'),
       assert(!(suffix != null && suffixText != null), 'Declaring both suffix and suffixText is not supported.');

With unions, this API would be clearer and there would be no need for these runtime asserts.

It's already possible to do this without union/sum types. I think the Flutter team consider it simpler to have this kind of API with assertion, but I agree that it would be better to have a safer API.

vanceism7 commented 1 year ago

Another example that would benefit from unions are Flutter widget constructors that accept either parameter A or B, but not both.

Eg:

const InputDecoration({
   ...
    this.label,
    this.labelText,
   ...
    this.prefix,
    this.prefixText,
   ...
    this.suffix,
    this.suffixText,
   ...
  }) : ...
       assert(!(label != null && labelText != null), 'Declaring both label and labelText is not supported.'),
       assert(!(prefix != null && prefixText != null), 'Declaring both prefix and prefixText is not supported.'),
       assert(!(suffix != null && suffixText != null), 'Declaring both suffix and suffixText is not supported.');

With unions, this API would be clearer and there would be no need for these runtime asserts.

You can implement that type of functionality with sum types. We regularly do stuff like that in Purescript/Haskell and neither of them have union types

Jure-BB commented 1 year ago

You can implement that type of functionality with sum types. We regularly do stuff like that in Purescript/Haskell and neither of them have union types

I didn't specify type of unions as both as both type unions or tagged unions could be used. Sum types == tagged/discriminated unions, so both Purescript and Haskell have tagged unions ;)

munificent commented 1 year ago

I do understand we are asking for a lot of work for an entire Dart team.

Union types aren't just complex to design and implement. They are also complex to learn and use. So, while they provide a lot of value, they also carry cost to users of Dart as well. They will make compile error messages harder to understand. They might significantly slow down static analysis leading to a slower IDE experience and longer compile times.

It's not just a question of "can the Dart team afford this". It's also a question of whether our users want to afford it too. It might still be worth doing, but the value proposition isn't entirely clear to me.

Wdestroier commented 1 year ago

Too Long; Didn't Read: 100% most depended-upon JS/TS projects use union types.

the value proposition isn't entirely clear to me

I'm going to try and show you how awesome union types are by showing that they are used by every single popular NPM package. Or rather, the ones I could analyze.

Top 1000 most depended-upon NPM (JavaScript and TypeScript) packages. The list is available at: https://gist.github.com/anvaka/8e8fa57c7ee1350e3491.

  1. lodash - 69147. Available at: https://github.com/lodash/lodash. It's entirely written in JavaScript. findLast.js -> @param {Array|Object} Extra code: if (!isArrayLike(collection)) { collection = Object.keys(collection) } at.js -> @param string | string[] paths property.js -> @param {Array|string} path Alright, lodash definitely uses union types.

  2. chalk - 39816. Available at: https://github.com/chalk/chalk. I can find under vendor/ansi-styles/index.d.ts export type ColorName = ForegroundColorName | BackgroundColorName; Under vendor/supports-color: export type ColorSupportLevel = 0 | 1 | 2 | 3; export type ColorInfo = ColorSupport | false; These types are exported by the library.

  3. request - 35681. Available at: https://github.com/request/request. Another project written in JS. At lib/tunnel.js I can find var port = uriObject.port Judging by the code, port can be either a String or a number. This object is probably an URL, the class from JS, and port is a string. At lib/multipart.js I can find: var body = chunked ? new CombinedStream() : []

  4. commander - 32077. Available at: https://github.com/tj/commander.js. This one was easy. It contains a typings/index.d.ts file. God bless the maintainers. 31 union types in this file.

  5. react - 30604. Available at: https://github.com/facebook/react. React is very well typed and contains infinite union types. at react/src/ReactCache.js p: null | Map<string | number | null | void | symbol | boolean, CacheNode>, at scripts/flow/environment.js input?: ArrayBuffer | DataView | $TypedArray on(event: string | symbol, listener: (...args: any[]) => void): Busboy;

  6. express - 27420. Available at: https://github.com/expressjs/express. I can remember express has TypeScript bindings. I should be able to save some time. The bindings are available at: https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/express. export function query(options: qs.IParseOptions | typeof qs.parse): Handler;

  7. debug - 23285. Available at: https://github.com/debug-js/debug. At src/node.js:

    let val = process.env[key];
    if (/^(yes|on|true|enabled)$/i.test(val)) {
        val = true;
    } else if (/^(no|off|false|disabled)$/i.test(val)) {
        val = false;
    } else if (val === 'null') {
        val = null;
    } else {
        val = Number(val);
    }

    Edit: I imagine val should be infered as string | undefined before the if/else statements and bool | number | null afterwards.

I'm only searching for 1 or 2 usages per project. I can keep searching after the 7th project, but I need to stop at some point. This huge 931.05 MB https://github.com/DefinitelyTyped/DefinitelyTyped repository contains a bunch of type definitions and can show how useful the feature really is.

jodinathan commented 1 year ago

I'm going to try and show you how awesome union types are by showing that they are used by every single popular NPM package. Or rather, the ones I could analyze.

I work a lot with TS and I see that as a bad thing to be honest.

Too much unions or overloading methods are a mess to work with =\

One solution to make this feature used sparingly is to make it only available through typedefs:

void foo(String | Foo arg); // error

typedef Id = String | int;

void foo(Id id); // ok

It would also solve one of the interop questions

munificent commented 1 year ago

I'm going to try and show you how awesome union types are by showing that they are used by every single popular NPM package.

Everyone in Minnesota owns snow tires, but that doesn't mean you need them in Florida.

Each programming language exists in a certain ecosystem. TypeScript was designed for and exists in world where there is lots of dynamically typed JavaScript. In that world, it makes perfect sense that you would often see union types because dynamically typed APIs often accept values of multiple unrelated types.

Union types are very common in gradually typed languages or in type systems that were bolted onto existing dynamically typed languages: Typed Racket, Python's type hints, Ruby 3's new type annotations, Flow, etc. They are much less common in languages whose ecosystem has always been statically typed. They're absent from C, C++, Java, Scala, C#, Swift, Kotlin, Rust, Haskell, etc. (C/C++ unions are different from union types.)

Just because they are a good fit for TypeScript, that doesn't necessarily mean they are a good fit for Dart. They may or may not be, but the context has to be taken into account.

Wdestroier commented 1 year ago

Thank you for your feedback, Bob 🙂

One solution to make this feature used sparingly is to make it only available through typedefs:

@jodinathan I think it's useful, but I don't think it's a good idea to always give an union a new name.

Too much unions or overloading methods are a mess to work with =\

What I don't like in TS are literal types. Example: type Easing = "ease-in" | "ease-out" | "ease-in-out";

Axios had a very complicated API in my opinion:

export type RawAxiosRequestHeaders = Partial<RawAxiosHeaders & {
  [Key in CommonRequestHeadersList]: AxiosHeaderValue;
} & {
  'Content-Type': ContentType
}>;

type RawCommonResponseHeaders = {
  [Key in CommonResponseHeadersList]: AxiosHeaderValue;
} & {
  "set-cookie": string[];
};
eernstg commented 1 year ago

We have had some discussions about how we could emulate union types. Here is an example showing how we can use inline classes to emulate union types. It uses a library 'inline_union_type.dart' which is available here: https://github.com/eernstg/inline_union_type.

import 'package:inline_union_type/inline_union_type.dart';

// Use `split` to discriminate: Receive a callback for every case, in order.

int doSplit(Union2<int, String> u) => u.split(
  (i) => i,
  (s) => s.length,
);

// Use `splitNamed`: Can handle subset of cases, has `onOther`, may return null.

int? doSplitNamed(Union2<int, String> u) => u.splitNamed(
  on1: (i) => i,
  on2: (s) => s.length,
);

int? doSplitNamedOther(Union2<int, String> u) => u.splitNamed(
  on2: (s) => s.length,
  onOther: (_) => 42,
);

int? doSplitNamedInvalid(Union2<int, String> u) => u.splitNamed(
  onInvalid: (_) => -1,
);

void main() {
  // We can introduce union typed expressions by calling a constructor.
  // The constructor `UnionN.inK` injects a value of the `K`th type argument
  // to a union type `UnionN` with `N` type arguments. For example,
  // `Union2<int, String>.in1` turns an `int` into a `Union2<int, String>`.
  print(doSplit(Union2.in1(10))); // Prints '10'.
  print(doSplit(Union2.in2('ab'))); // '2'.

  // We can also use the extension getters `asUnionNK` where `N` is the arity
  // of the union (the number of operands) and `K` is the position of the type
  // argument describing the actual value. So `asUnion21` on an `int` returns a
  // result of type `Union2<int, Never>` (which will work as a `Union2<int, S>`
  // for any `S`).
  print(doSplit(10.asUnion21)); // '10'.
  print(doSplit('ab'.asUnion22)); // '2'.
  print(doSplitNamed(10.asUnion21)); // '10'.
  print(doSplitNamed('ab'.asUnion22)); // '2'.
  print(doSplitNamedOther(10.asUnion21)); // '42'.

  // It is a compile-time error to create a union typed value with wrong types.
  // Union2<int, String> u1 = Union2.in1(true); // Error.
  // Union2<int, String> u2 = Union2.in2(true); // Error.
  // Union2<int, String> u3 = true.asUnion21; // Error.
  // Union2<int, String> u4 = true.asUnion22; // Error.

  // However, we can't entirely prevent the introduction of invalid union values,
  // because it is always possible to force the type by an explicit cast. This
  // situation can be handled in a `splitNamed` invocation as shown in
  // `doSplitNamedInvalid`, and it can be detected using `isValid`.
  // If it is not detected, `split` will throw.
  var u = true as Union2<int, String>; // Bad, but no error.
  print(doSplitNamedInvalid(u)); // '-1'.
  print(u.isValid); // 'false'.
  // doSplit(u); // Throws.
}

This emulation yields some crucial elements of real union types: We can create expressions whose type is an emulated union type, they are subject to static type checks, we can use emulated union types as declared types (of function parameters, local variables, etc), and we can discriminate the cases of a union type using split (which must handle exactly the given types and returns the best type) or splitNamed (which is more flexible, but may return null).

There is no wrapper object, which means that we do not pay anything extra for the use of a union type compared to passing the underlying object directly (with type dynamic, probably). The discrimination (split etc) does have a cost (it uses callbacks). It is of course possible to perform type tests directly and avoid the callback, in return for giving up on compile-time checks on the set of cases.

On the other hand, there are some parts which are missing compared to a real language mechanism: There is no "algebra" of union types, which means that the type that emulates int | String is unrelated to the type that emulates String | int (with a real language mechanism they would be mutual subtypes). Moreover, there is no subtype relationship between int and the type that emulates int | String or String | int. We do have some of the desirable subtype relationships; for example, Union2<S1, S2> <: Union2<T1, T2> whenever we have S1 <: T1 and S2 <: T2.

We could achieve assignability (allowing Union2<int, String> u = 'Hello!';) by adding support for implicit conversions (e.g., implicit constructors, or an implicit version of asUnionNK). The implicit operation would be applied implicitly by compilers in a situation where the context type is a union type and the given expression has a type which is one of the operands. Implicit operations like this are not part of Dart today and not part of the inline class feature, but there is at least a non-trivial amount of interest in having them.

Real union types would be nicer in several ways, but this will work today (if inline classes are enabled), and I think it will handle many of the tasks that would otherwise be handled using union types.

RastislavMirek commented 1 year ago

@eernstg This would help to tie some loose ends for the time being. I hope that inline classes and implicit constructors for inline classes make it to the language so we can use this. Even if not distributed with Dart, it could be released as a package.

medz commented 1 year ago

I've been using Dart for four years, and I've been developing mobile apps in Flutter before that. In the past year, I have been keen on using the Dart language in server-side development. The more I do this the more I feel the need for union types.

Yep, I'm building a database ORM for a Dart backend, and some web frameworks. Whether I'm building a REST API or a GraphQL API, the input types of the ORM need it too much.

I don't want to discuss the feasibility and popularity of making it in Dart, just my emotions as a programmer who is keen on server-side programs. Tired of switching between languages every day as a full-stack developer. This makes the development experience very bad. . . I had to install various languages and editor extensions locally.

PS: For example, I am working on Prisma ORM for Dart. Because of the lack of joint types, I want to build a type-safe tool. Therefore, the dynamic type has not been used, which has resulted in the inability to develop many practical functions.

I always expect that when developers use it, they only need to click on the function name to know the type passed in, instead of reading the documentation of balabalabala.

Shidoengie commented 1 year ago

i think this is a really important feature

ahmednfwela commented 11 months ago

It uses a library 'inline_union_type.dart' which is available here: https://github.com/eernstg/inline_union_type.

That's interesting, we have built something very similar here https://github.com/Bdaya-Dev/dart-oneof https://pub.dev/packages/one_of it was as a helper library for us when generating OAS3 spec in dart (which uses oneof and anyof)

eernstg commented 11 months ago

https://pub.dev/packages/one_of

Interesting! You're doing a non-trivial amount of work in order to keep track of the variant: If we have a type that models Object | num then your typeIndex would allow us to remember that a given int is actually intended to be considered as a num or as an Object, and not as the other one. The approach taken in https://github.com/eernstg/extension_type_unions (which is the most recent version of 'inline_union_type') does not make any attempt to store this information, it will happily deliver that int when you ask for the value at any of those two types.

You're (at least potentially) using late binding because there is a common superclass abstract class OneOf, but otherwise you could probably make the choice to turn all those OneOf... class declarations into extension type declarations, if you prefer the associated trade off (pro: faster, smaller; con: can circumvent constructor invocations using as).

sebastianbuechler commented 10 months ago

That looks quite nice, however, now we have to use unnecessary new types like JsonMap instead of just Map. Is it possible to build a Json sealed class with existing classes like Map and List?

So going from

  var json = JsonMap({
    "values": JsonList([JsonNull(), JsonString("obo"), JsonInt(42)]),
    "z": JsonBool(false)
  });

to

  Json json = 
    "values": [null, "obo", 42],
    "z": false,
  };