Null Safety
Understanding Null Safety in Dart
Null reference errors are one of the most common bugs in programming. Tony Hoare, who invented the null reference in 1965, famously called it his "billion-dollar mistake." Dart's sound null safety system eliminates this entire category of bugs at compile time, making your code safer and more reliable.
What is Null?
null represents the absence of a value. Before null safety, any variable in Dart could be null, which led to runtime crashes when you tried to access properties or methods on a null object.
// Before null safety (old Dart) — any variable could be null
String name = null; // This was allowed!
print(name.length); // CRASH! NoSuchMethodError at runtime
// With null safety — the compiler catches this
String name = null; // Compile-time ERROR: can't assign null
print(name.length); // Safe — name is guaranteed non-null
Nullable vs Non-Nullable Types
In Dart with null safety, every type is non-nullable by default. To make a type nullable, add a ? suffix.
// Non-nullable types — CANNOT hold null
String name = 'Alice';
int age = 25;
double height = 5.6;
bool isActive = true;
// name = null; // ERROR: A value of type 'Null' can't be assigned
// Nullable types — CAN hold null
String? nickname = null; // OK
int? score = null; // OK
double? weight; // Defaults to null
bool? hasLicense; // Defaults to null
print(nickname); // null
print(score); // null
String and String? as two different types. String is guaranteed to have a value. String? is a union of String and Null. You cannot use a String? where a String is expected without first checking for null.
The Null-Aware Access Operator (?.)
The ?. operator lets you safely access properties or methods on a value that might be null. If the value is null, the entire expression evaluates to null instead of throwing an error.
String? name = 'Alice';
print(name?.length); // 5
name = null;
print(name?.length); // null (no crash!)
// Chaining null-aware access
class User {
Address? address;
User({this.address});
}
class Address {
String? city;
Address({this.city});
}
User? user = User(address: Address(city: 'Dubai'));
print(user?.address?.city); // Dubai
print(user?.address?.city?.length); // 5
user = null;
print(user?.address?.city); // null (safely handled)
The Null-Coalescing Operator (??)
The ?? operator returns the left-hand value if it is not null; otherwise, it returns the right-hand value. This is perfect for providing default values.
String? input = null;
// Without ?? operator
String result;
if (input != null) {
result = input;
} else {
result = 'default';
}
// With ?? operator — clean and concise
String result = input ?? 'default';
print(result); // default
// Practical examples
String? username = getUsernameFromDB();
String displayName = username ?? 'Anonymous';
int? savedTheme = loadThemePreference();
int theme = savedTheme ?? 0; // Default to light theme
// Chaining ?? for multiple fallbacks
String? firstName;
String? nickname;
String? email;
String display = firstName ?? nickname ?? email ?? 'Unknown User';
The Null-Aware Assignment Operator (??=)
The ??= operator assigns a value to a variable only if that variable is currently null.
int? count;
print(count); // null
count ??= 0; // count is null, so assign 0
print(count); // 0
count ??= 10; // count is NOT null (it's 0), so do nothing
print(count); // 0
// Practical use: lazy initialization
class Config {
Map<String, String>? _cache;
Map<String, String> get cache {
_cache ??= _loadFromDisk(); // Only load once
return _cache!;
}
Map<String, String> _loadFromDisk() {
print('Loading config...');
return {'theme': 'dark', 'lang': 'en'};
}
}
The Null Assertion Operator (!)
The ! operator (called the "bang" operator) tells the compiler: "I know this value is not null." If the value is null at runtime, it throws an exception.
String? name = 'Alice';
String nonNullName = name!; // OK — name is not null
print(nonNullName.length); // 5
// DANGER: Using ! on a null value
String? nullName = null;
// String crash = nullName!; // RUNTIME ERROR: Null check operator used on a null value
! sparingly! Every ! operator is a potential runtime crash. Only use it when you are 100% certain a value is not null and the type system cannot prove it. Prefer null checks, ??, or ?. instead.
// BAD: Overusing !
String? name = getName();
print(name!.length); // Could crash!
print(name!.toUpperCase()); // Could crash!
// GOOD: Check once, then use safely
String? name = getName();
if (name != null) {
print(name.length); // Safe — Dart knows name is non-null here
print(name.toUpperCase()); // Safe
}
// GOOD: Provide a default
String name = getName() ?? 'Unknown';
print(name.length); // Safe
The late Keyword
The late keyword tells Dart that a non-nullable variable will be initialized before it is used, even though it is not initialized at declaration.
// Without late — must initialize immediately
String name = 'Alice';
// With late — promise to initialize before use
late String name;
void init() {
name = 'Alice'; // Initialize later
}
void greet() {
init();
print('Hello, $name'); // OK — initialized before use
}
// late for expensive computation (lazy initialization)
class DataProcessor {
// This is NOT computed until first accessed
late final List<int> processedData = _heavyComputation();
List<int> _heavyComputation() {
print('Computing...');
return List.generate(1000000, (i) => i * 2);
}
}
// The computation only runs when you access processedData
var processor = DataProcessor();
print('Created processor'); // No computation yet
print(processor.processedData.length); // NOW it computes
late variable before it is initialized, Dart throws a LateInitializationError. The late keyword is a promise to the compiler — you must keep that promise.
The required Keyword for Named Parameters
By default, named parameters are optional and nullable. The required keyword forces callers to provide a value, making the parameter non-nullable.
// Without required — parameter is optional and nullable
void greet({String? name}) {
print('Hello, ${name ?? "stranger"}');
}
greet(); // Hello, stranger
greet(name: 'Ali'); // Hello, Ali
// With required — caller MUST provide the value
void createUser({
required String name,
required String email,
int age = 0, // Has default, so not required
}) {
print('User: $name ($email), age: $age');
}
createUser(name: 'Ali', email: 'ali@mail.com'); // OK
// createUser(name: 'Ali'); // ERROR: Missing required argument 'email'
// In classes — very common pattern
class Product {
final String name;
final double price;
final String? description; // Optional
Product({
required this.name,
required this.price,
this.description,
});
}
Type Promotion (Flow Analysis)
Dart's flow analysis is smart. When you check a nullable variable for null, Dart automatically "promotes" it to a non-nullable type within the scope of that check.
void printLength(String? text) {
// text is String? here
if (text == null) {
print('No text provided');
return;
}
// text is automatically promoted to String here!
print(text.length); // No need for text?.length or text!.length
print(text.toUpperCase()); // Dart knows text is non-null
}
// Works with different check styles
void processValue(int? value) {
// Style 1: null check with return
if (value == null) return;
print(value + 10); // value promoted to int
// Style 2: != null check
if (value != null) {
print(value * 2); // value promoted to int inside block
}
}
// Type promotion with is checks
void handleData(Object? data) {
if (data is String) {
print(data.length); // data promoted to String
print(data.toUpperCase());
} else if (data is int) {
print(data.isEven); // data promoted to int
}
}
class MyClass {
String? name;
void doSomething() {
// BAD: Promotion doesn't work on fields
// if (name != null) {
// print(name.length); // ERROR: still String?
// }
// GOOD: Assign to local variable
final localName = name;
if (localName != null) {
print(localName.length); // OK: localName promoted to String
}
}
}
Nullable Collections
Understanding the difference between a nullable collection and a collection of nullable items is essential.
// Nullable list — the list itself can be null
List<String>? nullableList = null;
nullableList = ['a', 'b', 'c'];
// List of nullable items — the list always exists, but items can be null
List<String?> listOfNullable = ['a', null, 'c', null];
// Both nullable — list can be null AND items can be null
List<String?>? bothNullable = null;
bothNullable = ['a', null, 'c'];
// Working with nullable lists
List<String?> names = ['Alice', null, 'Bob', null, 'Charlie'];
// Filter out nulls
List<String> validNames = names.whereType<String>().toList();
print(validNames); // [Alice, Bob, Charlie]
// Process with null handling
for (var name in names) {
print(name?.toUpperCase() ?? 'UNKNOWN');
}
// ALICE, UNKNOWN, BOB, UNKNOWN, CHARLIE
// Nullable map
Map<String, int?> scores = {
'Alice': 95,
'Bob': null, // Bob hasn't taken the test
'Charlie': 87,
};
scores.forEach((name, score) {
print('$name: ${score ?? "N/A"}');
});
Practical Patterns for Handling Null
Here are real-world patterns you will use frequently when working with null safety in Dart.
// Pattern 1: Safe parsing
int? tryParseAge(String? input) {
if (input == null) return null;
return int.tryParse(input);
}
String ageText = '25';
int age = tryParseAge(ageText) ?? 0;
// Pattern 2: Conditional method calls
List<String>? items;
int count = items?.length ?? 0;
bool isEmpty = items?.isEmpty ?? true;
// Pattern 3: Safe casting
Object? data = getDataFromApi();
String? text = data as String?; // Returns null if data is not a String
// Pattern 4: Returning early for null
String formatUser(Map<String, dynamic>? json) {
if (json == null) return 'No data';
String name = json['name'] as String? ?? 'Unknown';
int age = json['age'] as int? ?? 0;
return '$name (age $age)';
}
// Pattern 5: Builder pattern with null-safe chaining
class QueryBuilder {
String? _table;
String? _where;
int? _limit;
QueryBuilder table(String t) { _table = t; return this; }
QueryBuilder where(String w) { _where = w; return this; }
QueryBuilder limit(int l) { _limit = l; return this; }
String build() {
var query = 'SELECT * FROM ${_table ?? "unknown"}';
if (_where != null) query += ' WHERE $_where';
if (_limit != null) query += ' LIMIT $_limit';
return query;
}
}
// Pattern 6: Extension methods for null handling
extension NullSafeString on String? {
bool get isNullOrEmpty => this == null || this!.isEmpty;
String orDefault(String fallback) => this ?? fallback;
}
String? name;
print(name.isNullOrEmpty); // true
print(name.orDefault('Guest')); // Guest
Migrating Your Thinking to Null Safety
Moving to null-safe code requires a shift in how you design your programs.
// OLD THINKING: "Any variable might be null, check everywhere"
// ignore: avoid_init_to_null
String? name = null;
if (name != null) {
print(name.length);
}
// NEW THINKING: "Variables are non-null by default. Only use ? when null is meaningful."
// Ask: Does this NEED to be null?
// Yes → use nullable type and handle it
// No → use non-nullable type
// GOOD: User always has a name
class User {
final String name; // Non-null: every user must have a name
final String? bio; // Nullable: bio is optional
final DateTime createdAt;
final DateTime? deletedAt; // Nullable: null means not deleted
User({
required this.name,
this.bio,
DateTime? createdAt,
this.deletedAt,
}) : createdAt = createdAt ?? DateTime.now();
}
// GOOD: Function parameters reflect intent
double calculateDiscount({
required double price, // Must have price
required int quantity, // Must have quantity
String? couponCode, // Optional coupon
}) {
double discount = 0;
if (couponCode != null) {
discount = _lookupCoupon(couponCode); // couponCode promoted
}
return price * quantity * (1 - discount);
}
double _lookupCoupon(String code) => code == 'SAVE10' ? 0.1 : 0.0;
? when null carries meaningful information (e.g., "not set yet", "not applicable", "deleted"). If you find yourself adding ? just because you do not have a value yet, consider using late or a default value instead.
Summary
| Feature | Syntax | Purpose |
|---|---|---|
| Nullable type | Type? | Allow null values |
| Null-aware access | ?. | Safe property/method access |
| Null coalescing | ?? | Provide default for null |
| Null-aware assign | ??= | Assign only if null |
| Null assertion | ! | Assert value is non-null |
| Late init | late | Defer initialization |
| Required param | required | Force callers to provide value |
| Type promotion | Flow analysis | Auto-promote after null check |
Practice Exercise
Create a UserProfile class with the following:
name(required, non-nullable)email(required, non-nullable)phone(optional, nullable)bio(optional, nullable)
Then write a function String formatProfile(UserProfile? profile) that:
- Returns
"No profile available"if profile is null - Returns a formatted string with all available fields
- Uses
??to show"Not provided"for null fields - Uses
?.to safely access the profile
Test with both a full profile and a null profile.