Search code examples
dartdart-analyzer

dart enforce subtype in generic type (disallow base type) VS switch(Foo<T>)


Given such classes

sealed class Base { /* ... */ }

class X extends Base { /* ... */ }
class Y extends Base { /* ... */ }

class Foo<T extends Base> {
   final T t;
   const Foo({required this.t});
}

Reading the docs it seems impossible to restrict the type of an instance of Foo<T> so that T must be a real sub types of Base and may not be Base. Is it possible?

Background is that I want to switch over a Foo<T> instance:

final a = Foo<X>(/* ... */);

final result = switch (a) {
  Foo<X> _ => 'x',
  Foo<Y> _ => 'y',
  Foo<Base> _ => throw 'does not exist'; // <--
}

The missing_enum_constant_in_switch rule requires me to specify the Base _ case. That is bad, because it matches all cases, so that if we later add a new class Z extends Base {/* ... */}, the rule would no longer report a missing case but just use the Foo<Base> case and throw does not exist.

Given that Base is sealed it also is abstract and hence cannot be instantiated and hence I would expect the compiler to not require that case - but I understand that it does, because in general the type restriction allows for Foo<Base>.

As a workaround I am currently switching over a.t.

final a = Foo<X>(/* ... */);

final result = switch (a.t) {
  X _ => 'x',
  Y _ => 'y',
  // no `Base` case is required.
}

This looks fine in an example, but is very cumbersome and error prone in realistic production code, because it would require a manual unchecked cast of a if downstream functions depend on a rather than on a.t:

final a = Foo<X>(/* ... */);

final result = switch (a.t) {
  // THIS IS NOT POSSIBLE
  X _ => somethingX(a as Foo<X>),
  Y _ => somethingY(a as Foo<Y>),
}

This would not work, because a is of type Foo<Base> and hence flutter throws type 'Foo<Base>' is not a subtype of type 'Foo<X>' in type cast.

Hence, we further must workaround by changing somethingX and somethingY to also accept the downcasted value like this:

final a = Foo<X>(/* ... */);
final t = a.t;
final result = switch (t) {
  X _ => somethingX(a, t),
  Y _ => somethingY(a, t),
}

or by doing something like:

final a = Foo<X>(/* ... */);
final t = a.t;
final result = switch (t) {
  X _ => somethingX(a.cast<X>()),
  Y _ => somethingY(a.cast<Y>()),
}

where cast is:

Foo<T extends Base> {
  // ...

  Foo<T2> cast<T2 extends Base>() => Foo(
    t: t as T2,
  );
}

Solution

  • It's not possible.

    Dart type parameters are only restricted by subtype checks, so there is no way to accept both X and Y and also not accept Base, not with the current definitions.

    The one hack you'd probably try, and which won't actually work, is to have a hidden shared superclass:

    class Base {}
    class _SecretBase extends Base {}
    class X extends _SecretBase {}
    class Y extends _SecretBase {}
    
    class Foo<T extends _SecereteBase> { ... }
    

    Then you'd think that someone outside of your library would not be able to create a Foo<_SecretBase>, and they can't use Base, so would have to use one of the proper subtypes of Base. Sadly it doesn't work, since a raw type like new Foo() would "instantiate to bounds" and create a Foo<_SecretBase>, even if the caller couldn't write the type.

    So, it's not possible to restrict type arguments to proper subtypes of a shared supertype.