Skip to content
/ typer Public

Provide `Typer<X>`, a more capable version of `Type`

License

Notifications You must be signed in to change notification settings

eernstg/typer

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

48 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

typer

Provide Typer<X>, a user-written, but more capable version of Type.

The built-in Type class

The language Dart has built-in support for obtaining a reified representation of a given type T by evaluating the corresponding type literal as an expression:

typedef TypeOf<X> = X;

void main() {
  // `type` is an `Object` representing the type `int`.
  Type type = int;
  
  // Some types are not expressions, but we can still get their `Type`
  // via a helper type alias like `TypeOf`.
  Type functionType = TypeOf<void Function([String])>;
  
  // Instances of `Type` can't be used for much, but they do have equality.
  print(type == functionType); // 'false'.
  
  // For example, we can't use them in type tests, nor in subtype checks.
  1 is type; // Compile-time error.
  1 as type; // Compile-time error.
  type <= functionType; // Compile-time error.
}

The the greatest power offered by Type is that we can obtain a reification of the run-time type of any given object using runtimeType:

void main() {
  Object? o1 = ..., o2 = ...; // Anything will do.
  print(o1.runtimeType == o2.runtimeType);
}

Typer

For all other things than getting the run-time type of an existing object, we can use an instance of Typer<T> for any type T that we can denote, and this will do more than a Type can do.

Comparing types

In particular, Typer has support for the relational operators <, <=, >, and >=. They will determine whether one Typer represents a type which is a subtype/supertype of another:

void main() {
  const t1 = Typer<int>();
  const t2 = Typer<num>();
  print(t1 <= t2); // 'true'.
  print(t2 <= t1); // 'false'.
}

Type tests and type casts

If typer is a Typer<T> then we can test whether a given object o is an instance of T using o.isA(typer), and perform a cast to T using o.asA(typer). Note that these tests will use the actual value of the type argument of typer, not the statically known type argument (which could be any supertype of the actual one).

void main() {
  // Somehow, we've forgotten that `typer` represents `int`,
  // we just remember that it is some subtype of `num`.
  Typer<num> typer = Typer<int>();
  
  // But the `isA` (and `asA`) methods will use the actual type.
  print(2.isA(typer)); // 'true'.
  print(1.5.isA(typer)); // 'false'.
  2.asA(typer); // OK.
  1.5.asA(typer); // Throws.
}

The methods isA, isNotA, and asA are extension methods. They are based on instance members. We may wish to use the extension methods because they have a more conventional syntax, or we may wish (or need) to use the instance members, e.g., because we do not want to import the extension, or because the call must be dynamic.

// Same thing as previous example, using instance members.

void main() {
  Typer<num> typer = Typer<int>();

  print(typer.containsInstance(2)); // 'true'.
  print(typer.containsInstance(1.5)); // 'false'.
  typer.cast(2); // OK.
  typer.cast(1.5); // Throws.
}

We can use the getter type to access the underlying type as an object (an instance of Type):

void main() {
  Typer<num> typer = Typer<int>();
  print(typer.type); // 'int'.
}

Using the type that a Typer represents

We can use the method callWith to get access to the underlying type in a statically safe manner (this is essentially an "existential open" operation):

List<X> createList<X>(Typer<X> typer) =>
    typer.callWith(<Y>() => <Y>[] as List<X>);

void main() {
  // Again, we do not have perfect knowledge about the type.
  Typer<num> typer = Typer<int>();

  List<num> xs = createList(typer);
  print(xs is List<int>); // 'true'.
}

To see why this is a non-trivial operation, try to complete the following example which is doing the same thing using the built-in Type objects:

List<X> createList<X>(Type type) => ...; // NB!

void main() {
  Type type = int;

  List<num> xs = createList(type);
  print(xs is List<int>); // 'true'.
}

Note that X is unconstrained, and there is no guarantee that it has any relationship with the given Type (X could have the value String and type could be a reified representation of int, and we wouldn't know there's a problem).

It is actually not possible (without 'dart:mirrors') to extract any information from the given type (other than equality, say, which could be used to test that type != X, but that wouldn't be very useful). So we basically can't write code (again, except using mirrors) which will create a List<int> based on the fact that type is a reified representation of int. Using equality we can take some baby steps, but it won't scale up:

List<X> createList<X>(Type type) => switch (type) {
    int => <int>[],
    String => <String>[],
    _ => throw "Surely we don't need more cases! ;-)",
  };

Finally we have two methods associated with promotion:

void main() {
  Typer<num> typer = Typer<int>();
  num n = Random().nextBool() ? 2 : 2.5;

  print('Promoting:');
  List<num>? xs = typer.promoteOrNull(n, <X extends num>(X promoted) {
    print('  The promotion to `typer` succeeded!');
    return <X>[promoted];
  });
  print('Type of `xs`: ${xs.runtimeType}'); // `List<int>` or `Null`.

  print('Promoting with `orElse` fallback:');
  Object o = n; // Promote from a rather general type.
  num n2 = typer.promote(o, <X extends num>(X promoted) {
      print('  The promotion to `typer` succeeded!');
      // `typer` has static type `Typer<num>`, so we can use `num` members.
      promoted.floor();
      return promoted;
    },
    orElse: () => 14,
  );
  print('n2: $n2'); // '2' or '14'.
}

We cannot use is or as directly to obtain a promotion because we cannot test directly against the underlying type T of a given Typer<T>. We can call isA or asA, but those methods do not give rise to promotion of their receiver because the type system does not know that isA actually returns true or false in exactly the same way as is does, and asA will throw in exactly the same manner as as, albeit based on the type T rather than on a type which can be denoted locally.

However, we can pass a generic callback which will receive the underlying type T of the Typer<T> as its actual argument, and it will also receive the object which is being type tested (promoted in the example).

In the body of that callback we can then use the promoted value, and it is statically known that it has a type which is a subtype of that type argument (in the example: we know that the actual argument has type X). Note that X is bounded by the statically known type argument of typer, which means that we can use the interface of num on the promoted object.

Note, though, that we are actually promoting the promoted object to the type reified by typer (which is int), not just to the statically known bound (which is num). So when the value of n is 2.5, we use the value from orElse(), and do not run the callback.

Example design

Here is an example of a design which can be used to enable techniques that are similar to an existential open operation, in a rather general fashion. That is, it allows us to use the type arguments of a given object "from the outside".

The basic idea is that a generic class with type parameters X1 .. Xk has a getter for each type variable Xj, returning a Typer<Xj>.

These getters can be used to write code that uses the actual value of each of the type variables of the class, which is again a feature that Dart does not support for client code. In fact, only code in the body of the class has access to the type variables because that's the only code for which those type variables are in scope. Outside clients only know an approximate value which is a supertype of the actual value, based on the statically known type of the given object.

For instance, in the body of the List<E> class the type parameter E is in scope, and we can do things like return <E>{};. But in code outside the body of List, we may only know that the list is a List<T> where T could be any supertype of the actual value, e.g., we might only know that it is a List<Object?>.

In this example, we use the Typer to create a set from a given list, preserving the actual type argument.

This is a non-trivial feat, as you may know from experience if you have tried to create a new object with the same type arguments as an existing object, or anything of a similar nature.

The example uses mock classes MyIterable, MyList, and MySet. This is just because we can't easily add the necessary Typer getters to the real List and Set classes. Nevertheless, if such getters were added then these techniques could be used on real List and Set objects, just like they are used here.

This is true for any class, of course: If you want to enable this kind of existential opening for one of the classes you maintain then you just need to add those Typer getters, and then you can use these techniques.

Here is the example:

abstract class MyIterable<E> {
  const MyIterable();

  E get first;

  Typer<E> get typerOfE => Typer();
}

class MyList<E> extends MyIterable<E> {
  final E e;

  const MyList(this.e);

  @override
  E get first => e;
}

class MySet<E> extends MyIterable<E> {
  final E e;

  const MySet(this.e);

  @override
  E get first => e;
}

MySet<X> iterableToSet<X>(MyIterable<X> iterable) =>
    iterable.typerOfE.callWith(<Y>() {
      return MySet<Y>(iterable.first as Y) as MySet<X>;
    });

void main() {
  // Assume that we do not know the precise type of `iterable`.
  MyIterable<Object?> iterable = MyList<int>(42);

  // Now we want to create a set with the same element type.
  var set = iterableToSet(iterable);
  print(set.runtimeType); // 'MySet<int>'.
}

This kind of code isn't particularly convenient. For example, we have to "manually" tell the type system that iterable.first has the type Y, by means of a type cast.

This cast is safe because Y will be the actual type argument of iterable. However, the type system cannot make that connection. (It would take a full-fledged language mechanism to be able to know this in the type system.) Still, the fact that Y is guaranteed to be the actual type argument of iterable allows us to write type casts that are guaranteed to succeed. The point is that we can now use that actual type argument because it's available as Y in the body of the function literal.

The crucial insight is that it is not possible to get access to the value of a type variable of an existing object unless we have something inside the body of the class of that object. The typerOfE getter is a quite general tool for this purpose.

This means that you can do things that you otherwise can't do.

About

Provide `Typer<X>`, a more capable version of `Type`

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages