Null safety is most useful when you treat it as a modeling tool rather than a collection of operators.
The central question is not “How do I silence this nullable-type error?” It is “Can this value be absent in the real domain?” When the type answers that question accurately, the compiler can catch many mistakes before the application runs.
This guide builds that mental model, then applies it to a Flutter feature that receives imperfect data from an API.
Nullable and non-nullable types#
In null-safe Dart, a variable does not accept null unless its type says so:
String framework = 'Flutter';
String? nickname;
String describes a string value. String? describes either a string or null. That distinction is enforced at compile time:
String framework = null; // Compile-time error.
The guarantee is sound: if a value has a non-nullable type, Dart can rely on it not being null-unless code explicitly opts out of safety with features such as the null assertion operator.
Flow analysis does more than null checks#
Dart understands control flow and promotes a nullable value after you prove it is present:
String greetingFor(String? name) {
if (name == null) return 'Hello, guest';
// name is promoted from String? to String here.
return 'Hello, ${name.trim()}';
}
The early return makes the remaining path safe. This is often clearer than forcing a value with !.
Promotion also works with pattern matching:
String displayName(String? name) => switch (name) {
final value? when value.trim().isNotEmpty => value.trim(),
_ => 'Guest',
};
The null-aware toolbox#
Dart includes a small set of operators for code that genuinely accepts absence.
Read conditionally with ?.#
final int? length = nickname?.length;
If nickname is null, the expression evaluates to null instead of calling length.
Provide a fallback with ??#
final label = nickname ?? 'Guest';
The right-hand side runs only when the left-hand side is null.
Assign a fallback with ??=#
String? cachedName;
cachedName ??= 'Guest';
This assigns only when the variable is currently null.
Expand nullable collections safely#
final List<String>? secondaryActions = loadSecondaryActions();
final actions = [
'Save',
...?secondaryActions,
];
The null-aware spread adds the values only when the collection exists.
required controls presence, not nullability#
Named parameters are optional by default. required makes the caller supply one:
class UserCard extends StatelessWidget {
const UserCard({
required this.name,
required this.subtitle,
super.key,
});
final String name;
final String? subtitle;
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(name),
subtitle: switch (subtitle) {
final value? => Text(value),
null => null,
},
);
}
}
Both parameters are required at the call site, but subtitle may still be null because its type is String?. Presence and nullability are separate decisions.
Use ! as a checked assertion#
The null assertion operator converts T? to T and throws at runtime if the value is null:
String? token;
print(token!.length); // Throws if token is null.
! is appropriate when an invariant is guaranteed outside the type system-for example, a framework callback that can only run after initialization. It is risky when used merely to make an analyzer error disappear.
Before using it, ask whether you can:
- promote the value with a null check;
- return early;
- use
?.or??; - redesign the API so the value is non-nullable where it is required.
Understand the cost of late#
late delays initialization of a non-nullable variable:
late final AnimationController controller;
void initialize(TickerProvider vsync) {
controller = AnimationController(vsync: vsync);
}
Reading the variable before it is initialized throws a LateInitializationError. Assigning a late final value twice also throws.
In a Flutter State object, lifecycle-managed fields are a reasonable use:
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Prefer constructor initialization or declaration-time values when possible. late moves a compile-time obligation into runtime, so it should represent a real lifecycle constraint.
Model missing data honestly#
An empty string and null do not always mean the same thing. Consider a user returned by an API:
class User {
const User({
required this.id,
required this.email,
required this.firstName,
this.lastName,
});
final String id;
final String email;
final String firstName;
final String? lastName;
String get fullName => switch (lastName?.trim()) {
final value? when value.isNotEmpty => '$firstName $value',
_ => firstName,
};
}
If the API distinguishes “not supplied” from an explicitly empty value, String? preserves that information. If the domain treats both cases identically, normalizing to '' at the boundary may be simpler.
Parse untrusted JSON at the edge#
Null safety cannot guarantee that a remote server sends valid data. dynamic values from decoded JSON still require validation.
factory User.fromJson(Map<String, dynamic> json) {
final id = json['id'];
final email = json['email'];
final firstName = json['first_name'];
if (id is! String || email is! String || firstName is! String) {
throw const FormatException('Invalid user payload');
}
final lastName = json['last_name'];
return User(
id: id,
email: email,
firstName: firstName,
lastName: lastName is String ? lastName : null,
);
}
After this boundary, the rest of the application works with a trustworthy User instead of repeatedly casting map values.
Render the model without defensive clutter#
class UserProfileView extends StatelessWidget {
const UserProfileView({required this.user, super.key});
final User user;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(user.fullName),
Text(user.email),
if (user.lastName case final lastName?)
Text('Last name: $lastName'),
],
);
}
}
Because parsing and fallback rules live at the model boundary, the UI can concentrate on presentation.
Common null-safety mistakes#
Using ! after every nullable expression#
Repeated assertions hide uncertainty rather than resolving it. Narrow the value once or redesign the boundary.
Making every API field nullable#
If a field is required for the application to function, reject an invalid payload instead of allowing uncertainty to spread through every layer.
Replacing meaningful absence with magic defaults#
Using 0, '', or an empty list can erase useful domain information. Choose defaults only when they represent the same meaning as absence.
Using late as a general escape hatch#
If initialization can happen in the constructor, do it there. Use late when lifecycle ordering makes delayed initialization necessary.
Trusting dynamic#
Null safety is strongest after external data has been validated and converted into typed objects. Keep dynamic at the boundary.
Design the type before choosing the operator#
Dart's null-safety model gives the compiler strong guarantees, but good results still depend on honest types.
Use non-nullable types for required values. Use nullable types when absence is part of the domain. Validate external data once. Prefer flow analysis to assertions. Reserve late for genuine lifecycle constraints.
When the model is accurate, null safety stops feeling like extra syntax. It becomes an active design partner.