language icon indicating copy to clipboard operation
language copied to clipboard

Extension types should always be able to implement their representation type

Open jakemac53 opened this issue 8 months ago • 7 comments

I ran into a bit of a weird edge case today, where I wanted to be able to create an extension type whose representation type is Null, and I wanted to be able to pass that type into any API which accepts null.

More broadly I realized this applies to all extension types whose representation type is nullable - they cannot implement those nullable types, but it isn't immediately clear why that would be unsafe (even if its a bit weird).

Concretely, consider the following code:

void main() {
  printNullable(A.instance);
}

/// "implements Null" is not allowed
extension type A._(Null _) /* implements Null */ {
  static final instance = A._(null);
}

void printNullable(String? s) {
  if (s != null) print(s);
}

In my actual more convoluted use case, this was causing an inferred type of Never, and thus runtime cast failures, which was fairly challenging to debug.

While I do not think this use case is particularly compelling, I would expect that all extension types could statically implement their representation type, regardless of what that type is.

I understand why real types cannot implement Null, but I don't know that the same logic applies to extension types.

jakemac53 avatar Apr 02 '25 18:04 jakemac53

I didn't realize this restriction applied also to Null.

I opened an issue previously about extension types for Function and Record types implementing their representation types. (#3839)

mmcdon20 avatar Apr 02 '25 18:04 mmcdon20

The primary reason for not allowing you to implement a non-interface type is that it mixes interface types and structural types. The type system is just not built for that.

For example the UP algorithm starts out by doing structural decomposition on the two types, until it ends up with two interface types that have no common structure. Then it used the old LUB algorithm which compares all superinterfaces based on their interface chain depth (Object and Null have depth 1). If an extension type, which is an interface type, can have a structural supertype, then that just doesn't fit into the algorithm. A structural type has no interface depth at all.

So we'll need, at least, a new UP algorithm before we can allow an interface type to have a non-nominative supertype in the type hierarchy. No "implementing" s record type. (I want it too, it's not that I can't see the benefits.)

As for not being allowed to implement Null, which is an interface type, that's more out of caution. It's very likely that a non-bottom proper subtype of Null will confuse a lot of code, including in the compiler, and still need a lot of spec work, just because the text assumes that there is no such type.

For example, a proper subtype of Null is not a nullable type. (A type is defined to be nullable if Null is a subtype of it.) It's also not non-nullable. (A type is non-nullable if it's a subtype of Object.) That's not new, we have other such types, but not ones where the only valid value is null, and which is assignable to null.

(I don't know what it would mean to implement void, dynamic or Never.)

I'm pretty sure there are no soundness issues with an extension type implementing its representation type, but I expect it to require a significant spec and implementation work, just to be sure nothing chokes.

(A proper non -bottom subtype of Record ... That must be a record type ... Right? Nope, not any more.)

lrhn avatar Apr 02 '25 20:04 lrhn

(I don't know what it would mean to implement void, dynamic or Never.)

Well for Never, you already can't create an extension type on Never, so no need to do anything here.

// ERROR: The representation type can't be a bottom type.
extension type NeverExtension(Never _) {}

It's weird that void is allowed, but in dart you can also declare a function parameter as void or a variable as void, so presumably an extension type implementing void would allow you to pass the value to said functions or variables (even though that's pretty much useless).

extension type VoidExtension(void _) implements void {}

void f(void v) {}

void main() {
  f(VoidExtension(print('hello')));
}

For dynamic it seems kind of redundant because of a variable or parameter of type dynamic would accept the extension type regardless of if it implements dynamic or not.

mmcdon20 avatar Apr 02 '25 22:04 mmcdon20

In a similar vane. Could the following become legal?

class A {} // a proper interface type

extension type B<T extends A>(T rep) implements T {} // <-- not allowed today, but could it be?

nielsenko avatar Jun 10 '25 13:06 nielsenko

This would allow stuff like:

class A {}

extension type B<T extends A>(T rep) implements T {
  String get foo => 'foo';
}

extension type C<T extends A>(T rep) implements T {
  String get bar => 'bar';
}

void main() {
  final b = B(A());
  final c = C(b);
  // now c has both interfaces
  print('${c.foo} ${c.bar}');

  final x = B(c); // <-- this might be a problem, but not sure since this is all statically resolved
}

nielsenko avatar Jun 10 '25 14:06 nielsenko

extension type B<T extends A>(T rep) implements T {} // <-- not allowed today, but could it be?

It still risks someone writing B<Never>. Obviously you can't instantiate that type, but it's still a proper subtype of the Never type. If Null is not a subtype of A (and it isn't if A is a non-null interface type), then it won't be implementing null.

It's probably something that can be handled, it's a bottom type so a subtype and supertype of Never. We just have to go through all the type system rules and check that nothing breaks.

// now c has both interfaces

That's another thing I'm worried about: A type not having a fixed set of members. What are the available members of B<int> here ... all the member of int presumably.

What are the members of x in void foo<T extends num>(B<T> x) { ... x ... }. That depends on the type of T. Presumably it will only statically allow the members of B<num>.

That's another reason we don't generally allow implementing type variables. It makes a lot of properties of types depend on the actual instantiation, which makes static analyis much harder.

lrhn avatar Jun 10 '25 14:06 lrhn

What are the available members of B here ... all the member of int presumably.

Well, B<int> would not be allowed, since int does not extend A, but yes B<C> would have all members of both B and C with the usual redeclare rules, if applicable (like in the final x = B(c) case).

What are the members of x in void foo<T extends num>(B<T> x) { ... x ... }. That depends on the type of T. Presumably it will only statically allow the members of B<num>.

Yes, that would be my expectation, with the above caveat.

That's another reason we don't generally allow implementing type variables. It makes a lot of properties of types depend on the actual instantiation, which makes static analysis much harder.

Okay. I guess I was hoping it would be simpler for extension types than regular generic classes.

Anyway, thank you for taking the time to answer so swiftly.

nielsenko avatar Jun 10 '25 15:06 nielsenko