Dart’s null safety gives you a strong contract, but there are still ways to weaken it in practice.
Flutter’s sound null safety is one of Dart’s most valuable features. When the compiler warns that a value might be null, it is not being overly strict. It is pointing out a real edge case that can surface in production if ignored.
However, null safety does not enforce your business logic. You can satisfy the type system using ?? '', !, or chained ?. operators while still introducing subtle bugs that are often harder to trace than a direct crash like Null check operator used on a null value.
These patterns around nullable fields can quietly build up as technical debt. They may feel convenient or safe at the moment, but they often create hidden problems that show up later in production.
1. Using ?? '' as a silent pass through to repository calls
This is one of the most common patterns and also one of the riskiest.
// ❌ The compiler is satisfied. The API is not.
final result = await profileRepo.updatePhone(phone: user.phone ?? '');
At first glance, this seems safe because the null case is handled. In reality, you have replaced a type safety issue with a semantic one. Most backends do not treat an empty string the same as a missing value.
This can lead to:
- Overwriting an existing phone number with an empty string
- Triggering backend validation errors only at runtime
- Passing validation silently while corrupting stored data
The fallback ?? '' creates a false sense of safety. It tells the compiler everything is fine, but it hides the fact that important information is missing. The null value was meaningful because it indicated the user has not provided a phone number, and that meaning is lost.
A clearer and safer approach is to explicitly handle the null case
// ✅ Explicit handling makes intent clear
final phone = user.phone;
if (phone == null) {
// Show validation error, log, or stop execution
return;
}
final result = await profileRepo.updatePhone(phone: phone);
Now Dart promotes phone to a non nullable String, and your intent is explicit. There is no ambiguity about what should happen when the value is missing.
The same issue appears with required IDs
// ❌ Hides missing required data
await orderRepo.fetchDetails(orderId: order.id ?? '');
// ✅ Explicit handling of missing value
final orderId = order.id;
if (orderId == null) {
// Handle error state appropriately
return;
}
await orderRepo.fetchDetails(orderId: orderId);
2. Using ! on Text Widget Values
The ! operator is a runtime assertion that says “I am certain this value is not null.” In a Text widget, that certainty is only checked at render time. That can happen after async updates, after a rebuild, after a logout that clears the user model, or while data is still loading.
// ❌ Crashes with "Null check operator used on a null value"
// when the user model is cleared or before profile data loads
Text(viewModel.user!.name!)
When this fails, the error happens inside the build method. Flutter cannot recover cleanly from that, so you get a red screen in development and potentially a broken or blank UI in production.
A safer approach is to provide a meaningful fallback
// ✅ Safe fallback for UI state
Text(viewModel.user?.name ?? 'Guest')
The fallback value like 'Guest' is appropriate here because UI can safely display a default state when data is missing. This is different from backend or repository calls where missing data usually has business meaning.
The key distinction is this: using ?? '' in API calls can silently corrupt data, while using ?? 'Guest' in UI is a deliberate presentation choice.
The same rule applies to widgets that require non-null values
// ❌ Unsafe forced unwrapping
Image.network(product.imageUrl!)
if (product.imageUrl != null) {
Image.network(product.imageUrl!)
} else {
const PlaceholderImage()
}
3. Passing null optional fields in query parameters and request payload
When building a Map<String, dynamic> for a GET request with filters or a PATCH request, you need to be intentional about which keys are included. Many REST APIs treat a key with a null value differently from a missing key entirely.
A null value can mean “clear this field” in some backends, or it can trigger validation errors. A missing key usually means “do not change this field.”
❌ Unsafe approach: sending null values
// ❌ Sends {"status": null, "category": null, "page": 1}
// Backend may interpret null as "clear this field"
final params = {
'status': filter.status,
'category': filter.category,
'page': page,
};
The same issue can happen when using `json_serializable` if null inclusion is not configured properly.
// ❌ includeIfNull: true will include null fields in the request body
Safe approach: include only non-null values
// ✅ Only include values that exist
final params = <String, dynamic>{
'page': page,
};
if (filter.status != null) {
params['status'] = filter.status;
}
if (filter.category != null) {
params['category'] = filter.category;
}
Cleaner approach: remove nulls from a map
extension MapNullStrip on Map<String, dynamic> {
Map<String, dynamic> withoutNulls() =>
Map.fromEntries(entries.where((e) => e.value != null));
}
final params = {
'status': filter.status,
'category': filter.category,
'page': page,
}.withoutNulls();
Using Dio with query parameters
In Dio, query parameters are passed using queryParameters. The same rule applies: avoid sending null values.
final response = await dio.get(
'/orders',
queryParameters: {
'status': filter.status,
'category': filter.category,
'page': page,
}.withoutNulls(),
);
This ensures only meaningful filters are sent to the backend.
Using Dio with request body (POST or PATCH)
For request payload, the same principle applies.
final response = await dio.patch(
'/profile',
data: {
'phone': phone,
'bio': bio,
'avatar': avatarUrl,
}.withoutNulls(),
);
Using json_serializable for PATCH requests
For PATCH semantics, you usually want only changed fields to be sent. You can enforce this at the model level.
@JsonSerializable(includeIfNull: false)
class UpdateProfileRequest {
final String? phone;
final String? bio;
UpdateProfileRequest({
this.phone,
this.bio,
});
}
This ensures only non-null fields are serialized.
4. Null checking after navigation, not before
In Flutter, navigation passes data through route arguments or constructor parameters. When something is null, the crash often happens in the destination screen, not where the mistake was made. This makes debugging harder because the source of the problem is separated from the failure point.
❌ Unsafe approach: forcing null with !
// ❌ Crashes inside RideDetailsScreen if order.id is null
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => RideDetailsScreen(orderId: order.id!),
),
);
This pushes the responsibility of handling null into the widget tree. If it fails, the error shows up far away from where the bug was introduced.
✅ Safer approach: validate before navigation
final orderId = order.id;
if (orderId == null) {
// Show error, log issue, or stop navigation
return;
}
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => RideDetailsScreen(orderId: orderId),
),
);
This ensures that RideDetailsScreen always receives a valid non nullable value. The screen stays simple and does not need defensive null checks inside its constructor or UI.
The key idea is that the decision about whether navigation is allowed should happen before navigation, not after.
The same pattern with go_router
// ❌ Can lead to invalid or unintended route values
context.push('/order/${order.id}');
✅ Safer approach: validate before navigation
final id = order.id;
if (id == null) return;
context.push('/order/$id');
5. Silent ?? 0 on numeric business fields
Using ?? 0 as a fallback for null numeric values is often misleading in real business logic. While it prevents crashes, it can silently change the meaning of your data.
❌ Unsafe examples: hiding missing data as zero
// ❌ Shows ₦0.00 when price is null, which looks like a free product
Text('₦${product.price ?? 0}')
// ❌ Treats missing values as zero and silently changes the total
final total = cartItems.fold(
0.0,
(sum, item) => sum + (item.price ?? 0) * (item.quantity ?? 0),
);
// ❌ Replaces missing ID with a fake value, which can create invalid records
await api.assignRider(userId: rider.id ?? -1);
The core problem is that null is meaningful. It usually represents missing, unknown, or not yet loaded data. Replacing it with zero removes that meaning.
Ask the right question: what does null actually mean here
In most applications, null typically means one of the following:
- The data is still loading, so the UI should show a loading or placeholder state
- The field is optional, so absence should be handled explicitly
- Something went wrong, so an error state should be shown instead of a default value
✅ Safer approach: handle missing values explicitly
// ✅ Clear UI state for missing price
if (product.price == null) {
const Text('Price unavailable');
} else {
Text('₦${product.price!.toStringAsFixed(2)}');
}
// ✅ Validate before doing calculations
final price = item.price;
final quantity = item.quantity;
if (price == null || quantity == null) {
// Skip item, log issue, or handle error state
return;
}
final lineTotal = price * quantity;
Using ?? 0 is only safe when zero is a truly meaningful default value, such as a display count.
For example:
Text('${product.reviewCount ?? 0} reviews')
In all other cases, especially calculations or API inputs, null should be handled explicitly so that missing data is not silently turned into incorrect business logic.
6. Chaining ?. Without a Final Guard
The safe navigation operator ?. is convenient, but chaining it silently carries null through your code until it hits something that requires a non-null which is often a widget, a map widget, or an API call.
// ❌ lat and lng are double? — passed to a function expecting double
// No crash, but moveCamera silently does nothing or behaves unexpectedly
final lat = order?.rider?.location?.lat;
final lng = order?.rider?.location?.lng;
moveCamera(lat, lng); // What does your map do with null coordinates?
// ❌ Setting a StreamController that might be null — silent no-op
viewModel.order?.id?.let((id) => trackingService.subscribe(id));
The rule: once you've chained ?., validate the final result before use.
// ✅ Validate before use
final lat = order?.rider?.location?.lat;
final lng = order?.rider?.location?.lng;
if (lat == null || lng == null) {
// Show "locating rider..." or return
return;
}
moveCamera(lat, lng);
For deeply nested optional objects that you access frequently, consider a dedicated getter on the model that handles the chain in one place:
extension OrderLocation on Order? {
LatLng? get riderLatLng {
final lat = this?.rider?.location?.lat;
final lng = this?.rider?.location?.lng;
if (lat == null || lng == null) return null;
return LatLng(lat, lng);
}
}
// Call site is clean and explicit
final position = currentOrder.riderLatLng;
if (position == null) return;
moveCamera(position);
7. Treating nullable booleans as binary values
A bool? is not just true or false. It has three possible states: true, false, and null. The null value usually represents “unknown” or “not yet loaded.” When you use ?? false, you collapse these states into one, and the UI loses important meaning.
❌ Unsafe approach: hiding the unknown state
// ❌ "Not verified yet" and "explicitly unverified" look the same
if (user.isVerified ?? false) {
showVerifiedBadge();
}
// ❌ A loading or unknown state is treated as false
ElevatedButton(
onPressed: (viewModel.canProceed ?? false) ? onPressed : null,
)
The issue here is not just correctness, but ambiguity. The UI can no longer distinguish between:
- The user is not verified
- The verification status has not been loaded yet
These are different states, but they render the same.
The fix depends on what null actually means
Case 1: null means data is still loading
In this case, you should reflect that explicitly in the UI.
// ✅ Show loading state when value is unknown
switch (user.isVerified) {
case null:
return const CircularProgressIndicator();
case true:
return const VerifiedBadge();
case false:
return const UnverifiedBadge();
}
Case 2: null means a distinct business state
If null represents something meaningful like “unknown” or “not applicable,” then it should be modeled explicitly instead of relying on nullable booleans.
// ✅ Explicit model removes ambiguity
enum VerificationStatus { verified, unverified, unknown }
class User {
final VerificationStatus verificationStatus;
User({this.verificationStatus = VerificationStatus.unknown});
}
Now the UI becomes self explanatory and type safe.
When is ?? false acceptable
Using ?? false is only safe when null and false are truly equivalent in meaning. For example, feature flags where absence means disabled.
Even then, that decision should be intentional, not automatic.
Nullable booleans are not just a convenience type. They represent a third state that often carries important meaning. Flattening them into true or false too early can hide real application states and lead to incorrect UI behavior.
Null safety ensures the compiler flags potential null issues, but it is still your responsibility to define what null means in the context of your application. Good null handling shifts that responsibility from “making the code compile” to “making the intent clear.” Your code should communicate to the next developer and your future self what the absence of a value represents and how it should be handled.
When you reach for ?? '', !, or long chains of ?., it is worth stopping to ask a simple question: what does null represent here, and is this fallback preserving or destroying that meaning?
That moment of clarity is where most null related technical debt is either introduced or avoided.
Top comments (0)