Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Sum/union types and type matching #83

Open
GregorySech opened this issue Nov 5, 2018 · 133 comments
Open

Sum/union types and type matching #83

GregorySech opened this issue Nov 5, 2018 · 133 comments
Labels
request Requests to resolve a particular developer problem union-types

Comments

@GregorySech
Copy link

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
Copy link
Member

eernstg commented Nov 5, 2018

First note that it should be possible to categorize this issue as a request or a feature. The request would articulate a non-trivial and practically relevant shortcoming in terms of software engineering that would justify considerations about adding new features to Dart. A feature description is a response to a request, that is, every feature under consideration should be grounded in a need for the feature in terms of an underlying software engineering problem, it should never just be "I want feature X just because that's a cool concept to have in a language". It may well be a non-trivial exercise to come up with such a request, and it may not necessarily have just one feature as its "corresponding solution", but that's a good thing, not a bug. ;-)

This issue is clearly a feature issue, specifically promoting a language construct (or a family of closely related ones). So we need a request issue which describes a software engineering problem more broadly, such that some notion of sum types would be an appropriate response to that request.

That said, requests for sum types are of course not a new thing. A couple of pointers: https://github.com/dart-lang/sdk/issues/4938 has been the main location for discussing union types, and the feature discussed in this issue may or may not be covered under that umbrella.

In particular, this proposal may or may not assume tags. If tags are assumed, typedef JSON would be similar to a class hierarchy like this:

class Json {}
class JsonMap implements Json {
  Map<String, Json> value;
}
class JsonList implements Json {
  List<Json> value;
}
class JsonLiteral implements Json {
  ... // Whatever is needed to be such a literal.
}

This would provide run-time support for discriminating all cases, because anything of type "Map<String, JSON>" would actually be wrapped in an object of type JsonMap, and a simple is JsonMap test would reveal whether we're looking at such an object. In a functional language you'd declare all cases in a single construct, and it would thus be easy to detect at compile time whether any given deconstruction is exhaustive. With an OO class hierarchy you'd need a concept like a 'sealed' class hierarchy in order to get a similar statically known exhaustiveness guarantee. (OCaml supports extensible variant types, and they require an explicit 'default' case in every pattern match, so we got the same trade-off there, albeit with the sealed design as the default.) In any case, tagged unions is the easy way to do this, and it may be somewhat costly at run time, because we have to create and navigate a lot of "wrapper" objects.

However, if we assume untagged unions then we cannot expect run-time support for O(1) discrimination. For instance, a recursive untagged union type could be defined like this (assuming that Dart is extended to support recursive union types):

typedef NestedIntList = int | List<NestedIntList>;

If we're simply considering the type NestedIntList to be the (infinite) union of int and List<int> and List<List<int>> and so on, then it would be possible for a variable of type NestedIntList to refer to an instance of int, and it could also refer to an instance of List<int>, etc. There would not be any wrapper objects, and we would have no support to tell in time O(1) that any given object (say, a List<int>) is in fact of type NestedIntList. This means that the expression e is NestedIntList would need to perform an unbounded number of type checks, and it would in principle be unable to stop at any finite level (if it's looking at a List<List<.....<String>.....>> it wouldn't be able to say "no" before it hits the String, which could be nested any number of levels.)

So we need to clarify whether sum types are intended to be similar to SML algebraic data types (that is, tagged unions), or similar to TypeScript union types (untagged unions).

We should note that TypeScript does not support dynamic type tests (and I do not believe the user-defined workaround is a relevant replacement in Dart). Dart will remain sound in the sense that the obvious heap invariant ("a variable of type T refers to an entity of type T") is maintained at all times, based on a combination of static and dynamic checks. So we don't have the option to ignore that certain type tests / type checks are expensive.

@GregorySech GregorySech changed the title Sum types and type matching [feature] [request] Sum types and type matching Nov 5, 2018
@GregorySech
Copy link
Author

Sorry for the missing tag, it seems I'm unable to edit them, I'll propose them in the title while I'm looking for documentation on how to do it.

Thank you for the extensive explanation, I didn't dive deep enough into the sdk issues and I apologize.

To answer the specification question: I was not aware of union types in TypeScript as I've not used it yet so I intended them to be as similar as possible to SML data types however I was ignoring the implementation details of such feature.
I'll start studying the mentioned concept in the next days.

@eernstg
Copy link
Member

eernstg commented Nov 5, 2018

@GregorySech, first: Thanks for your input! I should have said that. Second: I guess there's some permission management in place which restricts the labeling; I'm sure those labels will be adjusted as needed, so don't worry. So the main point is simply that it would be really useful in the discussion about a topic like this to have a characterization of the underlying software engineering problem (without any reference to any particular language construct that might solve the problem), and it would be helpful if you could capture that underlying software engineering challenge for which such a feature as 'sum types' would be a relevant solution.

@yjbanov
Copy link

yjbanov commented Nov 5, 2018

@eernstg

if we assume untagged unions ... There would not be any wrapper objects

Would this still be true if Dart adds unboxed types in the future? For example, imagine in the future non-nullable int is directly represented by 64-bit integer and its value is inlined into variables and fields. Now, what happens when you define a variable of type int | String?

@mdebbar
Copy link

mdebbar commented Nov 6, 2018

FutureOr<T> is a real use case that is hardcoded in Dart today and would be solved neatly when Dart introduces union types. I agree they don't have to match all the specifics of other languages.

@eernstg
Copy link
Member

eernstg commented Nov 6, 2018

.. [no] wrapper objects
Would this still be true if Dart adds unboxed types[?]

If Dart is extended to support a notion of unboxed types I would expect such instances to be compatible enough with the rest of Dart to allow a reference of type Object to refer to them, and that would require support for some notion of boxing.

You may or may not want to say that a boxed entity of type T is a wrapper object containing an unboxed entity of type T, but we're still talking about the same type (with different representations at run-time).

With a wrapper which is used as the representation of a tagged union type there is a transition from one type to another type when the wrapper is added. An example in SML would be the following:

datatype IntString = INT of int | STRING of string;
fun f (INT i) = "An integer"
  | f (STRING s) = s;
val v1 = f (INT 42);
val v2 = f (STRING "24");

Here, INT 42 is of type IntString, but a plain 42 is of type int, so there's a difference between "just being an int" and "being an int which is one of the cases in the union of int and string", and that difference amounts to an entire wrapper object. It's a compile-time error to call f with an argument of type int or string, they must be wrapped first, hence f (INT 42). You need to create that wrapper object explicitly (OK, we could try to infer that, but that's another tarpit ;-), and in return you can test for it at run time (so f knows which case to run when you call it). An extension of Dart with tagged unions would presumably have a lot of the same properties.

An extension of Dart with untagged unions would be different in many ways. I just wanted to point out the fact that this distinction matters, so we shouldn't discuss union types (or sum types) without knowing which kind we're talking about. ;-)

@eernstg
Copy link
Member

eernstg commented Nov 6, 2018

FutureOr<T> is a real use case that is hardcoded in Dart today

Right. We knew from day one that FutureOr could be used as a playground for getting to know exactly how that special case of union types would affect the whole language.

@yjbanov
Copy link

yjbanov commented Nov 6, 2018

@eernstg Thanks for the explanation! I misread your "wrapper object" as boxing. Yes, something like tagged unions does not require boxing. For example, Rust's and Swift's enum types are essentially tagged unions, and they do not box them.

@mit-mit mit-mit changed the title [feature] [request] Sum types and type matching Sum types and type matching Nov 6, 2018
@mit-mit mit-mit added the request Requests to resolve a particular developer problem label Nov 6, 2018
@eernstg
Copy link
Member

eernstg commented Nov 12, 2018

@yjbanov wrote:

For example, Rust's and Swift's enum types are essentially tagged unions,
and they do not box them.

Right, but I think the situation in Rust and Swift is slightly different from the situation in Dart.

In general, a range of memory cells does not have to be a full-fledged heap entity in order to be able to contain a primitive value (like an integer or a pointer to an immutable string (Rust: &str)) as well as a reference to an associated value (Swift) or a tuple or struct with no subtype relations (Rust) such that the size of the whole thing is known statically as soon as we know which "case" of the enum type we are working on, and we can compute how much space is needed in order to be able to store any of the declared cases.

But at this point we do not have general support for non-heap entities in Dart of any other size than that of a primitive, so if we want to support that kind of composite entity in Dart I think we will have to keep them in the heap (that is, we need to box them). That could still be useful, however, because an enum with an associated value is similar to a tagged union where the enum value itself is the tag.

The ability to work on unboxed entities of arbitrary size would be an orthogonal feature in Dart, and we would be able to introduce support for this separately (and sort out how they would work in dynamic code), and then we could use it for entities of any kind, including enum-with-associated-value.

@stt106
Copy link

stt106 commented Nov 15, 2018

If this is implemented + non-nullable types, then Dart will be the best language ever!
Sum types (and records) are incredibly powerful constructs to model domains.

@GregorySech
Copy link
Author

Let's say that Dart had a non-nullable type ancestor called Thing, Object might be Thing | Null. I'm still unaware of implementation implications for this features to work together but the message I'm trying to convey is that this might save a lot of refactoring if non-null is implemented.

@burakemir
Copy link

burakemir commented Dec 16, 2018

Hey Erik, since you asked for "software engineering arguments" further up, I will bite and state why algebraic data types and pattern matching is useful from a software engineering perspective.

The simple truth is:
1 - representing structured data so that it can be inspected from the outside isn't what objects were made for, and
2 - yet most data in real world applications has to deal with structured data (it started with tuples, records, relations and the "OO-SQL impedance mismatch").

Today, NULL/optional, enums etc can all provide only half-hearted solutions to case distinction. The paper "Matching Objects with Patterns" (Emir, Odersky, Williams) and also my thesis "Object-oriented pattern matching" discusses the benefits and drawbacks of the object-oriented and other encodings of algebraic data types. The "type-case" feature is also discussed, but not a notion of untagged sum type.

I'd be an advocate for the tried and tested algebraic data types which is a way to represent tuples, sum types, "value classes" and a whole bunch of other programming situations that all help with modeling and dealing with structured data. Algebraic data types would reflect the symmetry between product types and sum (=co-product) types, as known from category theory and its manifestations in functional programming in Haskell, Scala, ocaml aka Reason etc. What better software engineering argument than "it works" can there be? : ) Let me know if this is the appropriate issue or I should file a new one.

(updated, typo)

@eernstg
Copy link
Member

eernstg commented Dec 21, 2018

(Hello Burak, long time no c, hope everything is well!)

data [..] inspected from the outside isn't what objects were made for

Agreed, I frequently mention that argument as well. And I'd prefer to widen the gap, in the sense that I would like to have one kind of entity which is optimized from access-from-outside ("data") and another one which is optimized for encapsulation and abstraction ("objects").

I'd be an advocate for the tried and tested algebraic data types

I think they would fit rather nicely into Dart if we build them on the ideas around enumerated type with "associated values", similarly to Swift enumerations (but we'd box them first, to get started, and then some unboxed versions could be supported later on where possible).

Considering the mechanisms out there in languages that are somewhat similar to Dart, the notion of an enum class with associated "values" comes to mind here. I mentioned enums and their relationship with inline allocation up here, but I did not mention how similar they are to algebraic data types.

// Emulate the SML type `datatype 'a List = Nil | Cons of ('a * 'a List)`
// using names that are Darty, based on an extension of the Dart `enum`
// mechanism with associated data.

enum EnumList<X> {
  Nil,
  Cons(X head, EnumList<X> tail),
}

int length<X>(EnumList<X> l) {
  switch (l) {
    case Nil: return 0;
    case Cons(var head, var tail): return 1 + length(tail);
  }
}

main() {
  MyList<int> l = MyList.Cons(42, MyList.Nil);
  print(length(l));
}

The enum declaration would then be syntactic sugar for a set of class declarations, one declaration similar to the one which is the specified meaning of the enum class today, and one subclass for each value declared in the enum, carrying the declared associated data in final fields.

We'd want generated code to do all the usual stuff for value-like entities (starting with operator == and hashCode, probably including support for some notion of copy-with-new-values, etc).

There will be lots of proposals for better syntax (e.g., for introduction of patterns introducing fresh names to bind as well as fixed values to check for), but the main point is that the notion of an enum mechanism with associated values would be a quite natural way in Dart to express a lot of the properties that algebraic data types are known to have in other languages.

@lukepighetti
Copy link

lukepighetti commented Dec 30, 2018

There are a lot of people in this thread with much more formal computer science education than I possess. My argument will be thus be quite simple. Union types in TypeScript make my life easy as a developer. Not having them in Dart makes my life considerably worse as a developer. That's all I have to share as a consumer of Dart, and quite frankly, that's all the experience I need to have an opinion on whether we should have them or not. I won't be able to provide anything of great intelligence to this conversation, just my opinion based on experience of building things with Dart. How that gets implemented is well outside of my wheelhouse, I'll defer to the software engineers to use (or not use) my feedback. 😄

@ds84182
Copy link

ds84182 commented May 23, 2019

@lifranc Please don't come to language repository just to leave comments that do not add to the conversation. I'm watching the repository so I can keep track of all the conversations happening here... constructive conversations. Your comment is redundant, and unconstructive.

@dart-lang dart-lang deleted a comment May 23, 2019
@truongsinh
Copy link

https://github.com/spebbe/dartz from @spebbe can be another use case that can make use of union types

dartz was originally written for Dart 1, whose weak type system could be abused to simulate higher-kinded types. Dart 2 has a much stricter type system, making it impossible to pull off the same trick without regressing to fully dynamic typing.

Also, https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/send API, when implemented in Dart, is quite tricky, either we have to have send(dynamic data), which loses type-safety, or sendString(String data) + sendBytes(Uint8List data) + sendBlob(Blob data) etc

@wkornewald
Copy link

wkornewald commented Sep 13, 2019

I'd be really disappointed if Dart added ADTs / enum types instead of real union types. The latter are much more ergonomic, flexible and capture exactly the essence of what the developer wants to express: Different types are possible. This essence is captured with minimal syntax: A | B | C | D. No tags. It's also flexible in that you can pass a subset like A | C or B | D and it will still work correctly. This is what we really want: No limitations. Minimal syntax. Just the essence.

It's always annoying if you can't restrict to a subset and always have to deal with the whole enum and match against all of cases and raise an exception for the disallowed cases at runtime instead of having them disallowed at compile-time. It also makes working with types from different packages annoying because you can't easily mix them (esp. individual cases of the enum). This happens often enough to be an annoyance in Swift, Kotlin and other popular languages.

After all, we're doing this because we want compile-time checks. That's the whole point of union types. If you've ever worked with TypeScript's unions, as an end-developer (vs. the PL designer), you really don't want to go back to something as inflexible and verbose and unnecessarily complicated as enums. Yeah, enums might be easier to optimize, but I believe we can still find a "good enough" optimization for union types. The use-cases for unions in Dart would be different than those in e.g. Haskell because we mostly use classes and I believe the performance is less of an issue here. For example, we don't represent our lists as Cons | Nil. We already have a List class for that.

For more context, please take a look at my quick proposal from a year ago: https://github.com/dart-lang/sdk/issues/4938#issuecomment-396005754

@werediver
Copy link

@wkornewald The untagged unions concept sounds nice, but it doesn't sound like a replacement to the algebraic data types. They can totally coexist and serve different needs.

@wkornewald
Copy link

@werediver Sure they could coexist, but what is missing from union types that is absolutely necessary from a practical point of view? Do you have a code example?

@werediver
Copy link

werediver commented Sep 13, 2019

what is missing from union types that is absolutely necessary from a practical point of view?

That is quite easy to answer: extra semantics.

data Message
  = Info    String
  | Warning String
  | Error   String

Which would be possible with sealed classes and untagged unions together, but that would be noticeably less succinct.

@rrousselGit
Copy link

rrousselGit commented Sep 13, 2019

I definitely agree that union types expressed as A | B | C instead of something like sealed classes are a better IMO.

Technically speaking, sealed classes don't bring any new feature, they just reduce the boilerplate.

For example, sealed classes cannot be used to represent JSON since they are a new type.
But unions can as they reuse existing types:

typedef Json = String | num | List<Json> | Map<String, Json>

Instead of such sealed class:

sealed class LoginResponse {
    data class Success(val authToken) : LoginResponse()
    object InvalidCredentials : LoginResponse()
    object NoNetwork : LoginResponse()
    data class UnexpectedException(val exception: Exception) : LoginResponse()
}

we could use a typedef & unions (+ potentially data classes to describe Token/...)

typedef LoginResponse =
  | Token
  | NoNetwork
  | InvalidCredentials
  | Exception

@eernstg
Copy link
Member

eernstg commented Feb 21, 2024

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
Copy link

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
Copy link
Member

eernstg commented Feb 21, 2024

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
Copy link

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
Copy link

iapicca commented Apr 13, 2024

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
Copy link

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
Copy link

a14n commented May 27, 2024

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
Copy link
Member

lrhn commented May 27, 2024

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
Copy link

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

@a14n
Copy link

a14n commented May 27, 2024

@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
Copy link
Member

mraleph commented May 27, 2024

@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
Copy link

a14n commented May 27, 2024

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
Copy link
Member

lrhn commented May 28, 2024

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
Copy link
Member

mraleph commented May 28, 2024

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
Copy link

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
Copy link
Member

lrhn commented May 28, 2024

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:

  • Union types
  • Intersection types (usually implied by union types and contravariance).
  • value/singleton types (JS literal types, making "yes" | "no" be a type because "yes" is a type. In Dart, I'd allow any constant, not just JS primitive types, to be literal types).
  • Structural map types ({"lat": num, "lng": num} is a type of a map with those two keys having those types of values).
  • Structural list types (TS tuples, [:num, :String] is a type of a list with (at least?) two elements having those types of values).
  • Function/Method overloading. (Multiple declarations with the same name and different signatures, effectively a function which has a type that is an intersection type of function types. with the same implementation.)
  • Variadic functions.
  • A representation of the type undefined that is compatible with TS.
  • Unsound nullability? (Probably always do the equivalent of strictNullChecks:true.)

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
Copy link

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
Copy link

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
Copy link

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
Copy link

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
Copy link

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
Copy link

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
Copy link
Contributor

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
Copy link

nate-thegrate commented Aug 27, 2024

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



Edit:

Or is it just an ergonomic problem?

Yes indeed 💯

@lrhn
Copy link
Member

lrhn commented Aug 27, 2024

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
Copy link

nate-thegrate commented Aug 27, 2024

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
request Requests to resolve a particular developer problem union-types
Projects
None yet
Development

No branches or pull requests