The introduction of Null Safety marks a major milestone for the Dart language. Null Safety helps you avoid an entire class of problems by catching null errors during development rather than at runtime.
This article outlines what's changed, and shows how to use the new Null Safety features by example.
Null Safety is available as a stable release with Flutter 2.0, and is enabled by default for all projects created with Flutter 2.2. You can try it with Dartpad.
Some Context
Null References were first introduced in 1965 in the ALGOL programming language, and since then they have been adopted by most mainstream programming languages.
However, null errors are so common that null references have been called the The Billion Dollar Mistake.
So let's see what's changed in Dart to address this.
Dart Type System
Before addressing Null Safety, let's talk about the Dart type system.
Dart is said to have a sound type system. When we write Dart code, the type checker makes sure that we can't write something like this:
int age = "hello world"; // A value of type `String` can't be assigned to a variable of type `int`
This code produces an error telling us that "a String
value can't be assigned to a variable of type int
".
Similarly, when we write a function in Dart, we can specify a return type:
int square(int value) {
return value * value;
}
Because of type safety, Dart can guarantee with 100% confidence that this function always returns an int
.
Type safety help us write safer programs, and more easily reason about the code.
But type safety alone can't guarantee that a variable (or return value) is not null
.
As a result this code compiles, but generates an exception at runtime:
square(null);
// Unhandled Exception: NoSuchMethodError: The method '*' was called on null.
In this example it's easy enough to spot the problem. But in large codebases it's hard to keep track of what can and cannot be null
.
Runtime null
checks can mitigate the problem, but they add more noise:
int square(int value) {
assert(value != null); // for debugging
if (value == null) throw Exception();
return value * value;
}
What we really want here is to tell Dart that the value
argument should never be null
.
A better solution is needed - and now we have it. 😎
Dart Null Safety: Benefits
Dart 2.12 enables Sound Null Safety by default and brings three main benefits:
- We can write null-safe code with strong compile-time guarantees. This makes us productive because Dart can tell us when we're doing something wrong.
- We can more easily declare our intent. This leads to APIs that are self-documenting and easier to use.
- The Dart compiler can optimise our code, resulting in smaller and faster programs.
So let's see how Null Safety works in practice.
Declaring Non-Nullable Variables
The main language change is that all types are now non-nullable by default.
This means that this code doesn't compile:
void main() {
int age; // non-nullable
age = null; // A value of type `Null` can't be assigned to a variable of type 'int'
}
When using non-nullable variables, we must follow one important rule:
Non-nullable variables must always be initialized with non-null values.
If you reason along these lines, it will be easier to understand all the new syntax changes.
Let's revisit this example:
int square(int value) {
return value * value;
}
Here both the value
argument and the return value are now guaranteed to be not null
.
As a result runtime null
checks are no longer necessary, and this code now produces a compile-time error:
square(null);
// The argument type 'Null' can't be assigned to the parameter type 'int'
But if all types are now non-nullable by default, how can we declare nullable variables?
Declaring Nullable Variables
The ?
symbol is what we need:
String? name; // initialized to null by default
int? age = 36; // initialized to non-null
age = null; // can be re-assigned to null
Note: You don't need to initialize a nullable variable before using it. It is initialized to
null
by default.
Here are some other ways of declaring nullable variables:
// nullable function argument
void openSocket(int? port) {
// port can be null
}
// nullable return type
String? lastName(String fullName) {
final components = fullName.split(' ');
return components.length > 1 ? components.last : null;
}
// using generics
T? firstNonNull<T>(List<T?> items) {
// returns first non null element in list if any
return items.firstWhere((item) => item != null);
}
Take away: you can declare nullable variables anywhere in your code with the
?
syntax.
Nullable variables are a good way of expressing the absence of a value, and this is useful in many APIs.
When you design an API, ask yourself if a variable should be nullable or not, and declare it accordingly.
But there are cases where we know that something can't be null
, but we can't prove it to the compiler. In these cases, the assertion operator can help.
The assertion operator
We can use the assertion operator !
to assign a nullable expression to a non-nullable variable:
int? maybeValue = 42;
int value = maybeValue!; // valid, value is non-nullable
By doing this, we're telling Dart that maybeValue
is not null
, and it's safe to assign it to a non-nullable variable.
Note that applying the assertion operator to a null
value will throw a runtime exception:
String? name;
print(name!); // NoSuchMethodError: '<Unexpected Null Value>'
print(null!); // NoSuchMethodError: '<Unexpected Null Value>'
When your assumptions are wrong, the
!
operator leads to runtime exceptions.
Sometimes we need to work with APIs that return nullable values. Let's revisit the lastName
function:
String? lastName(String fullName) {
final components = fullName.split(' ');
return components.length > 1 ? components.last : null;
}
Here the type system can't help. If we know that the function will return a non-null
value for a given argument, we should assign it to a non-nullable variable as soon as possible.
This is done with the !
operator:
// prefer this:
String last = lastName('Andrea Bizzotto')!;
// to this:
String? last = lastName('Andrea Bizzotto');
In summary:
- Try to create non-nullable variables when possible, as these will be guaranteed to be not
null
at compile time. - If you know that a nullable expression won't be
null
, you can assign it to a non-nullable variable with the!
operator.
Flow Analysis: Promotion
Dart can make your life easier by taking into account null
checks on nullable variables:
int absoluteValue(int? value) {
if (value == null) {
return 0;
}
// if we reach this point, value is non-null
return value.abs();
}
Here we use an if
statement to return early if the value
argument is null
.
Beyond that point, value
cannot be null
and is treated (or promoted) to a non-nullable value. Hence we can safely use value.abs()
rather than value?.abs()
(with the null-aware operator).
Similarly, we could throw an exception if the value is null
:
int absoluteValue(int? value) {
if (value == null) {
throw Exception();
}
// if we reach this point, value is non-null
return value.abs();
}
Once again, value
is promoted to a non-nullable value, and the null-aware operator ?.
is not needed.
In summary:
- Use upfront null checks to return early or throw exceptions
- After null checks, nullable variables are promoted to be non-nullable
And after a nullable variable has been null checked, Dart lets you use it as a non-nullable variable, which is quite nice.
Flow Analysis: Definite Assignment
Dart knows where variables are assigned and where they're read.
This example shows how to initialize a non-nullable variable after checking for a condition:
int sign(int x) {
int result; // non-nullable
print(result.abs()); // invalid: 'result' must be assigned before it can be used
if (x >= 0) {
result = 1;
} else {
result = -1;
}
print(result.abs()); // ok now
return result;
}
As long as a non-nullable variable is given a value before it's used, Dart is happy.
Using non-nullable variables with classes
Instance variables in classes must be initialized if they are non-nullable:
class BaseUrl {
String hostName; // Non-nullable instance field 'hostName' must be initialized
int port = 80; // ok
}
If a non-nullable instance variable can't be initialized with a default value, set it with a constructor:
class BaseUrl {
BaseUrl(this.hostName);
String hostName; // now valid
int port = 80; // ok
}
Non-nullable named and positional arguments
With Null Safety, non-nullable named arguments must always be required or have a default value.
This applies to regular methods as well as class constructors:
void printAbs({int value}) { // 'value' can't have a value of null because of its type, and no non-null default value is provided
print(value.abs());
}
class Host {
Host({this.hostName}); // 'hostName' can't have a value of null because of its type, and no non-null default value is provided
final String hostName;
}
We can fix the code above with the new required
modifier, which replaces the old @required
annotation:
void printAbs({required int value}) {
print(value.abs());
}
class Host {
Host({required this.hostName});
final String hostName;
}
And when we use the above APIs, Dart can tell us if we're doing something wrong:
printAbs(); // The named parameter 'value' is required, but there's no corresponding argument
printAbs(value: null); // The argument type 'Null' can't be assigned to the parameter type 'int'
printAbs(value: -5); // ok
final host1 = Host(); // The named parameter 'hostName' is required, but there's no corresponding argument
final host2 = Host(hostName: null); // The argument type 'Null' can't be assigned to the parameter type 'String'
final host3 = Host(hostName: "example.com"); // ok
On the flip side, if we use nullable instance variables we can omit the required
modifier (or the default value):
class Host {
Host({this.hostName});
final String? hostName; // nullable, initialized to `null` by default
}
// all valid cases
final host1 = Host(); // hostName is null
final host2 = Host(hostName: null); // hostName is null
final host3 = Host(hostName: "example.com"); // hostName is non-null
Positional parameters are subject to the same rules:
class Host {
Host(this.hostName); // ok
final String hostName;
}
class Host {
Host([this.hostName]); // The parameter 'hostName' can't have a value of 'null' because of its type, and no non-null default value is provided
final String hostName;
}
class Host {
Host([this.hostName = "www.codewithandrea.com"]); // ok
final String hostName;
}
class Host {
Host([this.hostName]); // ok
final String? hostName;
}
Between nullable and non-nullable variables, named and positional arguments, required and default values, there's a lot to take in. If you're confused, remember the golden rule:
Non-nullable variables must always be initialized with non-null values.
To fully understand all the Null Safety features, practice using them with Dartpad. Dart will tell if you're doing something wrong - so read the error messages carefully. 🔍
Null-aware cascade operator
To deal with Null Safety, the cascade operator now gains a new null
-aware variant: ?..
. Example:
Path? path;
// will not do anything if path is null
path
?..moveTo(0, 0)
..lineTo(0, 2)
..lineTo(2, 2)
..lineTo(2, 0)
..lineTo(0, 0);
The cascade operations above will only be executed if path
is not null
.
The null-aware cascade operator can short-circuit, so only one ?..
operator is needed at the beginning of the sequence.
Null-aware subscript operator
Up until now, checking if a collection was null
before using the subscript operator was verbose:
int? first(List<int>? items) {
return items != null ? items[0] : null; // null check to prevent runtime null errors
}
Dart 2.9 introduces the null
aware operator ?[]
, which makes this a lot easier:
int? first(List<int>? items) {
return items?[0];
}
The late keyword
Use the late
keyword to initialize a variable when it is first read, rather than when it's created.
A good example is when initializing variables in initState()
:
class ExampleState extends State {
late final TextEditingController textEditingController;
@override
void initState() {
super.initState();
textEditingController = TextEditingController();
}
}
Even better, initState()
can be removed altogether:
class ExampleState extends State {
// late - will be initialized when first used (in the build method)
late final textEditingController = TextEditingController();
}
It's common to use late
in combination with final
, to defer the creation of read-only variables to when they are first read.
This is ideal when creating variables whose initializer does some heavy work:
late final taskResult = doHeavyComputation();
When used within a function body, late
and final
can be used like this:
void foo() {
late final int x;
x = 5; // ok
x = 6; // The late final local variable is already definitely initialized
}
Though I don't recomment using late variables this way. Because this style can result in non-obvious runtime errors. Example:
class X {
late final int x;
void set1() => x = 1;
void set2() => x = 2;
}
void main() {
X x = X();
x.set1();
print(x.x);
x.set2(); // LateInitializationError: Field 'x' has already been initialized.
print(x.x);
}
By declaring a non-nullable late
variable, we promise that it will be non-null at runtime, and Dart helps us with some compile-time guarantees.
But I recommend to only use late
sparingly, and to always initialize late
variables when they are declared.
Static and global variables
All global variables must now be initialized when they are declared unless they are late
:
int global1 = 42; // ok
int global2; // The non-nullable variable 'global2' must be initialized
late int global3; // ok
The same applies to static class variables:
class Constants {
static int x = 10; // ok
static int y; // The non-nullable variable 'y' must be initialized
static late int z; // ok
}
But as I said before, I do not recommend using late
this way as it can lead to runtime errors.
Conclusion
Null Safety is a major change for the Dart language, and it helps you write better and safer code, as long as you use it correctly.
For new projects, keep in mind all the syntax changes we covered. This may seem like a lot to take in, but it all boils down to this:
Every time you declare a variable in Dart, think about whether it should be nullable or not. This is extra work, but it will lead to better code and Dart will help you along the way.
For existing projects created before Null Safety, things are a bit more tricky and you have to migrate your code first. This involves multiple steps that should be followed in the right order. Here's a practical case study showing how to do this:
Additional Resources
This article was mainly inspired by these sources:
- Announcing Dart 2.12
- What is Null Safety in Dart? | Stack Overflow
- Sound null safety | dart.dev
- Understanding null safety | dart.dev
Migration resources:
- Migrating to null safety | dart.dev
- Null Safety Migration Tooling | pub.dev
- How to migrate Dart code to Non-nullable (NNBD)? | Stack Overflow
The Complete Dart Developer Guide
If you want to learn Dart in-depth with a more structured approach, consider taking my complete Dart course. This covers all the most important language features, including exercises, projects and extra materials: