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.

Clock 6 min read Calendar Published: 5 Nov 2021
Author Cameron McLoughlin
Migrating a Flutter app to Null Safety

In 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 be null. 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, the Never 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 nullable T
  • 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 an enum 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 type Never to tell the compiler that
  • late and late final fields - null safety checks can be deferred to runtime with the late keyword. This also allows late 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!

Visit our careers page
Time for a simpler, smarter note taking accommodation?

Time for a simpler, smarter note taking accommodation?

Glean is the online note taking tool that makes compliance simple, reduces cost and admin burden, and improves student outcomes.
Learn More