dart-lang / language

Design of the Dart language
Other
2.65k stars 203 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.

eernstg commented 1 year ago

If we get something like implicit constructors then we can introduce an implicit transformation from a Map<String, dynamic> to JsonMap, and so on.

// Json related extension types.

extension type Json(Object? o) {}
extension type JsonMap(Map<String, Json> value) implements Json {}
extension type JsonList(List<Json> value) implements Json {}
extension type JsonNull(Null _) implements Json {}
extension type JsonNumber(num value) implements Json {}
extension type JsonString(String value) implements Json {}
extension type JsonBoolean(bool value) implements Json {}

// Static extensions, providing implicit constructors of Json values.

static extension JsonMapExt on JsonMap {
  implicit factory JsonMap.fromMap(Map<String, Json> _) = JsonMap.new;
}

static extension JsonListExt on JsonList {
  implicit factory JsonList.fromList(List<Json> _) = JsonList.new;
}

static extension JsonNullExt on JsonNull {
  implicit factory JsonNull.fromNull([Null _]) => JsonNull(null);
}

static extension JsonNumberExt on JsonNumber {
  implicit factory JsonNumber.fromNum(num _) = JsonNumber.new;
}

static extension JsonIntExt on JsonInt {
  implicit factory JsonInt.fromInt(int _) = JsonInt.new;
}

static extension JsonStringExt on JsonString {
  implicit factory JsonString.fromString(String _) = JsonString.new;
}

static extension JsonBooleanExt {
  implicit factory JsonBool.fromBool(bool _) = JsonBool.new;
}

// Example usage.

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

  // Use implicit construction.
  {
    Json json = {"values": [null, "obo", 42], "z": false};
    print(json);
  }

  // Meaning of implicit construction code.
  {
    Json json = JsonMap.fromMap({
      "values": JsonList.fromList([
        JsonNull.fromNull(null),
        JsonString.fromString("obo"),
        JsonInt.fromInt(42),
      ]),
      "z": JsonBool.fromBool(false),
    });
    print(json);
  }
}

Note that JsonMap isn't necessarily a useless type. You might want to keep track of the fact that a given Map<String, dynamic> is typed as a JsonMap, because the former can map any given string to an object of any type, e.g., {"someString": #aSymbol}, but if it is typed as a JsonMap then it is likely to have been created using the constructors, and they give rise to type checks.

As always with extension types, there is no wrapper object. So the representation of JsonMap at run time is actually the type Map<String, dynamic> and you can do <String, Symbol>{} as JsonMap and succeed at run time. But if you don't want to diverge from the static types then you can just avoid casting.

quadband commented 9 months ago

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.

I echo this sentiment. I work with TypeScript a lot, and the ability for my IDE to just show me what, for instance, a function accepts is great. Dart could even enforce the use of type guards for dynamic returns or arguments, which TypeScript already does.

We technically already have a union type return with FutureOr, but the implementation seems to be black boxed within the compiler and runtime.

rrousselGit commented 9 months ago

While I appreciate the effort, I don't think the UnionX proposal would work me. It's too hacky and unreliable IMO.

We don't have Union2<A, B> == Union2<B, A>. IMO unordered comparison of cases is crucial to the feature. This is what enables packages to interact with each-other.

I hope that such a solution does not land in the Dart SDK. I'd rather have this be in a standalone package than the core SDK.

eernstg commented 7 months ago

I suppose one part of the title (type matching) has been covered quite nicely by the addition of support for patterns and pattern matching in Dart, and sealed classes are helpful with respect to sum types as well.

The remaining part (union types) hasn't been addressed directly. In other words, union types are becoming quite visible as a missing feature these days.

I don't think it's going to be easy to add them, though. Types of the form FutureOr<T> or T? for some T are special cases of union types, and the rich set of rules that apply to types of these forms illustrates that it would be a substantial undertaking to add fully general union types to Dart. So let's just conclude for now that we don't have them.

With that in mind, you might want to consider using an approximation to the real thing. I created a package https://pub.dev/packages/extension_type_unions that uses extension types to emulate the core properties of union types:

import 'package:extension_type_unions/extension_type_unions.dart';

bool isBig(Union2<int, String> intOrString) => intOrString.split(
      (i) => i > 42,
      (s) => s.length > 5,
    );

void main() {
  print(isBig(1.u21)); // 'false'.
  print(isBig('Enormous'.u22)); // 'true'.
}

When 'extension_type_unions.dart' has been imported, union types can be used. The union type that includes int and String is written as Union2<int, String>. It is used as the type of the parameter of isBig in the example.

Other union types (Union3, Union4, ... Union9) exist. They are identical except for the number of operands.

Values of the union type have to be established explicitly (this is the really big difference between a built-in union type and this emulation). In particular, 1.u21 turns 1 into an expression of type Union2<int, Never>. Similarly, 'Enormous'.u22 has type Union2<Never, String>. Since Never is a subtype of every type, we can use 1.u21 in every situation where the required type is Union2<int, T>, for any T. The first digit in u21 indicates the number of types that are "unioned" together (so with u2... it's a Union2 type), and the second digit indicates which one of them is the selected one for this particular value (so for u21 it's the first type).

The crucial part is that it is a compile-time error to pass any argument to isBig which isn't an int or a String (suitably coerced into a Union2<int, ...> or Union2<..., String>), which makes the union type safer than a very general type which is a supertype of the two types int and String:

// What would we do today, most likely?

bool isBig(Object intOrString) => switch (intOrString) {
      int i => i > 42,
      String s => s.length > 5,
      _ => throw "Unexpected argument type",
    };

void main() {
  print(isBig(1)); // 'false'.
  print(isBig('Enormous')); // 'true'.
  print(isBig(true)); // No compile-time error. Throws at run time.
}

Please take a look if you might be interested in this. Comments are very welcome, of course!

rrousselGit commented 7 months ago

One big issue I have with using extension types wrapping around Object? is, casts don't throw if misused. We can do 42 as Union<String, bool>, and it passes.

This is bound to be problematic when dealing with generics.

Code like:

T fn<T>() => something as T;
...
fn<Union2<A, B>>():

would silently fail. And it is a quite common pattern (most service locators do something like that for instance).


Overall I think a better approach is to rely on analyzer.

One project I tried a long time ago but never released was: Implementing Unions through lint rules. Rather than an extension type, Union would be an annotation:

@Union(int, double)
typedef MyUnion = Object?; // could be "num" too here

Then the Dart analyzer would emit an error when trying to do MyUnion foo = "string"

The crucial point here is that that the union is unordered.

The analyzer can freely support:

@Union(A, B)
Object? foo;

@Union(B, A)
Object? bar = foo; // Valid, unions are unordered

@Union(A, B, C)
Object? baz = foo; // Valid too, the union's length does not matter.

I've successfully implemented this before. I can release it if desired.

Even then, we still can't support as Union and need a custom asMyUnion(value) function (which can be code-generated). That's the reason I didn't release the package originally. But we have the same issue with extension types.

eernstg commented 7 months ago

Those are very well taken points, @rrousselGit!

casts don't throw if misused.

That's true! This is the basic trade-off: With extension types there is no wrapper object. This means that the union type is completely gone at run-time and, e.g., e as Union2<int, String> will succeed (because it just means e as Object? at run time). On the other hand, the fact that there is no wrapper object also ensures that the mechanism is zero cost at run time.

If you want a different trade-off then you can take a copy of extension_type_unions.dart, replace extension type by class everywhere, and fix up the constructors (extension types use something which is probably going to be known as primary constructors, but with a class you have to write the constructor using the traditional syntax, at least for now). With that we will have the same client code, the usual run-time representation of all type arguments, real wrapper objects, and the typing relations will be enforced (for any computational steps taken, even during dynamic invocations). Not hard, just a lot more costly. ;-)

I personally think the class version of union types won't fly (because it's too costly), but it is certainly an option which is available for anyone who wants it.

In general, casting into an extension type is a bad idea, hence https://github.com/dart-lang/linter/issues/4760. However, abstractions (like passing an extension type as the actual type argument in a generic function invocation) will make it possible to cast into an extension type in ways that are beyond static analysis (it's an undecidable question whether it will or will not occur, in general).

It would be useful if we could determine for every generic function whether or not it is parametric in each of its type arguments (basically: it doesn't perform run-time operations based on that type argument X, that is, it doesn't do e is X, e as List<X>, switch (e) { case X(): ... }, <X>[], etc.).

A parametric function can safely be called with an extension type as a type argument, and this will pass information from the actual arguments to the returned result (for example, x in the example below will have static type Union2<A, B>).

You can use the validation members to bridge the gap in cases where the extension type has been abstracted away (by being the value of a type argument).

T fn<T>() => something as T;

void main() {
  var x = fn<Union2<A, B>>();
  assert(x.isValid); // Throws unless `x is A || x is B`.
}

would silently fail.

That's not true, the execution of fn would succeed. But I modified your example to include an assertion, and the assertion would fail.

@Union(int, double)
typedef MyUnion = Object?; // could be "num" too here

I don't quite understand how MyUnion and the annotation would provide a solution to the issues created by fn<MyUnion>()?

On the other hand, I do understand that @Union(A, B, C) can be considered to be a supertype of @Union(A, C) and @Union(C, B). That's nice: we do want all those algebraic properties, if we can have them. On the other hand, it does rely on an enhancement of the analyzer, and extension_type_unions.dart is just using existing (though recently added ;-) features of the language Dart. In other words, extension_type_unions.dart is not a potential future feature, it's a way to do things that you can just choose to use today.

That said, I think it's worth noting that extension types can be used in many different ways. An extension type which is just providing some convenience members to an existing type can be used interchangeably with the underlying representation type:

extension type FancyString(String it) implements String {
  String get dup => '$this$this';
}

This is known as a 'transparent' extension type: It is no secret at all that the underlying representation type is String, and we don't really care whether any given string has type String or it has type FancyString. We can always turn it into a FancyString if we need those extra members. We can freely use a transparent extension type as a type argument, because it doesn't create any surprising behaviors that it is erased to the underlying representation type at run time.

In the case where the extension type and the underlying representation type are substantially different (the UnionN types in extension_type_unions is a good example), it is important to be aware of the fact that an extension type is a compile-time mechanism, and the run-time behavior is based on the representation type (the extension type is 'erased' away during compilation). For those extension types it's a good idea to limit the abstractions that they are subjected to. For example, we shouldn't pass them as actual type arguments unless we know that the callee is parametric.

A good rule of thumb would be to use non-transparent extension types as the declared types of variables and function parameters, or as function return types. With these kinds of usage, the extension type isn't abstracted away. Note that the case where a function has a formal parameter whose type is a union type is a perfect example of such a safe usage. I'd expect this kind of usage to be the most important use case for non-transparent extension types like the ones in extension_type_unions.dart.

rrousselGit commented 7 months ago

To be clear, I'm not trying to say that we should use the analyzer to implement Unions.
I talked about it, because IMO it is strictly superior to using extension types. Technically, the analyzer approach I've shown can go much further. For example we can support constant strings and number ranges: @Union("foo", "bar"), @Union(Range(0, 10))

Yet it still doesn't meet my standard of what I think is good for the ecosystem.

I already have a working implementation of the analyzer approach, by using an analyzer_plugin. But I voluntarily didn't release it because of the cast/generic issue.

My concern here is, I'm really worried about offering a subpar solution to this problem, especially when it comes from Google. If people start massively adopting the proposed workarounds, I'm afraid that this would lead to a long-term degradation of the ecosystem.
One of the main reasons I like Dart over Typescript personally is because 42 as String throws in Dart. I've lost many hours in TS due to a package saying a function returns something when it does not.

I don't want a package to expose:

Union2<String, bool> fn();

Only to realise that due to a bug in the package, in some cases, fn returns neither a String nor a bool. This could easily happen if the package forgot to check .isValid somehow.

This would be a big source of trust issue in the ecosystem

It would be useful if we could determine for every generic function whether or not it is parametric in each of its type arguments (basically: it doesn't perform run-time operations based on that type argument X, that is, it doesn't do e is X, e as List, switch (e) { case X(): ... }, [], etc.).

I don't think that's enough, because of downcasts. Consider:

class Example<T> {
  T? value;
}

Example<Object?> example = Example<Union2<String, bool>>(); // Valid downcast
example.value = 42; // This code should throw but in fact will succeed.

There are no casts involved, but the code still fails to throw an error when it should. This would be quite difficult to guard against

eernstg commented 7 months ago

I don't think that's enough, because of downcasts.

A downcast is certainly also a mechanism that relies on the run-time value of a type, that is, code that performs a downcast is not parametric.

Given void foo<X, Y>() { ... }, even X == Y is non-parametric, in spite of the fact that it doesn't reveal anything about the actual values of X or Y.

In other words, parametricity is a strong constraint, especially in a language like Dart where reliance on the run-time value of a type can arise in so many ways.

As an aside, here's a positive spin on that: Look how many cool things Dart can do because it has reified type arguments! All those non-parametric usages would just be impossible (or completely type-unsafe, like an Unchecked Cast in Java) if we did not have this kind of reification. The other side of the coin is that it does cause real work to be performed at run time: Space must be allocated to hold the value of all those type parameters, time must be spent computing and passing them, and so on. Extension types allow us to get a kind of type arguments that are guaranteed to be eliminated in specific ways (for example, UnionN type parameters have absolutely no representation in the given value).

That's the whole point of extension types: We do not want to pay for a wrapper object, and this implies that the run-time value of the extension type must be the representation type (there's no way a String can confirm or deny that it has or has had the type FancyString if there is no run-time representation of FancyString whatsoever).

However, if a union type like Union2<int, String> is the declared type of a function parameter, or a return type of a method or function, or the declared type of an instance variable (or any other kind of variable for that matter) then every statically checked usage of that declaration will use the union type for the static checks, and this means that you can monitor the data transfers safely. (Like a proof by induction: As long as you're good, any number of steps from there will preserve the "goodness".)

What the types in extension_type_unions can promise to do for you is to maintain consistency for static checks ("preserving the goodness" of the situation). Judicious placement of extension types can ensure that all checks are static checks. Those two things together will give you some real support for maintaining properties based on compile-time checks alone, with a zero cost at run time.

It is true that you can destroy this kind of "goodness" by introducing a dynamic type operation (e.g., a cast). This might be perfectly OK for a FancyString (because every String is an OK FancyString), and less so with Union2<int, String> (because it isn't true that every Object? is an OK Union2<int, String>).

But the assumption is that the ability to maintain a discipline based on static type checks is worth more than nothing, especially when it costs nothing at run time. Besides, you can always provide manually coded improvements, like the isValid getter on the UnionN types. If the static type of x is Union2<int, X> then x.isValid will indeed check that x has type int, or x has type X (note that it is not a problem that a type parameter is used as an argument to UnionN).

By the way, it wouldn't be hard to create a variant of extension_type_unions that makes the representation type yet another type variable:

extension type Union2<X, X1 extends X, X2 extends X>._(X value) {
  Union2.in1(X1 this.value);
  Union2.in2(X2 this.value);

  bool get isValid => value is X1 || value is X2;

  X1? get as1OrNull => value is X1 ? value as X1 : null;
  X2? get as2OrNull => value is X2 ? value as X2 : null;

  X1 get as1 => value as X1;
  X2 get as2 => value as X2;

  bool get is1 => value is X1;
  bool get is2 => value is X2;

  R split<R>(R Function(X1) on1, R Function(X2) on2) {
    var v = value;
    if (v is X1) return on1(v);
    if (v is X2) return on2(v);
    throw InvalidUnionTypeException(
      "Union2<$X1, $X2>",
      value,
    );
  }

  R? splitNamed<R>({
    R Function(X1)? on1,
    R Function(X2)? on2,
    R Function(Object?)? onOther,
    R Function(Object?)? onInvalid,
  }) {
    var v = value;
    if (v is X1) return (on1 ?? onOther)?.call(v);
    if (v is X2) return (on2 ?? onOther)?.call(v);
    if (onInvalid != null) return onInvalid(v);
    throw InvalidUnionTypeException(
      "Union2<$X1, $X2>",
      value,
    );
  }
}

This means that every union type is represented by a common supertype (X1 extends X and X2 extends X enforces this), and the actual value will be guaranteed to be a subtype of that common supertype (Union2<Object, int, String> works exactly like the type Object at run time).

I didn't do that, because that's yet another chore that users must think about when they are using these union types. In any case, it's an option which is easily expressible if anybody wants it.

Finally, let's return to the example:

import 'package:extension_type_unions/extension_type_unions.dart';

class Example<T> {
  T? value; // Assignments to `value` are not parametric in `T`.
}

void main() {
  Example<Object?> example = Example<Union2<String, bool>>(); // Upcast, accepted.
  example.value = 42; // The dynamic check uses the representation type and succeeds.
}

This is again an example where a type parameter is used in a way which is not parametric: The setter which is implicitly induced by the declaration T? value; is using the value of the type parameter T at run time in order to ensure that the value which is being assigned is indeed of type T. As always, the extension type Union2<String, bool> is erased to the representation type Object?, and this means that the type check just succeeds every time.

On the other hand, if you avoid the abstraction step which is implied by using an extension type as the value of an actual type argument then you can get the expected type check statically:

import 'package:extension_type_unions/extension_type_unions.dart';

class Example {
  Union2<String, bool>? value;
}

void main() {
  var x = Example();
  // x.value = 42; // Compile-time error, also for `42.u21` and `42.u22`.
  x.value = 'foo'.u21; // OK.
  x.value = true.u22; // OK.
  x.value = null; // OK.
}
rrousselGit commented 7 months ago

By the way, it wouldn't be hard to create a variant of extension_type_unions that makes the representation type yet another type variable:

For what it's worth, extension methods cover this nicely. We can do:

extension<T> on Union2<T, T> {
  T get value;
}

Union2<int, double> union;
union.value; // typed as "num"

With this, Users don't need to specify a generic argument for that. That's a pattern I've used in a previous experiment based around functions (union)

https://github.com/rrousselGit/union/blob/3137eaae8d0bb14f973a351cef40512e4b404b3c/lib/src/union.dart#L437

eernstg commented 7 months ago

For what it's worth, extension methods cover this nicely.

That's cool!

import 'package:extension_type_unions/extension_type_unions.dart';

extension<T> on Union2<T, T> {
  T get lubValue => this as T; // Can't use the name `value`, `Union2` has that already.
}

class A {}
class B1 implements A {}
class B2 implements A {}

void main() {
  Union2<int, double> union = 20.u21;
  union.lubValue.expectStaticType<Exactly<num>>; // OK.

  Union2<B1, B2> classUnion = B2().u22;
  classUnion.lubValue.expectStaticType<Exactly<A>>; // OK.
}

typedef Exactly<X> = X Function(X);

extension<X> on X {
  X expectStaticType<Y extends Exactly<X>>() => this;
}

However, this still doesn't equip the UnionN type itself with any reification of the type variables, which means that it is still possible to have a value whose static type is UnionN<...> whose value can have absolutely any type.

For example, we could add the following to main above:

  Union2<Never, Symbol> badUnion = false as Union2<Never, Symbol>;
  badUnion.lubValue; // Throws.

It is possible to determine that the union value is invalid because it throws when we run lubValue, but that's just a weaker version of the test performed by isValid (isValid will check directly that the value has a type which is a subtype of at least one of the type arguments, lubValue only fails if the value doesn't have the standard upper bound type of the type arguments). For example, Union2<B1, int?> will have a standard upper bound of Object?, which means that lubValue will happily return <String>[] if that's the actual value, but isValid will return false.

In contrast, a Union2 with the extra type argument X (as defined here) will provide run-time checks based on X, which can be a lot more specific:

const something = true;

T fn<T>() => something as T;

void main() {
  var x = fn<Union2<num, int, double>>(); // Throws.
}

This means that we can use the extra type argument to specify a type which is reified at runtime, and which serves as an approximation of the union type itself. This extra type argument must be an upper bound of the arguments that are the operands of the union. In this case we use num as the upper bound, which is actually the best possible bounding of int and double. However, we could choose whatever we want as long as it is a supertype of all the "unioned" types.

So this does matter at run time. It's not nice to have to invent and specify that extra type argument to UnionN, but it might be sufficiently useful to make some developers prefer that approach. Who knows? ;-)

marcglasberg commented 6 months ago

In my day to day work this is one of the features I miss the most in Dart. I understand it would be a substantial undertaking, but it would be really nice to have this.

iapicca commented 5 months ago

another example of use case where this feature would be valuable https://github.com/pocketbase/dart-sdk/blob/master/lib/src/auth_store.dart#L9

#

does this issue being absent from the language funnel mean that is not under the radar?

Willian199 commented 5 months ago

It would be nice to have this functionality. Mainly for defining function types, like.

T exec(T Function() | T Function(P) run, [P args]);

MyClass c = exec(MyClass.new);
a14n commented 4 months ago

A place where union types would be super useful is js-interop. In the JS world a lot of APIs are using them. For example in Google Maps JavaScript API there are a lot of unions that leads to have a lot of JSAny as parameter type and return type. So you loose type hints.

  external void setCenter(JSAny /*LatLng|LatLngLiteral*/ latlng);
lrhn commented 4 months ago

Would it make sense to implement the JS API using two Dart methods for the same JS method? It would be a more Dart-styled API, rather than try to implement the JS API directly in Dart.

Something like:

@JS(...)
class Whatnot {
  @JS()
  external void setCenter(LatLng latLng);
  @JS("setCenter")
  external void setCenterJson(Map<String, Object?> latLngJson);
}

(I won't expect Dart to statically check that the map has lat and lng properties with double values.)

Or another example:

@JS('Date')
extension type JSDate._(JSObject _) {
  @JS('Date')
  external JSDate(int year, Month month,
      [int day, int hour, int minute, int second, int milliseconds]);

  @JS('Date')
  external JSDate.fromMS(int msSinceEpoch);

  @JS('Date')
  external JSDate.fromString(String dateText);

  // ...
}

That is: The Dart way to have overloading is to have different names, the JS is to dynamically inspect the arguments. A Dart adaption of a JS API could have multiple names for APIs that accept multiple distinct argument signatures. (Doesn't scale to a function taking ten "String|int" arguments, obviously.)

jodinathan commented 4 months ago

@lrhn the problem is that you need to manually give names to stuff. Basic JS world is already huge

a14n commented 4 months ago

@lrhn having several method names could work for parameters (if the number of combination is not huge) but the problem for return type is still there. When you have control on the types in the union you can add a parent type XxxOrYyy and make Xxx and Yyy implement XxxOrYyy. But it become quickly a mess with cross dependencies between libs. Moreover this doesn't work with types outside your package (eg. JSString)

mraleph commented 4 months ago

@a14n I think this is a great example which shows that naively translating APIs does not make sense.

Consider for example this type LatLng|LatLngLiteral - it is introduced so that at call site developers could pass both an instance of LatLng and a literal {lat: x, lang: y} (which will be converted to an instance of LatLng under the hood anyway). This type does not actually make sense in Dart because you can't use Map literal as LatLngLiteral anyway - so there is no convenience gained at the caller from allowing to pass it.

So you can translate this API without union types to Dart:

external void setCenter(LatLng latlng);

This makes API clean and Dart-y.

a14n commented 4 months ago

For sure that's what I did (hide LatLngLiteral) on the current version of google_maps. However there are cases where it's not that obvious. Take for example DirectionsRequest where direction and origin can be string|LatLng|Place|LatLngLiteral. Even if we forget LatLngLiteral the combinatory leads to 9 constructors. And I'm not sure what to do for the types of accessors.

extension type DirectionsRequest._(JSObject _) implements JSObject {
  external DirectionsRequest({
    JSAny /*string|LatLng|Place|LatLngLiteral*/ destination,
    JSAny /*string|LatLng|Place|LatLngLiteral*/ origin,
    TravelMode travelMode,
    bool? avoidFerries,
    bool? avoidHighways,
    bool? avoidTolls,
    DrivingOptions? drivingOptions,
    String? language,
    bool? optimizeWaypoints,
    bool? provideRouteAlternatives,
    String? region,
    TransitOptions? transitOptions,
    UnitSystem? unitSystem,
    JSArray<DirectionsWaypoint>? waypoints,
  });
  external JSAny /*string|LatLng|Place|LatLngLiteral*/ destination;
  external JSAny /*string|LatLng|Place|LatLngLiteral*/ origin;
  external TravelMode travelMode;
  external bool? avoidFerries;
  external bool? avoidHighways;
  external bool? avoidTolls;
  external DrivingOptions? drivingOptions;
  external String? language;
  external bool? optimizeWaypoints;
  external bool? provideRouteAlternatives;
  external String? region;
  external TransitOptions? transitOptions;
  external UnitSystem? unitSystem;
  external JSArray<DirectionsWaypoint>? waypoints;
}

What would you do here?

lrhn commented 4 months ago

I'd drop string and LatLngLiteral. Then try to see if one of Place or LatLng can be a supertype of the other, if not then whether I can introduce a shared supertype, like Position. If still not, see if one can be converted into the other (LatLng have a toPlace() method, or vice versa).

If none of those are possible, I'd start considering why the two types are even related.

In this case, I think it'd be a Place parameter, since a Place can be created from a LatLng. All the others can be converted to a Place, allowing them directly is just the JS way of allowing the caller to omit that conversation.

(Would also give one or both of them a fromString constructor).

Doesn't work for the accessors. I'd consider writing a Dart wrapper to return a Place,

  @JS("destination")
  external JSObject _destination;
  Place get destination {
    var destination = _destination; 
    if (destination is JSString) return Place.parse(destination.toDart);
    // how to detect other JS types ...
    return place;
  }
  set destination(Place place) { _destination = place; }

That is: Make the Dart API different from the JS API. Yest that takes writing. Creating a Dart API does, when one starts with something that is not a good Dart API.

You'll have to decide whether the goal is to provide a semi-transparent Dart interface to the JS API, or a good Dart API for the same functionality. The latter takes Dart design work. The former isn't always type-safe today.

mraleph commented 4 months ago

However there are cases where it's not that obvious. Take for example DirectionsRequest where direction and origin can be string|LatLng|Place|LatLngLiteral.

Checking Place reveals that it is basically a combination of all other options. So this type can be reduced from string|LatLng|Place|LatLngLiteral to Place - again making things simpler and cleaner. There is no need to ask yourself "what does string mean as a destination" - it's always a Place and when creating a Place the meaning of string is obvious (because you can have a corresponding constructor Place.query(String query)).

extension type DirectionsRequest._(JSObject _) implements JSObject {
  external DirectionsRequest({
    Place destination,
    Place origin,
    // ...
  });
  external Place destination;
  external Place origin;
}

I think this just continues to show that union types are almost always a symptom of poor API design - an API that tries to cater too much to "make call-sites a bit easier to write" kind of thinking.

Wdestroier commented 4 months ago

Last week I rewrote a HTML/CSS/JS app in Flutter and I spent way too much time modeling unions, because the client and server were sharing code.

I'd drop string and LatLngLiteral.

Aren't you making the API less easy to use? 'Street ABC' can be a valid destination argument and LatLngLiteral can be a record typedef. Other API changes would be required, because the code already exists in Google Maps docs, and we would end up with a not more convenient API (less convenient?).

I can introduce a shared supertype, like Position.

Isn't the compiler's job to avoid this complexity? By introducing an anonymous supertype (the union).

I'd start considering why the two types are even related.

Rewriting the API is a burden, but who is going to maintain the code later? Keeping track of the changes as both APIs diverge is yet another challenge.

lrhn commented 4 months ago

Aren't you making the API less easy to use?

Yes. Or at least less convenient ("easy to use" can be easy to write code for, and easy to use correctly, which is not always the same thing).

There is no trivial mapping of a JavaScript or TypeScript API to Dart. The type systems are so different that an automatic mapping into Dart is always going to be dynamically typed.

If the goal is to have an automatic conversion from JS/TS APIs to Dart APIs with the same behavior and same type safety, then it's currently very much a square peg in a round hole-problem. You have to either lose typing or add more methods.

The question here is what are we willing to add to Dart to make JS interop easier and more direct?

Do we want to add some (but not all) of the type features of TypeScript to Dart, so that JS APIs can be approximated more closely by Dart types. And if so, how many. This issue is about union types, but it's not just union of Dart types, it's also unions of value types like "true" | "false". That's two Dart features, not one.

If there are sufficiently good workarounds for the API mismathching, then matching JS APIs directly is won't count as strongly for adding new features. What we're looking for here is such a workaround. And the question becomes whether the workaround is sufficently good. (It's probably not, it's just how I'd do the API if I wrote it in Dart.)

We're definitely not going to add features to Dart only to support JS interop. That's too specialized. A language feature should have some possible general use, and should be able to be defined independendtly of JS interop, because the feature is there for people who never interoperate with JS too.

If the goal is to expose precisely the JS API with the TS type system, then nothing short of the TS type system can do that. The set of features that we would need to add, to get full parity with TypeScript types include:

We can do union types, probably with intersection types, but it's not something we strongly want for Dart itself. Will it be enough for JS interop, or will we just start needing literal types then?

tatumizer commented 4 months ago

Unrelated to places and latitudes: The syntax of union types should probably be like (String | int) in parentheses - otherwise, we won't be able to express a nullable type (String | int)? But as soon as we introduce the syntax (T1 | T2 | T3), it becomes evident that the difference between union types and record types is the same as between OR and AND. This observation alone is an argument in favor of union types.

Union types are convenient. Instead of defining N constructors in a class A: A.fromB, A.fromC etc, we can define just one A((B | C))

zigzag312 commented 4 months ago

Checking Place reveals that it is basically a combination of all other options. So this type can be reduced from string|LatLng|Place|LatLngLiteral to Place - again making things simpler and cleaner. There is no need to ask yourself "what does string mean as a destination" - it's always a Place and when creating a Place the meaning of string is obvious (because you can have a corresponding constructor Place.query(String query)).

The Place interface lacks sound null safety. Every property can be null, which logically is an invalid place. This is still poor design.

Additionally, if API only accepts a Place type, the user has to manually create a Place type from other types, as such conversion can't happen implicitly in Dart. This places an unnecessary burden on the developer.

With ADT this can be straightforwardly modelled to be always valid and easy to use.

I think this just continues to show that union types are almost always a symptom of poor API design - an API that tries to cater too much to "make call-sites a bit easier to write" kind of thinking.

I can't say I agree with that. They map to logic nicely. Trying to model a similar concept with classical OOP is usually clumsy and often looks like a case of, "if you have a hammer, everything looks like a nail".

Wdestroier commented 4 months ago

Can literal types be separated from this issue? I believe it's conflicting opinions, I personally don't want it, and it's not mentioned in the issue description.

I don't think it's desired to have many TS features in Dart. To narrow down, the issue description mentions union types, enhanced switch (which we already have?) and recursive typedefs (should be issue #3714?). Intersection types are very related to this issue, but they might be too much to ship with union types (maybe union-types-later?). I wonder if the record spreading feature is related to intersection types.

I have a different usage example for Flutter. Currently, the RichText widget can have a TextSpan with a single child or multiple children. The code is so boilerpla-ty, but it can look much more readable if we change List<InlineSpan> to List<InlineSpan | String> and allow 'text' instead of const TextSpan(text: 'text'). I made an example, but I don't want to pollute the chat: https://pastebin.com/raw/Bwh30rPD.

jodinathan commented 4 months ago

I'd drop string and LatLngLiteral. Then try to see if one of Place or LatLng can be a supertype of the other, if not then whether I can introduce a shared supertype, like Position.

the typings package already does most of this job. It tries to clean up the JS/TS API to the most complex object when it encounters unions or method overloads.

the package does most of the work that you need to do when you use Darts default JS Interop, so you can just use it instead.

a help to make it a little bit easier to use would be very nice tho

TekExplorer commented 1 month ago

I normally wouldn't want sum|union types

The thing is, we have to. Its the only way to non-breakingly strongly type places that use dynamic to accept unrelated types (like for Json)

There is no way to fix that without some way to relate types together.

That means, we need union types, or rust-like traits (since you can implement Foo for Bar, you can effectively create a shared super type)

The benefit of unions is that they are anonymous, and you can even promote it through type checks, as A|B|C could become A|B if we have a guard against C like we do for null.

(The benefit of trait-likes is that we don't actually need a new kind of type - just a way to declare implementers outside our control. The main issue for this though, is that (Object) Foo is Bar might not be aware of that implementation, while a union|sum type would just check across its options)

nate-thegrate commented 1 month ago

flutter.dev/go/using-widget-state-property describes a messy problem that the Flutter team is facing with both Material and Cupertino widgets.

If union types were a Dart language feature, they would enable an elegant solution.

mateusfccp commented 1 month ago

flutter.dev/go/using-widget-state-property describes a messy problem that the Flutter team is facing with both Material and Cupertino widgets.

If union types were a Dart language feature, they would enable an elegant solution.

Can you give an example? I'm not aware of a use case that could be solved with untagged unions but not by tagged unions.

Or is it just an ergonomic problem?

nate-thegrate commented 1 month ago

@mateusfccp if you're aware of a way to obtain the functionality proposed in https://github.com/flutter/flutter/issues/154197 without union types, please let me know 🥺



Edit:

Or is it just an ergonomic problem?

Yes indeed 💯

lrhn commented 1 month ago

One approach would be implicit coercions.

If we had "implicit constructors", possibly extension constructors:

static extension Color2Property on WidgetProperty<Color> {
  implicit factory WidgetProperty.fromColor(Color color) =
     ColorWidgetProperty;
}

static extension Property2Color on Color {
  implicit factory Color.fromProperty(
      WidgetProperty<Color> color) => color.value;
}

Then you could automatically convert between Color and WidgetProperty<Color> as needed.

nate-thegrate commented 1 month ago

Implicit constructors would be awesome; I believe they'd work for Property<T> without a need for static extensions:

sealed class Property<T> {
  implicit const factory Property.fromValue(T value) = ValueProperty<T>;

  T resolve(Set<WidgetState> states);
}

class ValueProperty<T> implements Property<T> {
  const ValueProperty(this.value);
  final T value;

  @override
  T resolve(Set<WidgetState> states) => value;
}

abstract class WidgetStateProperty<T> implements Property<T> {
  // no changes necessary for existing WidgetStateProperty API
}

And then any parameter with the Property<T> type would accept either T or WidgetStateProperty<T> objects as arguments.

Overall, I like the idea of union types more (probably much more intuitive for some people, and no additional keyword is needed), but either one would work beautifully.