Stop Using Null Assertions in Flutter
Alternatives to Null Assertions in Dart
In most programming languages, we have some kind of nullability. A variable does not have any value, or it is not initialised.
In many languages, there is some equivalent of a null pointer exception, which is a runtime error that occurs when you try to access a property or method of a null object.
In Dart, we have null safety, which is a feature that helps us avoid null pointer exceptions. Even during development, it will warn us about the nullability of a variable.
However, I see a lot of developers using null assertions (!
) to bypass null safety. It is a way to tell the compiler that you are sure that the variable is not null, even if it is not initialised or it can be null.
Even though it is a feature of the language, I think it is a bad practice to use it. It can lead to runtime errors, and it defeats the purpose of null safety.
I heard some people call it bang operator for a reason. It can ruin your intentions and lead to unexpected behavior.
There are a plethora of alternatives to null assertions, which can help you to avoid null pointer exceptions and make your code more robust.
Here is a run-down of them:
- Do not be afraid to use nullable types, they are there for a reason - you can use the nullable type operator
?
to mark a variable as nullable, likeString? name;
and work with them safely - they are not a bad thing - Use the null-coalescing operator
??
to provide a default value if the variable is null, e.g.String name = user.name ?? 'Guest';
- Use the null-aware assignment operator
??=
to assign a value to a variable only if it is null, e.g.user.name ??= 'Guest';
- or the null-aware operator
?
for widgets?widget
to conditionally render a widget if it is not null or adding items to list[element?]
or to access propertiesconfig?.domain ?? 'localhost'
All examples are in Dart with version 3.8.x - the latest stable version at the time of writing.
Dealing with nullability in methods
Firstly, let’s define a simple object which will appear in our examples. It is a simple configuration object with two nullable properties: domain
and port
.
class Config {
final String? domain;
final int? port;
const Config({this.domain, this.port});
}
Null prohibits you from program execution
This is the simplest scenario.
Let’s say you have a method that takes a nullable parameter and you want to ensure that the parameter is not null before proceeding with the execution.
Response processConfig(Config? config) {
...
}
You have a couple of options for what to do with the config
parameter.
You can use a simple if
statement to check if the config is null and throw an exception or return a default value.
Response processConfig(Config? config) {
if (config == null) {
return Response.noConfig('No config given');
}
// proceed with config
}
Afterwards, the compiler will know that config
is not null and you can safely access its properties.
You should not attempt to throw an exception in such case, because it is not an exceptional situation. It is a normal flow of the program and program should handle it gracefully.
Alternatively, you can use a default value in the input parameter, ensuring it always has a value regardless of what you pass.
Response processConfig({Config config = const Config()}) {
// proceed with config
}
Default value must be a constant.
Nullable properties in variables
Firstly, a variable can be, null and you want to access its properties safely.
Response processConfig(Config? config) {
client.connect(
domain: config?.domain ?? 'localhost',
port: config?.port ?? 8080,
);
}
Here is a combination of the null-aware operator ?.
and the null-coalescing operator ??
.
The null-aware operator ?.
is used to access the domain
and port
properties of the config
object only if it is not null. If it is null, it will return null.
Then we use the null-coalescing operator ??
to provide a default value if the property is null. In this case, if config?.domain
is null, it will use 'localhost'
as the default value, and if config?.port
is null, it will use 8080
.
If you are after more verbose code, another viable option is to use the ternary operator to check if the variable is null and return a default value.
Response processConfig(Config? config) {
client.connect(
domain: config != null ? config.domain : 'localhost',
port: config != null ? config.port : 8080,
);
}
Nullable properties initialisation
If you want to initialize a property only if it is null, you can use the null-aware assignment operator ??=
.
class User {
String? _userName
String get userName {
final name = _generateUserName();
_userName ??= name;
return name;
}
}
The null-aware assignment operator ??=
is used to assign a value to the _userName
property only if it is null. If _userName
is already set, it will not change its value.
It is useful when you want to ensure that the variable has a value, but you do not want to throw an exception or return a default value.
Nullable properties in lists
Only non-null values
Imagine you have a list of domains, and some of them are null. You want to filter out the null values and use the rest.
List<String?> domains = ['example.com', null, 'test.com', null];
List<String> filteredDomains = domains.nonNulls().toList();
Before, it used to be .whereType<String>()
to filter out the null values, but now you can use the nonNulls()
extension method.
During the initialisation of iterables
You can use the null-aware operator ?
to conditionally add an item to a list if it is not null.
final String domain1 = 'example.com';
final String domain2 = null;
final String domain3 = 'test.com';
List<String> domains = [domain1, ?domain2, domain3];
The resulting list will contain only ['example.com', 'test.com']
.
There is no need to use the if
statement to check if the variable is null, because the null-aware operator ?
will do it for you.
Merging nullable lists
If you have two lists and you want to merge them, but some of the items can be null, you can use the nonNulls()
extension method to filter out the null values.
List<String?> domains1 = ['example.com', null, 'test.com'];
List<String?> domains2 = [null, 'example.org', 'test.org'];
List<String> mergedDomains = [...domains1, ...domains2].nonNulls().toList();
Or the whole list can be nullable, and if you want to merge it with another list, you can use the null-aware spread operator ...?
to conditionally spread the list if it is not null.
List<String?>? domains1 = ['example.com', null, 'test.com'];
List<String?> domains2 = [null, 'example.org', 'test.org'];
List<String> mergedDomains = [...?domains1, ...domains2].nonNulls().toList();
Guard clauses and Pattern matching
Guard clauses are a way to handle the nullability of the variable at the beginning of the method.
The first example shown in this article is also an example of a guard clause.
Now, we will use pattern matching.
Response processUserConfig(Config? config) {
if (config case final Config currentConfig) {
// proceed with currentConfig
} else {
return Response.noConfig('No config given');
}
}
Since pattern matching is available, you can also use such syntax in the if statement to check if the variable is not null and assign it to a new variable. However, I prefer to use simple if ( ... != null)
.
If you have more complex logic where you can take multiple routes, then pattern matching is becoming much more useful, mainly thanks to the when
clause.
Response processUserConfig(Config? config) {
switch (config) {
case final Config currentConfig when currentConfig.domain != null:
// proceed with config with non-null domain
case final Config currentConfig:
// proceed with config with null domain
default:
return Response.noConfig('No domain given');
}
}
Suddenly, you can handle the nullability of the variable more elegantly and handle it with multiple cases and conditions.
The same example applies to sealed classes, where you can use the when
clause to handle the nullability of the variable in a more elegant way.
sealed class Config {}
class LocalConfig extends Config {}
class UserConfig extends Config {
final int port;
final String? domain;
UserConfig({required this.domain, required this.port});
}
Response processUserConfig(Config? config) {
switch (config) {
case UserConfig(:final domain) when domain != null:
// proceed with config with non-null domain
case LocalConfig():
// proceed with local host
default:
return Response.noConfig('No config given');
}
}
This way, you can nitpick the nullability of each variable in the sealed class and handle the case accordingly. Moreover, you see all the possible cases in one place, and you can always tweak the behaviour of the method without changing the method signature.
Nullability in widgets
All the examples above apply to widgets as well. You can use the null-aware operator ?
to conditionally render a widget if it is not null.
Null-aware operator in widgets
class MyWidget extends StatelessWidget {
final Widget? widget;
const MyWidget({Key? key, this.widget}) : super(key: key);
@override
Widget build(BuildContext context) {
return Column(
children: [
Text('Hello World'),
?widget
],
);
}
}
Null-aware properties
You can access nullable properties of widgets, like for theme colour or text style.
class MyWidget extends StatelessWidget {
const MyWidget({Key? key) : super(key: key);
@override
Widget build(BuildContext context) {
return Text(
'Hello World',
style: Theme.of(context).textTheme.bodyMedium ?? TextStyle(
color: Colors.black,
fontSize: 16,
),
);
}
}
Or you can go further in properties and use the null-aware operator ?.
to conditionally render a widget if it is not null.
class MyWidget extends StatelessWidget {
const MyWidget({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Text(
'Hello World',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.black,
fontSize: 16,
),
);
}
}
Pattern matching in widgets
You can use pattern matching in widgets as well. For example, you can use it to handle the nullability of the widget and render a different widget based on its type.
class MyWidget extends StatelessWidget {
final Config? config;
const MyWidget({Key? key, this.config}) : super(key: key);
@override
Widget build(BuildContext context) {
return switch (config) {
UserConfig(:final domain) when domain != null => Text('User config with domain: $domain'),
LocalConfig() => Text('Local config'),
_ => Text('No config given'),
};
}
}
When to use null assertions?
Personally, I do not use null assertions at all. Only during development, when I want to quickly fail some method as a test or prove some issue I am trying to solve.
As you can see, there are plenty of alternatives to null assertions, which can help you to avoid null pointer exceptions and make your code more robust.
I think it is a bad practice to use them in production code, because it can lead to runtime errors and defeat the purpose of null safety.
Thanks for reading! If you have any questions or comments, feel free to reach out.