Dart Null Safety: The Ultimate Guide to Non-Nullable Types

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.

Null references: the billion dollar mistake
Null references: 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:

Migration resources:

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:

Want More?

Invest in yourself with my high-quality Flutter courses.

Flutter Foundations Course

Flutter Foundations Course

Learn about State Management, App Architecture, Navigation, Testing, and much more by building a Flutter eCommerce app on iOS, Android, and web.

Flutter & Firebase Masterclass

Flutter & Firebase Masterclass

Learn about Firebase Auth, Cloud Firestore, Cloud Functions, Stripe payments, and much more by building a full-stack eCommerce app with Flutter & Firebase.

Flutter Animations Masterclass

Flutter Animations Masterclass

Master Flutter animations and build a completely custom habit tracking application.