Migrating a Flutter app to Null Safety
In this post, Team Glean’s Cameron talks us through the benefits and drawbacks of the ‘null safety’ upgrade to Dart.
6 min read Published: 5 Nov 2021In March 2020, Flutter 2.0 was released, with Dart 2.12 alongside it. This brought "null safety" to the stable channel, the largest upgrade to Dart since the 2.0 release introduced a sound type system. The basic premise of "null safety" is to eliminate null reference errors at runtime, and instead catch them at compile time.
The basics
For those not familiar with the change, here's a quick refresher:
- Types are now "non null by default". A
String
is now not allowed to benull
. If you do want to allow null, you have to explicitly mark a type as nullable (e.g.String? string = null;
) - You cannot call a method on an expression with a nullable type:
nullableString.length()
is now a compile time error - Some other smaller features:
!
to convert to a non-null type,late
for lazily initialized fields, theNever
type, and some general type system improvements
// before null safety
String name = 'Cameron';
print(name.length); // prints 7
name = null;
print(name.length); // throws a runtime error
// with null safety
String name = 'Cameron';
print(name.length); // prints 7
name = null; // compile time error, null is not a valid String
String? nullableName = null;
print(nullableName.length); // compile time error, nullableName might be null
Dart 3.0?
At first this sounds like a massively breaking change. Prior to null safety, all types were nullable, and now, none of them are (unless you add a ?
). But this was released as Dart 2.12, not Dart 3.0. This is because of Dart's ability to run "mixed-mode" programs, i.e. programs which contained some null safe code, and some legacy code.
In Glean's case, when we upgraded to Dart 2.12, almost nothing broke (barring a few deprecated APIs). This is because of the "language level".
Every Dart project has a pubspec.yaml
containing metadata about the project (name, description, etc), dependencies, and other configuration. The environment
property allows us to set the project-wide language level. This tells the Dart compiler to essentially act as if it is a different version. For example, our pubspec.yaml
contained the following:
environment:
sdk: ">=2.6.0 <3.0.0"
Which tells Dart to treat this project as if it only knows about Dart 2.6 features. If we changed that to 2.12.0
, we'd find that all of a sudden we'd hit all those null
-related errors. But crucially, since it's independent of the actual version of the compiler we're using, it's not a breaking change.
This might seem like a trivial point, but it enables "library-level language annotations". By putting // @dart=2.12
at the top of a file, we can set the language level to 2.12
(therefore enabling null safety) for only that file.
This is essential to a smooth migration experience. If we had had to migrate the entire codebase at the same time, it would have been infeasible. Every change to the codebase has to go through a pull request process, where it is reviewed by another developer, and usually by a tester. A change this large would be impossible to review. But by incrementally migrating file-by-file, we can keep the PR size down and make it easier to review.
Dependencies also get the same treatment; a project can have null safe and legacy dependencies working together and, for the most part, it just works.
The actual migration
While Dart does provide the dart migrate
tool for automating the migration, it proved fairly unreliable and would frequently crash. However, migrating by hand was still perfectly manageable given it could be done incrementally.
Many files required almost no changes. This might sound strange, but it is by design. When the Dart team were working on ideas for how null safety might work, they had several different ideas:
- An
Option<T>
type that would represent a nullableT
- A special non-nullable type
T!
- Non-nullable-by-default types (the actual system used)
The option type idea was fairly quickly dismissed: Dart doesn't support union types (with a few exceptions) so any API would likely be unidiomatic.
A special non-nullable type had more merit, but fell flat as well. In this world, legacy types would become nullable types, and nullable types can't have any methods called on them. This would mean that every single time any code wrote foo.bar()
, it would be an error until migrated. This would make migration far too cumbersome.
Non-nullable-by-default (NNBD) types were settled on because of a couple of key observations: Most of the time, a non-nullable type is correct Most existing Dart code is correct at runtime anyway
It turns out that by leveraging an existing Dart feature, they could find a way to turn dynamically correct legacy code into statically correct null-safe code. This feature is called "type promotion" and allows you to treat a type as a more specific type based on context:
Animal animal = randomAnimal();
if (animal is Dog) {
// animal is now definitely a Dog, we can safely treat it like one
animal.bark();
animal.fetchStick();
goToKennel(animal);
}
This example compiles pre-null-safety. Null safety simply extends this to nullable types:
String? name = randomName();
if (name != null) {
// name is now definitely not null, we can safely call methods on it
print(name.length); // totally fine
}
This is great, because most existing code either has these null checks in it already (so is "already migrated"), or doesn't expect null
to appear at all (so the field can just become non-nullable).
Caveats
One thing that separates Dart's null-safety from other languages is that it is "sound". This has different meanings to different people, but in the context of Dart, it means that: "if a variable has a non-nullable static type, then it is guaranteed to not be null".
This differs from other implementations of non-nullable types. For example, Kotlin has non-nullable types, and it prevents you assigning null
to one, but since Kotlin targets the JVM, it has to deal with its quirks. For example, it can't guarantee that there isn't some Java code somewhere that's putting null
where it shouldn't.
Dart, on the other hand, can be 100% sure that a String
is not null
. This opens avenues for performance improvements, since it allows runtime null-checks to be removed, and numbers can be unboxed (and their operations inlined etc).
However, this means that a variable can only be promoted if there is no way it can ever be null. Unfortunately, this isn't the case for fields of a class. Any field can potentially be overridden by a "getter" (a kind of argument-less function). In fact, it's almost the other way round: a field is just a combination of a getter (and maybe a setter) and a particular implementation of that getter (i.e. a reference to a location in memory). This means that the following code is unsound:
class Foo {
String? string;
void foo() {
if (string != null) {
print(string.length); // doesn't compile
}
}
}
At first glance, it seems like this should work. We've already checked to make sure string
is not null
, so why isn't the compiler letting us do this? Consider the following subclass:
class BadFoo extends Foo {
@override
String? get string => Random().nextBool() ? 'hello' : null;
}
If we call BadFoo().foo()
, we can't know that, just because the first call to string
returns a String
, that means all subsequent calls will. Luckily, we can get around this by just copying it into a local variable, since local variables are understood by the compiler to be stable in this regard.
The actual *actual* migration
As mentioned earlier, most code required almost no changes:
class EventTile extends StatelessWidget {
final Event event;
const Event({Key key, this.event}) : super(key: key);
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(event.name),
subtitle: Text(formatDuration(event.duration)),
trailing: event.isLoaded ? LoadedIcon() : NotLoadedIcon(),
);
}
}
For example, this pre-null-safety widget requires only a single line change (changing the constructor parameters to {Key? key, required this.event}
). The vast majority of cases were like this. Where we had genuinely nullable types, this was usually obvious.
Firstly, we created a custom annotation much earlier that we would mark nullable expressions with (e.g. @nullable String eventId
). Secondly, if we expected a variable to be null
, there was usually a if (variable == null)
check not too far away, so it was easy to spot which needed an extra ?
.
In the cases where it wasn't obvious, running our tests would usually highlight the issue. And if all else failed, git blame
and a Slack message did the trick.
Benefits
The main benefits are somewhat obvious; it is no longer possible to call methods on null
. This on its own is a huge improvement, and even if this were the only thing null safety provided, it would likely be worth it. But null safety also brings with it several type system improvements:
- Exhaustive enums switches - using a
switch
statment with anenum
now has proper exhaustivity checks, so you don't need to add a default case - An explicit bottom type (
Never
) - if you have expression that is unreachable, you can give it the typeNever
to tell the compiler that late
andlate final
fields - null safety checks can be deferred to runtime with thelate
keyword. This also allowslate final
fields to be assigned to once but never reassigned- Performance improvements - previously, any variable could be null, so virtually every single operation had to have an associated null check. Since Dart's null safety is sound (in the sense that it is impossible for a non nullable expression to end up with a null value), the compiler can omit these checks, since it can prove they do nothing. On a similar note, numeric types can now generally be unboxed, allowing for less indirection and more inlining. Seeing a 20% performance improvement is not uncommon
A final benefit that is often underrated is psychological. It can be stressful to write code in a large codebase that you aren't familiar with. You don't have time to look at every location where the function you're working on is called, but conversely, just adding null checks everywhere is messy and reduces the signal-to-noise ratio of your codebase.
Human beings are not good at managing this sort of information, especially when it's spread out between team members, the occasional code comment, a Confluence document detailing naming conventions for nullable fields, and a readme that hasn't been updated in a few months. The more of this that can be offloaded onto a computer, the better.
It lets you write safer, faster, and more readable code, and you get to have more fun doing it! What's not to like?
Fancy working for Team Glean?
At Team Glean, we're always on the lookout for talented people to join us.
To learn more about working with us, and to see the latest opportunities, follow the link below!
More from Tech Blog
View AllGlean hack week - developing a Minimal Loveable Feature
Our Glean Engineering team recently took time out of their busy schedules to run a hack week, designed to build innovative solutions and unleash their creativity. Engineering Manager, Mala Benn, is here to tell us how they got on.
Dart Type Promotion
In this article, we'll walk you through a common issue with dart type promotion, showing you why it occurs and some ways to solve it.
Exploratory Testing at Glean
Zaryoon shares what Exploratory Testing is, and how it can be implemented in your tech teams to gain a deeper understanding of how your features work.