Description
Given the following code:
abstract class Foo {
bool get isLarge;
}
class C {
final List<Foo> foos;
C(this.foos);
Foo get firstLargeFoo => foos.firstWhere(
(foo) => foo.isLarge, orElse: () => null);
}
Migration produces the result:
abstract class Foo {
bool get isLarge;
}
class C {
final List<Foo?> foos;
C(this.foos);
Foo? get firstLargeFoo => foos.firstWhere(
(foo) => foo!.isLarge, orElse: () => null);
}
It's really not obvious why the type of foos
gets changed to List<Foo?>
(even using the tool's "trace" functionality). The reason is that Iterable<T>.firstWhere
's signature is T Function(bool Function(T), {T Function() orElse})
, so any value returned by orElse
must be suitable for insertion in the list (even though firstWhere
won't actually try to insert it in the list). In this example, orElse
returns null
, so the only way the migration tool can see for that to work is if the list is a List<Foo?>
.
It's really unfortunate that the migration tool is changing the type of the list, because that's the sort of thing that can cause nulls to propagate to a large number of other places in the program.
A better migration would be:
abstract class Foo {
bool get isLarge;
}
class C {
final List<Foo> foos;
C(this.foos);
Foo? get firstLargeFoo => foos.cast<Foo?>().firstWhere(
(foo) => foo!.isLarge, orElse: () => null);
}
But in order to figure out that this is possible, we have to apply the knowledge that firstWhere
won't try to insert the value returned by orElse
into the list. (And technically, in order for that to be sound, we'd have to look through the entire program to make sure there isn't an override of firstWhere
that does do that). So if we do this at all, it's probably best to do it as a special-case optimization specific to Iterable.firstWhere
rather than some general case logic.
(Note that this arose concretely when @srawlins was trying to migrate Mockito)