Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support static constraints on type casts and type tests #4074

Open
eernstg opened this issue Sep 2, 2024 · 3 comments
Open

Support static constraints on type casts and type tests #4074

eernstg opened this issue Sep 2, 2024 · 3 comments
Labels
small-feature A small feature which is relatively cheap to implement. type-queries Issues about enhanced versions of `is` and `as` expressions and other run-time type queries

Comments

@eernstg
Copy link
Member

eernstg commented Sep 2, 2024

I do not think we have an issue dedicated to this topic, which came up again in dart-lang/sdk#56624. So here we go:

The as operator in Dart leaves everything to the developer, as in "I assume you know what you are doing". Similarly, the is operator will happily test any object against any type.

This is fine in the general case because it provides great freedom to inform the type checker about typing properties that are beyond the current static analysis—that is, it's good when we truly know what we are doing.

However, we may well have situations where the intended type casts or type tests are more constrained, e.g., an expression of the form e as T may well be written under the assumption that T is a proper subtype of the static type of e, and it's simply a bug if that relationship doesn't hold. For example, with num n in scope, we may at some point evaluate an expression like n as int, which could be well justified in the given situation, but n as String is inevitably a bug.

Similarly, a type test may well be intended to "be a downcast". For example, with Object o in scope, a test like o is num may promote o to num, but o is int? will not (because promotion will never occur when the target type isn't a subtype of the current type).

Consequently, it could be useful to have separate syntactic forms of type queries, adding constraints like "the target type must be a subtype of the static type of the scrutinee", or ".. a proper subtype ..", or ".. a supertype ..", and perhaps others.

Strawman syntax: as< respectively is< for "is a proper subtype", and similar operators for other cases. So we'd have this:

int? iq;

void main() {
  Object? o = 42; // Promoted to `Object` by initializing expression.
  
  // The promotion to `Object` was implicit, so we might not be aware of it.
  if (o is int?) {...} // OK, same treatment as today, but does not promote `o`.
  if (o is< int?) {...} // Compile-time error, `int?` is not a subtype of `Object`.
  if (o is< int) {...} // OK, and promotes `o` to `int`.
  ...
  if (iq is< int) { // OK, but `iq` is not local, so it is not promoted.
    var iq2 = iq as< String; // Compile-time error, should be a downcast, but isn't.
    var iq3 = iq as< int; // OK, is a downcast.
  }

  void f<X>(X x) {
    void g<Y extends X>() {
      x as<= Y; // OK, downcast, but not necessarily to a different type.
    }
  }

  // We may even wish to use an upcast (`ys` has type `Object` without it).
  Iterable<int> xs = [3];
  var ys = someCondition ? <String>['Hi'] : xs as> Iterable<Object>;
}

This kind of syntax could go along with other mechanisms dealing with run-time type queries, e.g., the syntax as? which is proposed in #399, where 1 as? int would yield 1 and 1 as? String would yield null (rather than throwing). We might then have e as?< T to make it a proper downcast, and defaulting to null.

@eernstg eernstg added small-feature A small feature which is relatively cheap to implement. type-queries Issues about enhanced versions of `is` and `as` expressions and other run-time type queries labels Sep 2, 2024
@eernstg eernstg changed the title Support static constraints on type casts, type tests, and other run-time type queries Support static constraints on type casts and type tests Sep 2, 2024
@mateusfccp
Copy link
Contributor

What is the advantage of keeping the current behavior?

For instance, if for an object o with type int I do o as String, it will always be a runtime error.

If we use o as< String it will be a compile-time error, which I think is what anyone would expect from the start.

What is the advantage of keeping o as String as a valid construct that throws at runtime, instead of making o as String the same as o as< String?

@eernstg
Copy link
Member Author

eernstg commented Sep 2, 2024

One thing is breakage: Type tests and type casts are extremely common, and it is likely to cause massive breakage if the plain as and is expressions are suddenly checked according to a more strict rule than they used to be.

If we had no history then perhaps the plain as should be used for downcasts, and then some other syntax could enable other kinds of casts. It might be worthwhile to handle the breakage (it should not be hard to implement an automated fix), but my initial assumption was that the breakage would be unacceptable.

You could also argue that the general kind of type cast (and type test) should use the simplest syntax, and the constrained forms should be requested explicitly by writing an extra character or two. Basically, everybody should be able to use the straightforward syntax; then, as in many other cases, if you want more checks then you'd write more code in order to specify exactly which ones.

In any case, o as String could be linted because it might be statically recognized as an expression that throws (and you should just write throw ... if that's what you want). But the more typical case is probably that it isn't totally impossible for a weird cast to succeed (e.g., someone could have written a class which is both a Widget and a Future<Widget>, and stuff like that).

So the proposal isn't quite like "keep a lot of expressions around as non-errors at compile time, even though they will throw at run time", it's more like "given that we can't usually know whether a cast is legitimate, let's preserve the existing syntax and semantics, and offer some safer variants using a bit of extra syntax".

@lrhn
Copy link
Member

lrhn commented Sep 2, 2024

There would be no difference in runtime behavior between is and is<. The difference would be that is would never promote, is< would be a compile-time error if it cannot promote (second operand is not a proper subtype of static type of first operand), and is<= would be a compile-time error if the second operand is not a subtype of the static type of the first operand (but not necessarily proper subtype).

Basically, it's a way to express what the author expects of the existing relation between the types, before doing a type check. If that expectation isn't true, they get a compile-time error, so it's an extra validation before doing a type check.

The as, as< and as<= would do the same, document the expected existing relation between the types, then do as as normal at runtime. We may need as> and as>= too, for up-casts. Sometimes you need to change the type of an expression.

I'm not sure all the variants are really worth it. Especially the distinction between proper and non-proper downcast is going to be very confusing to a lot of people. (And conceptually, going from FutureOr<Object?> to Object? feels like a downcast, after all the definition of a union type is that it's a supertype of each part type. Include type variables, extension type erasure, and people thinking about what happens at runtime, not compile time, and it'll be foot-guns all the way down. What is is< really saying then?
I'd just go for is< and is>, which allows non-proper down-/up-casts respectively.
I'd even be happy with is~ which just requires the types to be either subtype or supertype related.

Another alternative is to have a second, different, syntax and have that only do (not necessarily proper) downcasts, like the often suggested .as<type> suffix/selector operator. If that one can only do downcasts, or at least "related casts" (down or up), then that will likely be sufficient. (I want it anyway, along with .await, to make selectors easier to work with.)

(Which is partly also because I'm not fond of the is< syntax. Might as well introduce <: directly as an operator then!)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
small-feature A small feature which is relatively cheap to implement. type-queries Issues about enhanced versions of `is` and `as` expressions and other run-time type queries
Projects
None yet
Development

No branches or pull requests

3 participants