language icon indicating copy to clipboard operation
language copied to clipboard

Implicit coercion through implicit constructors.

Open lrhn opened this issue 1 year ago • 8 comments

Dart has implicit coercion only in very few cases: Implicit call-tearoff, implicit generic instantiation, and (arguably) implicit downcast from dynamic (which doesn't coerce anything, the value doesn't change).

A fairly common pattern, especially when interacting with native JS code, is an argument like dynamic location /* String or Uri */ where the same function can take its URI argument as either a Uri object or a String. Dart does not have union types, and the author didn't want to have two different functions (which can explode combinatorially if there is more than one such parameter), so the only choice was to choose a supertype, and do type testing at runtime.

An alternative would be to introduce a way for a String to be automatically convertible to a Uri. Maybe only locally when using this API, or generally.

The proposal suggests introducing implicit constructors only through static extensions.

An implicit constructor is a constructor marked with the modifier implicit, which must be callable with a single positional argument.

At a point where a the static type, S, of an expression is not otherwise assignable to its context type, Q, and it needs to be, the context type is checked for implicit constructors that would accept the expression's static type.

We look for allowed type declarations of the context type (greatest closure of the context type scheme) as follows:

  • If the context type is FutureOr<V>, the allowed type declarations is the union of the allowed type declarations of Future and of V.
  • If the context type is V?, the allowed type declarations is the union of the allowed type declarations of Null and of V.
  • If the context type is a type variable X with bound B, the allowed type declarations are the allowed type declarations of B.
  • If the context type is a promoted type variable X&B, the allowed type declarations is the union of the allowed type declarations of B and of X.
  • If the context type is the type of a sealed class declaration C, the allowed type declarations is the union of the set {C} and the allowed type declarations of its immediate subtypes.
  • Otherwise if the context type is the type of a class, mixin, mixin class, enum or extension type declaration D, the allowed subtypes is the union of the set {D} and the set of the allowed type declarations of all sub-classes/extension types of D that are declared in, or imported into, the current library. ("Imported into" means in the export scope of an imported library, whether with or without prefix, and not hidden by a show or hide clause, even if the name has been conflicted or shadowed.)

Then find applicable types and constructors

  • For each declaration allowed type declaration D:
    • If the the declaration is accessible from L,
    • For each implicit constructor declaration of D and each available implicit static extension constructor of D, D.id where id is new for the unnamed constructor:
      • If D.id is accessible from L (id is not private to another library),
      • and we can infer type arguments <TArgs> to D (if generic) as we would for T _ = D.id(e) where e has the static type S, s.t. D<Targs> is well-bounded and a subtype of T,
      • then we consider the type D<TArgs> and constructor name D.id as an applicable type and constructor pair.

Now look only at the applicable types and constructor pairs.

One type and constructor pair is more specific than another if:

  • The type of the former is a subtype of the latter, and not vice-versa.
  • Or otherwise (neither type is a subtype of the other, or both are subtypes of each other)
    • The first positional parameter type of the former constructor (instantiated at its type as D<TArgs>.id) is a subtype of the first positional parameter type of the latter constructor (also instantiated at its type), and not vice versa.
    • Or otherwise (neither first positional parameter of the instantiated types is a subtype of the other, or both are subtypes of each other)
      • The first positional parameter type of the former constructor when the type is instantiated to bounds, is a subtype of the the first positional parameter type of the latter constructor when its type is instantiated to bounds, and not vice versa.

If there is precisely one applicable type and constructor pair among the applicable ones that has precedence over all other applicable type and constructor pairs, then the original expression is implicitly coerced by using its value as the argument to the most specific D<TArgs>.id, producing a value with static type D<TArgs> which is a subtype of T and therefore valid.

Otherwise a compile-time error occurs, because S is not assignable to T as required.

We say that a type S is coercible to T, in a specific lexical context and library, if S is assignable, or if there is a single allowed, applicable and most specific implicit constructor which can coerce S to T.

The rules for which sub-classes to look for constructors in uses the ideas of .enumValue shorthands to look for subclasses that are readily available, or implicitly implied by a sealed class. That makes sense because both are looking for a declaration which can provide a value for a context type, which means looking for "reasonably available" subtypes of the context type. If "enum value shorthands" are extended to also work with constructors (it should), and we also get this feature, then you can write either = e for an implicit constructor invocation. or .id(e) for an explicit short-handed constructor invocation, and get the same result. (That's why the two features should use the same rules.)

The "most specific" discrimination tries to follow the pattern of extension resolution:

  • The most specific result type is best.
  • If not, the most specific argument type is presumed to best know how to treat that argument.
  • If not, a specific type parameter type is considered more precise than a generic type that happens to be instantiated to the same type.

If we get implicit static extension constructors, then it'll probably be important to use the same discrimination rules (although I'm not sure they're applied to the same types).

All these rules can be tweaked.

lrhn avatar Apr 14 '24 10:04 lrhn

This would be a game-changer for Jaspr, so I want to present my use-case here for this.

The most frequent feedback I get regarding devex of Jaspr is how verbose / unintuitive the html component syntax is (especially when compared with normal HTML). It currently looks like this:

Component build(BuildContext context) {
  return div([
    div([
      .text('Hello'),
      strong([
        .text('World'),
      ]),
      .text('!'),
    ]),
  ]);
}

There are two things here making the syntax weird:

  1. The frequent ([ / ]) double brackets.
  2. The .text() method, which is a constructor on Component and using dot-shorthands.

Both are due to the fact that each html component (like div) has to accept a List<Component> as children, even though the desired content may be just a single component, a String, or a combination of these.


With this feature, the syntax could look like this:

Component build(BuildContext context) {
  return div(
    div([
      'Hello',
      strong('World'),
      '!',
    ]),
  );
}

Which is a lot more elegant and concise (This is ofc. only a small example. In real-world use the impact is many times bigger).

Here div() just takes a single Component child parameter, and Component has two implicit constructors Component.fragment(List<Component>) and Component.text(String) (which already exist today).


There are other things inside Jaspr that would also benefit from this, specifically the styling API, but the component syntax would get the biggest benefit from this.

Overall it feels like a natural step after dot-shorthands to not only shorten, but fully reduce the constructor invocation for things that are trivially convertible from one type to another.

schultek avatar Nov 13 '25 13:11 schultek

This would be a game-changer for Jaspr, so I want to present my use-case here for this.

This would work great for Flutter too.

Additionally, this would be great for reducing renders for signal based apps. In this example, the entire widget tree rebuilds on every tap because the count is subscribed in the top level build method.

class Counter extends HookWidget {
  Counter();

  Widget build(){
    final count = useSignal(0);
    return Scaffold(
      body: Center(
        child: ElevatedButton(
          onPressed: ()=> count.value = count.value += 1,
          child: Text("${count.value}")
        )
      )
    );
  }
}

The solution for this is to add a SignalBuilder above the Text widget. However this quickly gets tedious for each signal throughout your application.


With this feature, the syntax could look like this:

class Counter extends HookWidget {
  Counter();

  Widget build(){
    final count = useMomoized(() => Signal(0));
    return Scaffold(
      body: Center(
        child: ElevatedButton(
          onPressed: ()=> count.value = count.value += 1,
          child: ()=>Text("${count.value}")
        )
      )
    );
  }
}

By just adding ()=> before a widget, you've drastically reduced renders! A static extension on Widget Function() (and Widget Function(BuildContext) if you'd like ) would implicit create a SignalBuilder. This would make it vastly easier to create performant apps with much less boilerplate.

dickermoshe avatar Nov 13 '25 14:11 dickermoshe

This would also be a great way to support a "union-like"/"overload-like"/"UndefinedOr<T>" use cases.

For instance:

This would also help greatly with dart_mappable. You could define a copyWith that can set fields to null

T copyWith({Value<String?> name = Undefined()}){
  if (name.isDefined){
    // return with new name 
  }
}

copyWith(name: Defined(null)) // Verbose!

copyWith(name: null) // Implicity converted to Defined<T>(null)

By placing a static extension on String?, this could be used to significant improve these usecases

dickermoshe avatar Nov 13 '25 14:11 dickermoshe

Wow I haven't though of that, but yes this could even solve the nullable copyWith problem.

schultek avatar Nov 13 '25 14:11 schultek

@dickermoshe I'm not sure writing copyWith this way is better than what you can currently do with https://github.com/dart-lang/language/issues/314#issuecomment-3058739700 or https://github.com/dart-lang/language/issues/314#issuecomment-3061520426. Your way is a bit easier to implement perhaps, but the end user will still see the wrapper Value<String?> as the argument type suggested in the IDE rather than just String? which is not ideal.

mmcdon20 avatar Nov 13 '25 18:11 mmcdon20

Those are not typesafe

dickermoshe avatar Nov 13 '25 19:11 dickermoshe

@dickermoshe It is typesafe. The end user can pass only the correct types. The use of Object within the implementation is not exposed to the user calling copyWith.

mmcdon20 avatar Nov 13 '25 20:11 mmcdon20

With this feature, the syntax could look like this:

Component build(BuildContext context) { return div( div([ 'Hello', strong('World'), '!', ]), ); } Which is a lot more elegant and concise (This is ofc. only a small example. In real-world use the impact is many times bigger).

This still reads weird even after the change imo.

Wouldn't it be better to have a jsx like syntax ? Isn't that kind of thing possible with an analyzer plugin ? maybe you can have syntax highlight, auto completion and errors for (i don't know):

final content = """
<div class="card">
  <h1>${user.name}</h1>
  <button onclick="${handleClick}">Click</button>
</div>
""";

It seems it's already supported by android studio ?

Image

cedvdb avatar Dec 02 '25 22:12 cedvdb