Skip to content

Union types, like in typescript #2711

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

Closed
topperspal opened this issue Dec 11, 2022 · 6 comments
Closed

Union types, like in typescript #2711

topperspal opened this issue Dec 11, 2022 · 6 comments
Labels
feature Proposed language feature that solves one or more problems

Comments

@topperspal
Copy link

Dart is great, and it is also statically typed (sometimes not convenient). For example, why enums are not always good for a simple list of options like

Toast.show("Boring enums everywhere! 😕", length: ToastLength.long);

// Here we created an enum class only for two options [short, long].
enum ToastLength { short, long }

// But this is verbose and also clutter auto-completition list in code editor.
// Can this be implemented simply like typescript typedef

typedef _ToastLength = "short" | "long";

class Toast {
  static void show(String message, {_ToastLength length = "short"}) {
    ...
  }
}

// Or bind prefered options directly in the method like this

class Toast {
  static void show(String message, {"short"|"long" length}) { ... }
}

// So we can write the method without and clutter like this

Toast.show("Now dart looks cool! 😎", length: "long");

This will also help library developers to reduce the types, so they don't conflict with types in other libraries.

@topperspal topperspal added the feature Proposed language feature that solves one or more problems label Dec 11, 2022
@lrhn
Copy link
Member

lrhn commented Dec 11, 2022

If we generalize this feature a little, it would seem that it is a way to define a "subset type":

  • It introduces a (finite) type which contains only known constant values of some larger shared super-type. Here the larger type is String, but there is no need to restrict to just that. I'd allow any container super-type and any constants of that type with primitive equality. (Including a super-type of Object? and arbitrary primitive-== constants.)
  • Allows only constant expressions with one of those values to be assigned to the type, or existing expressions of the type.

Subtyping

The subset type is probably going to be a subtype of Object?. Maybe of Object if all the constants are non-null. Types don't really work in Dart if they are not, because generics assume an implicit bound of Object?.

Should the _ToastLength type be related to String or not, and if so, how?

If we don't make it as subtype of String, we just allow those particular constants to be assignable to _ToastLength, not the other direction. It's compile-time decidable whether a constant is one of those constants. You can't assign them back.(You can always do as String anyway.)

If we make _ToastLength a subtype of _String, then the toast lengths can be assigned back to String. Still want the assignability of constants in the other direction.

If we make _ToastLength a supertype of _String, then any string can be assigned to _ToastString, which we probably do not want, because then we can't prevent other values. So no relation or subtype only.

I'd go for being a subtype.

Then it's also clear that you can use any member of the supertype on every value.

Subtype checking

Can you do anyValue is _ToastType? If you can, it requires comparing the value to a finite set of constants, which all have primitive ==, which usually just means identity checks. Maybe compilers can do even better by using hash codes to narrow the constants to compare to, but that can easily be more expensive than just doing five identity checks.
Definitely possible, but different from any other type check, so implementations will need a special code path for it.

If you cannot, then things get weird. You probably cannot have a List<_ToastLength> because List.add is coviariant, so it does an is E check on the argument.
So, probably do need to allow is checks, which will be more complicated than usual type checks.
And it requires reifying the type at runtime, so it can be bound to the E type variable of List.

The alternative is to not reify the type at runtime, and just erase it to the shared supertype. At that point, there is no longer any way to ensure that another string doesn't get "cast" to _ToastLength at runtime, and the compiler cannot assume switches over all the official values to be exhaustive. (Which it otherwise can, like a proper enumerated type.)

I'd recommend reifying the type, so it exists at runtime too.
Which likely means not using typedef for the declaration.

Other ponderings

An enum is really just a special case of this, where then constants' types and the super-type are the same, and we ensure you cannot introduce more instances of the type, so the is check can be a normal type check.

This feature may be useful for some things, but I'm not sure replacing normal enums with string constants is the use-case that sells it to me.
I'd rather make enum values easier to use, like #357.

@rubenferreira97
Copy link

rubenferreira97 commented Dec 14, 2022

@lrhn Not 100% about this issue, but a "sub-problem". Could Dart ever have "literal types" (more concrete sub-type of a collective type) so developers could express a more correct API? With unions this might be useful.

Some examples (horrendous syntax that might have grammar conflicts):

final 1 one = 1;

final 1 | 2 | 3 | 4 | 5 | 6 diceRoll = 3;

final 'yes' | 'no' answer = 'yes';

typedef DiceRoll = 1 | 2 | 3 | 4 | 5 | 6;

void printDiceResult(DiceRoll diceRoll, DiceRoll dice2Roll) {
  print(diceRoll + dice2Roll);
}

printDiceResult(diceRoll, diceRoll);

@eernstg
Copy link
Member

eernstg commented Dec 14, 2022

Could Dart ever have "literal types"

An enum type does have this nature: It is a statically known, finite set of objects. You can't subset them in the traditional sense, but you can use type arguments to do so:

abstract class All {}
abstract class SomeA implements All {}
abstract class SomeB implements All {}
abstract class SomeOverlap implements SomeA, SomeB {}

enum E<X extends All> {
  a<SomeA>(1),
  b<SomeB>(2),
  c<SomeOverlap>(3);

  final int i;
  const E(this.i);
}

This means that E<SomeA> has two elements and is a subset of E<All> which has 3 elements, etc.

You can use fields on the enum declaration in order to turn those sets of enum values into sets of other kinds of values. In the example above, a is mapped to 1 by the getter i, b is mapped to 2, so E<SomeA> indirectly models the finite set {1, 2}.

Of course, this might look like an overly verbose and indirect exercise, but if you really need this typing structure then the technique where you are using an enum type is one possibility that should at least be on the radar.

@rubenferreira97
Copy link

There are some differences between using Enums and literal types. Literal types are opaque, meaning that the API consumer does not care about other person code (I don't need to pass a specific declared enum from X library).

This might avoid "dumb" enums from multiple libraries that can't be reused like:

enum Answer {
   Yes,
   No
}

Another question I have @eernstg, how would you extend a Enum, like typedef 8SidedDiceRoll = DiceRoll | 7 | 8; since you can't do this in dart:

enum DiceRoll {
  One(1),
  Two(2),
  Three(3),
  Four(4), 
  Five(5),
  Six(6),

  final int i;
  const DiceRoll (this.i);
}

enum 8SidedDiceRoll extends DiceRoll {
  Seven(7),
  Eight(8),

  const 8SidedDiceRoll (super.i);
}

@eernstg
Copy link
Member

eernstg commented Dec 14, 2022

There are some differences between using Enums and literal types.

Certainly, usages would be quite different. It's only relevant if you really need that typing structure, and you are willing to pay the syntax that it takes to get it.

how would you extend a Enum

You cannot extend an enum type. That's exactly the reason why I wasn't modeling subsets of a given finite set of values by declaring several enum types with a subtype relationship among them: That won't happen, because it is crucial for enum types that they are at the bottom of the subtype hierarchy (except for Never and its ilk).

But you can use a type argument to organize the values of a given enum type into subsets, and that graph can be tailored to any DAG you might want (that is, it can model all possible subset relationships). This is what I did in the example here, based on the type hierarchy All, SomeA, SomeB, and SomeOverlap.

I didn't say it was convenient. Just possible. 😸

@topperspal
Copy link
Author

topperspal commented Dec 14, 2022

Enums are great for some cases. Here I am talking about literal unions types.

For example making a http request basically require an url and a method (get, post, put etc.). Now I can achieve this by creating an enum RequestType

enum RequestType { get, post, put, patch, delete }

and then call the method

final res = await Request(url, RequestType.get).call();
// Just for showing the case, nothing ingenious

Now instead of creating the enum, It would be easy if I can write the above code like this

final res = await Request(url, "get").call();

where "get" is a union type ("get", "post", "patch"), with auto-completion and without any other string value. Then we don't need to create an enum ( a TYPE) for this simple job.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
feature Proposed language feature that solves one or more problems
Projects
None yet
Development

No branches or pull requests

4 participants