Description
This issue is a response to #356 and other issues requesting virtual static methods or the ability to create a new instance based on a type variable, and similar features.
Static substitutability is hard
The main difficulty with existing proposals in this area is that the set of static members and constructors declared by any given class/mixin/enum/extension type declaration has no interface and no subtype relationships:
class A {
A();
A.named(): this();
static int foo => 1;
static int bar => 2;
}
class B extends A {
B();
static int foo => -1;
static int baz => -3;
}
As a fundamental OO fact, B
is an enhanced version of A
when it comes to instance members (even in this case where we don't enhance anything), but it is simply completely unrelated when it comes to constructors and static members.
In particular, the relationship between the constructors A();
and B();
is very different from an override relationship. A
has a constructor named A.named
but B
doesn't have a constructor named B.named
. The static member B.foo
does not override A.foo
. B
does not inherit A.bar
. In general, none of the mechanisms and constraints that allow subtype substitutability when it comes to instance members are available when it comes to "class members" (that is, static members and constructors).
Consequently, it would be a massively breaking change to introduce a rule that requires subtype substitutability with respect to class members (apart from the fact that we would need to come up with a precise definition of what that means). This means that it is a highly non-trivial effort to introduce the concept which has been known as a 'static interface' in #356.
This comment mentions the approach which has been taken in C#. This issue suggests going in a different direction that seems like a better match for Dart. The main difference is that the C# approach introduces an actual static interface (static members in an interface that must be implemented by the class that claims to be an implementation of that interface). The approach proposed here transforms the static members into instance members, which means that we immediately have the entire language and all the normal subsumption mechanisms, we don't have to build an entirely new machine for static members.
What's the benefit?
It has been proposed many times, going back to 2013 at least, that an instance of Type
that reifies a class C
should be able to do a number of things that C
can do. E.g., if we can do C()
in order to obtain a new instance of the class C
then we should also be able to do MyType()
to obtain such an instance when we have var MyType = C;
. Similarly for T()
when T
is a type variable whose value is C
.
Another set of requests in this topic area is that static members should be virtual. This is trivially true with this proposal because we're using instance members of the reified Type
objects to manage the access to the static members.
There are several different use cases. A major one is serialization/deserialization where we may frequently need to create instances of a class which is not statically known, and we may wish to call a "virtual static method".
Proposal
We introduce a new kind of type declaration header clause, static implements
, which is used to indicate that the given declaration must satisfy some subtype-like constraints on the set of static members and constructors.
The operand(s) of this clause are regular class/mixin/mixin-class declarations, and the subtype constraints are based on the instance members of these operands. In other words, they are supertypes (of "something"!) in a completely standard way (and the novelty arises because of that "something").
The core idea is that this "something" is a computed set of instance members, amounting to a correct override of each of the instance members of the combined interface of the static implements
types.
abstract class A<X> {
int get foo;
void bar();
X call(int _);
X named(int _, int _);
}
class B static implements A<B> {
final int i;
B(this.i);
B.named(int i, int j): this(i + j);
static int get foo => 1;
static void bar() {}
}
These declarations have no compile-time errors. The static analysis notes the static implements
clause, computes the corresponding meta-member for each static member and for each constructor, and checks that the resulting set of meta-members amount to a correct and complete set of instance members for a class that implements A<B>
. Here is the set of meta-members (note that they are implicitly created by the tools, not written by a person):
mixin MetaMembers_Of_B on Type implements A<B> {
B call(int i) => B(i);
B named(int i, int j) => B.named(i, j);
int get foo => B.foo;
void bar() => B.bar();
}
The constructor named B
becomes an instance method named call
that takes the same arguments and returns a B
. Similarly, the constructor named B.named
becomes an instance method named named
. Static members become instance members, with the same name and the same signature.
The point is that we can now change the result of type Type
which is returned by evaluating B
such that it includes this mixin.
This implies that for each constructor and static member of B
, we can call a corresponding instance member of its Type
:
void main() {
dynamic t = B; // `t` is the `Type` that reifies `B`.
t(10); // Similar to `B(10)`, yields a fresh `B`.
t.named(20, 30); // Ditto, for `B.named(20, 30)`.
t.foo; // Similar to `B.foo`.
t.bar(); // Similar to `B.bar()`.
}
This shows that the given Type
object has the required instance members, and we can use them to get the same effect as that of calling constructors and static members of B
.
We used the type dynamic
above because those methods are not members of the interface of Type
. However, we could change the typing of type literal expressions such that are not just Type
. They could be Type & M
in every situation where it is known that the reified type has a given mixin M
. We would then be able to use the following typed approach:
class C static implements A<C> {
final int i, j;
C(int i): this(i, i);
C.named(this.i, this.j): assert(i < j);
static int get foo => 1000;
static void bar() {}
}
void main() {
var t = B; // `T` has type `Type & MetaMembers_Of_B`.
// With that in place, all of these are now statically checked.
t(10); t.named(20, 30); t.foo; t.bar();
// We can also use the type `A` in order to abstract the concrete class away.
X f<X>(A<X> a) {
a.bar();
return switch (a.foo) {
1 => a(),
_ => a.named(),
};
}
B b = f(B);
C c = f(C);
}
Next, we could treat members invoked on type variables specially, such that T.baz()
means (T).baz()
. This turns T
into an instance of Type
, which means that we have access to all the meta members of the type. This is a plausible treatment because type variables don't have static members (not even if and when we get static extensions), so T.baz()
is definitely an error today.
We would need to consider exactly how to characterize a type variable as having a reified representation that has a certain interface. Let us use the following, based on the syntax of regular type parameter bounds:
X f<X static extends A<X>>() { // New relationship that `B` and `C` satisfy.
X.bar();
return switch (X.foo) {
1 => X(),
_ => X.named(),
};
}
void main() {
B b = f(); // Inferred as `f<B>();`.
C c = f(); // Inferred as `f<C>();`.
}
Even if it turns out to be hard to handle type variables so smoothly, we could of course test it at run time:
X g<X>() { // No specialized bound.
var Xreified = X;
if (Xreified is! A<X>) throw "Ouch!";
Xreified.bar();
return switch (Xreified.foo) {
1 => Xreified(),
_ => Xreified.named(),
};
}
void main() {
B b = f(); // Inferred as `f<B>();`.
C c = f(); // Inferred as `f<C>();`.
}
Customized behavior
The behavior of the reified type objects can be customized, that is, they can do other things than just forwarding a call to a static member or a constructor.
One possible approach could be to have static extends C with M1 .. Mk
in addition to static implements T1 .. Tn
on type introducing declarations (like classes and mixins), and then generate the code for the reified type object such that it becomes a subclass that extends C with M1 .. Mk
and also implements T1 .. Tn
. However, we could also apply those mixins outside the static extends
clause, so we only consider a simpler mechanism:
A type introducing declaration can include a static extends C
clause. This implies that the reified type object will be generated such that the given C
is the superclass. Compile-time errors occur according to this treatment. E.g., if C
is a sealed class from a different library then static extends C
is an error, based on the fact that it would give rise to a subclass relation to that class, which is an error.
This mechanism allows the reified type object to have arbitrary behaviors which can be written as code in the class which is being used as the static extends
operand.
Use case: An existential open mechanism
One particular kind of feature which could be very useful is a flexible mechanism that delivers the behaviors otherwise obtained by an existential open mechanism. In other words, a mechanism that allows the actual type arguments of a given class to be accessed as types.
This would not involve changes to the type system (so it's a much smaller feature than a real existential open would be). It is less strictly checked at compile time, but it will do the job—and it will presumably be used sparingly, in just that crucial bit of code that allows a given API to be more convenient to use, and the API itself would be statically typed just like any other part of the system.
For example, the reified type object could implement this interface:
abstract class CallWithTypeParameters {
int get numberOfTypeParameters;
R callWithTypeParameter<R>(int index, R Function<T>() callback);
}
A class C
with a single type parameter X
could use static extends _CallWithOneTypeParameter<X>
, which would make it implement CallWithTypeParameters
:
abstract class _CallWithOneTypeParameter<E> implements CallWithTypeParameters {
int get numberOfTypeParameters => 1;
R callWithTypeParameter<R>(int index, R Function<Y>() callback) {
if (index != 1) {
throw ArgumentError("Index 1 expected, got $index");
}
return callback<E>();
}
}
For example, assume that the standard collection classes will use this (note that this is a non-breaking change):
abstract mixin class Iterable<E> static extends _CallWithOneTypeParameter<E> {
...
}
abstract interface class List<E> static extends _CallWithOneTypeParameter<E>
implements Iterable<E>, ... {
...
}
...
The 'existential open' feature is so general that it would make sense to expect system provided classes to support it. It also makes sense for this feature to be associated with a very general interface like CallWithTypeParameters
, such that all the locations in code where this kind of feature is needed can rely on a well-known and widely available interface.
If we have this feature then we can deconstruct a type of the form List<T>
, Set<T>
, ..., and use the actual type argument:
X build<X, Y>(Y y) {
if (<X>[] is List<List>) {
// `X` is `List<Z>` for some `Z`.
final reifiedX = X as CallWithTypeParameters;
return reifiedX.callWithTypeParameter(1, <Z>() {
return <Z>[build<Z, Y>(y)];
}) as X;
} else if (<X>[] is List<Set>) {
// `X` is `Set<Z>` for some `Z`.
final reifiedX = X as CallWithTypeParameters;
return reifiedX.callWithTypeParameter(1, <Z>() {
return <Z>{build<Z, Y>(y)};
}) as X;
} else if (<Y>[] is List<X>) {
// `Y <: X`, so we can return `y`.
return y as X;
} else {
throw ArgumentError("Inconsistent call `build<$X, $Y>(_)");
}
}
void main() {
String v1 = build('Hello!'); // Passthrough, 'Hello!'.
List<num> v2 = build(1); // `<num>[1]`.
Set<List<int>> v3 = build(2); // `<List<int>>{<int>[2]}`.
print('v1: ${v1.runtimeType} = "$v1"');
print('v2: ${v2.runtimeType} = $v2');
print('v3: ${v3.runtimeType} = $v3');
}
Obviously, this involves some delicate low-level coding, and it needs to be done carefully. However, the resulting API may be considerably more convenient than the alternatives.
In particular, an API could use regular objects that "represent" the composite types and their type arguments. Those type representations would then be handled by an interpreter inside build
. For example, with this kind of approach it is probably not possible to obtain a helpful return type, and it is certainly not possible to use type inference to obtain an object that "represents" a type like Set<List<int>>
.
Running code, emulating the example above.
abstract class CallWithTypeParameters {
int get numberOfTypeParameters;
R callWithTypeParameter<R>(int index, R Function<T>() callback);
}
abstract class _CallWithOneTypeParameter<E> implements CallWithTypeParameters {
int get numberOfTypeParameters => 1;
R callWithTypeParameter<R>(int index, R Function<Y>() callback) {
if (index != 1) {
throw ArgumentError("Index 1 expected, got $index");
}
return callback<E>();
}
}
// Assume `static extends _CallWithOneTypeParameter<E>` in collection types.
class ReifiedTypeForList<E> extends _CallWithOneTypeParameter<E> {}
class ReifiedTypeForSet<E> extends _CallWithOneTypeParameter<E> {}
// Workaround: Allow the following types to be used as an expression.
typedef _ListNum = List<num>;
typedef _ListInt = List<int>;
typedef _SetListInt = Set<List<int>>;
CallWithTypeParameters? emulateFeature<X>() {
return switch (X) {
const (_ListNum) => ReifiedTypeForList<num>(),
const (_ListInt) => ReifiedTypeForList<int>(),
const (_SetListInt) => ReifiedTypeForSet<List<int>>(),
_ => null,
};
}
X build<X, Y>(Y y) {
if (<X>[] is List<List>) {
// `X` is `List<Z>` for some `Z`.
final reifiedX = emulateFeature<X>() as CallWithTypeParameters;
return reifiedX.callWithTypeParameter(1, <Z>() {
return <Z>[build<Z, Y>(y)];
}) as X;
} else if (<X>[] is List<Set>) {
// `X` is `Set<Z>` for some `Z`.
final reifiedX = emulateFeature<X>() as CallWithTypeParameters;
return reifiedX.callWithTypeParameter(1, <Z>() {
return <Z>{build<Z, Y>(y)};
}) as X;
} else if (<Y>[] is List<X>) {
// `Y <: X`, so we can return `y`.
return y as X;
} else {
throw ArgumentError("Inconsistent call `build<$X, $Y>(_)");
}
}
void main() {
String v1 = build('Hello!'); // Passthrough, 'Hello!'.
List<num> v2 = build(1); // `<num>[1]`.
Set<List<int>> v3 = build(2); // `<List<int>>{<int>[2]}`.
print('v1: ${v1.runtimeType} = "$v1"');
print('v2: ${v2.runtimeType} = $v2');
print('v3: ${v3.runtimeType} = $v3');
}
Type parameter management
With this mechanism, a number of classes will be implicitly induced (that is, the compiler will generate them), and they will be used to create the reified type object which is obtained by evaluating the corresponding type as an expression.
The generated class will always have exactly the same type parameter declarations as the target class: If class C
has 3 type parameters with specific bounds then the generated class will have the same type parameter declarations with the same bounds. This implies that T
in static implements T
or static extends T
is well defined.
abstract class A<Y> {
void foo();
List<Y> get aList => <Y>[];
}
class C<X1 extends B1, X2 extends B2> static extends A<X2> {
static void foo() => print('C.foo running!');
}
// Implicitly generated class.
class ReifiedTypeForC<X1 extends B1, X2 extends B2> extends A<X2> {
void foo() => C.foo();
// Inherited: `List<X2> get aList => <X2>[];`
}
// Example, where `String` and `int` are assumed to satisfy the bounds.
void main() {
void f<X static extends A>() {
X.foo(); // Prints 'C.foo running!'.
print(X.aList.runtimeType); // 'List<int>'.
}
f<C<String, int>>();
}
More capable type objects as an extension
We may well wish to equip an existing type that we aren't able to edit (say, String
) with reified type object support for a particular class.
We can do this by adding a "magic" static member on the class Type
as follows:
class Type {
...
static DesiredType? reify<TypeToReify, DesiredType>() {...}
}
This is, at first, just a less convenient way to reify a type (passed as the type argument TypeToReify
) into a reified type object. With an actual type argument of Type
(or any supertype thereof), the returned object is simply going to be the reified type object that you would also get by simply evaluating the given TypeToReify
as an expression.
However, if DesiredType
is not a supertype of Type
then the reified type object may or may not satisfy the type constraint (that is, it may or may not be an instance of a subtype of DesiredType
). If it is not an instance of the specified DesiredType
then reify
will attempt to use an extension of the reified type of TypeToReify
.
Such extensions can be declared in an extension
declaration. For example:
extension E<X> on List<X> {
static extends _CallWithOneTypeParameter<X>;
}
At the location where Type.reify<List<int>, CallWithTypeParameters>()
is invoked, we gather all extensions in scope (declared in the same library, or imported from some other library), whose on-type can be instantiated to be the given TypeToReify
. In an example where the given TypeToReify
is List<int>
, we're matching it with List<X>
.
For this matching process, the first step is to search the superinterface graph of the TypeToReify
to find the class which is the on-type of the extension. In the example there is no need to go to a superinterface, the TypeToReify
and the on-type of the extension are already both of the form List<_>
.
Next, the value of the actual type arguments in the chosen superinterface of the TypeToReify
is bound to the corresponding type parameter of the extension. With List<int>
matched to List<X>
, X
is bound to int
.
Next, it is checked whether there is a static implements T
or static extends T
clause in the extension such that the result of substituting the actual type arguments for the type parameters in T
is a subtype of DesiredType
.
In the example where TypeToReify
is List<int>
and DesiredType
is CallWithTypeParameters
, we find the substituted static implements
type to be _CallWithOneTypeParameter<int>
, which is indeed a subtype of CallWithTypeParameters
.
If more than one extension provides a candidate for the result, a compile-time error occurs.
Otherwise, the given reified type object is returned. In the example, this will be an instance of the implicitly generated class for this static implements
clause:
class ExtensionE_ReifiedTypeForList<X> extends _CallWithOneTypeParameter<X>
implements Type {}
The result is that we can write static implements
and static extends
clauses in extensions, and as long as we're using the long-form reification Type.reify<MyTypeVariable, CallWithTypeParameters>()
, we can obtain an "alternative reified object" which is specifically tailored to handle the task that CallWithTypeParameters
was designed for.
If we're asking for some other type then we might get it from the reified type object of the class itself, or perhaps from some other extension.
Finally, if the reified type object of the class/mixin/etc. itself doesn't have the DesiredType
, and no extensions will provide one, then Type.reify
returns null.
It is going to be slightly less convenient to use Type.reify
than it is to simply evaluate the type literal as an expression, but the added expressive power will probably imply that Type.reify
will be used for all the more complex cases.
Revisions
- Feb 21, 2025: Added a section about extension-like reified type objects. Added a section about how to manage type parameters in the implicitly induced class that defines the reified type object.
- Feb 20, 2025: Further developed the ideas about customized behavior.
- Feb 14, 2025: Add the section about customized behavior.
- Dec 10, 2024: Adjust the mixin to be
on Type
. - Dec 9, 2024: First version.