Skip to content

[inline classes] Support simple union types? #2603

@eernstg

Description

@eernstg

In response to https://github.com/dart-lang/language/issues?q=is%3Aissue+is%3Aopen+union+types+label%3Aunion-types, we could have something like the following in a widely available location (e.g., 'dart:union' or even 'dart:core'):

inline class Union2<X1, X2> {
  final Object? it;
  implicit Union2.inject1(X1 this.it);
  implicit Union2.inject2(X2 this.it);

  // We might want various methods, e.g.:
  bool get valid => it is X1 || it is X2;
  X1? get as1 => it is X1 ? it : null;
  X2? get as2 => it is X2 ? it : null;
}

inline class Union3<X1, X2, X3> {
  final Object? it;
  implicit Union3.inject1(X1 this.it);
  implicit Union3.inject2(X2 this.it);
  // ... and so on.
}

...

inline class Union9<...> {...} // Or some other max number of arguments.

The modifier implicit on constructors was proposed in #309 (for a more specialized purpose, but the idea immediately generalizes to a constructor of any class). The basic idea is that if an expression e has static type S and context type T, and S is not assignable to T, and T is a type (parameterized like C<U1, .. Uk>, or simply C) denoting a class that declares an implicit constructor (say, C.name(S1 s)) taking exactly one positional parameter and no required named parameters, then e is transformed into an invocation of that constructor (C.name(e) or C<...>.name(e)).

It could be used as follows:

import 'dart:union';

void f(Union2<int, String> u) {
  // We'd probably need special support in order to recognize that the set of cases is
  // exhaustive, and each case is possible (according to the type of `u`). We might
  // throw in some generated code in case someone did terrible things like
  // `var u = true as Union<int, String>;`, such that we don't have to write that every time.
  switch (u) {
    case int(): print(u.isEven);
    case String(): print(u.length);
    // A `default` case is generated to throw if the Union was misused.
  }
}

void main() {
  f(Union2.inject1(42)); // The constructors can of course be used directly.
  f(42); // If we get support for `implicit` then this would suffice, too.
}

The fact that inline classes do not involve allocation of an actual wrapper object ensures that (1) the union type has a zero cost representation at run time (an expression of type Union2<int, String> evaluates to an int or a String, no extras), and (2) it allows a plain type test (myUnion is int).

The mechanism is not robust (we can easily break it, e.g., true as Union2<int, String> won't throw), but it is only intended to help developers who want to ensure that a given expression has one out of a specified set of types, and we do get the static checking at a reasonable level. For instance, even with an implicitly invoked constructor, f(true) in main would be a compile-time error, because true doesn't have a type which is assignable to Union2<int, String>, and none of the implicit constructors (or any other available code transformation) will make it type correct.

The mechanism does not have the algebraic properties that we would expect from a full-fledged union type in the language: We do not have support for A <: A | B <: B | A <: A | B | C (no normalization, no reordering), it only has the subtype relationships that we can get from standard covariant type argument based subtyping: Union2<int, String> is a subtype of Union2<num, dynamic> because int <: num and String <: dynamic. This is one of the reasons why this mechanism would be a minimalistic version of union types.

The support for switch statements where the scrutinee has a union type is perhaps the most tricky part. We could simply omit this and rely on developers writing the switch statements in full. However, it seems quite useful if the compiler/analyzer (or perhaps just the linter) "knows about" union types, such that we can ensure that a sequence of cases testing the type of the scrutinee will actually test exactly the operand types in the union type, and also that it will throw in case there is no match (again, considering true as Union<int, String>).

[Edit Nov 2: Added reference to the old proposal about implicit constructors, and some more info about the mechanism.]
[Edit, Dec 8: Changed the syntax to the most recent syntax: Views are now inline classes.]

Metadata

Metadata

Assignees

No one assigned

    Labels

    extension-types-laterIssues about extension types for later considerationquestionFurther information is requested

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions