Dart: Dart Null Safety — Eliminating Null Pointer Exceptions
Last updated: 2026-08-26
Null Safety is Dart's armor — the compiler helps you block 95% of null pointer attacks.
1. What You Will Learn
- Sound Null Safety principles: Nullable type
T?vs Non-nullable typeT - null checks and type promotion
- late keyword: Delayed initialization and safety guarantees
- Use cases and risks of the
!operator - Bob's scenario: Null Safety design for optional configurations in DataPipeline
2. A Developer's Real Story
(1) Pain Point: NullPointerException Is the Most Common Runtime Crash
Bob's DataPipeline suffered from runtime crashes, 60% of which were NullPointerExceptions. An API returned a null order ID, an optional field was missing in a CSV, a configuration option was used without being set — each situation caused the program to crash, affecting an average of 50,000 orders per incident.
(2) The Solution: Sound Null Safety
Dart 3's Sound Null Safety divides types into nullable T? and non-nullable T. The compiler guarantees at compile-time that non-nullable types can never be null.
// Non-nullable: compiler guarantees non-null
String orderId = 'ORD-001'; // Cannot be null
// orderId = null; // Compile error!
// Nullable: must be checked before use
String? nickname; // Can be null
int length = nickname?.length ?? 0; // Safe access
> **Output:** Run in local DartPad or with `dart run`. All examples in this Dart course are based on Dart 3.x / Flutter 3.x, and results may vary slightly depending on the SDK version.
(3) Benefits
- Null pointer exceptions are moved from runtime to compile-time, reducing related bugs by 95%
- Non-nullable types make API contracts clearer: which fields are required is immediately obvious
- Null check promotion makes code more concise, eliminating the need for manual type casts
3. Sound Null Safety Principles
(1) Type System
flowchart TD
A[Variable Declaration] --> B{Can be null?}
B -->|Yes| C["T? Nullable type"]
B -->|No| D["T Non-nullable type"]
C --> E["Must check before use"]
E --> F["if (x != null) → promoted to T"]
C --> G["?? Provide default value"]
C --> H["!. Force unwrap - RISKY"]
D --> I["Use directly - SAFE"]
> **Output:** Run in local DartPad or with `dart run`. All examples in this Dart course are based on Dart 3.x / Flutter 3.x, and results may vary slightly depending on the SDK version.
▶ Example
> **Output:** Run in local DartPad or with `dart run`. All examples in this Dart course are based on Dart 3.x / Flutter 3.x, and results may vary slightly depending on the SDK version.
: Nullable vs Non-nullable Types
void main() {
// Non-nullable types - cannot hold null
String name = 'DataPipeline';
int count = 100;
double amount = 1500.0;
// name = null; // Compile error!
// count = null; // Compile error!
// Nullable types - can hold null
String? nickname;
int? maxRetries;
double? discountRate;
print(nickname); // null
print(maxRetries); // null
print(discountRate); // null
// Nullable types require checking before use
// print(nickname.length); // Compile error! Might be null
print(nickname?.length); // null (safe)
print(nickname?.length ?? 0); // 0 (with default)
}
> **Output:** Run in local DartPad or with `dart run`. All examples in this Dart course are based on Dart 3.x / Flutter 3.x, and results may vary slightly depending on the SDK version.
| Type | Can be null | Check before use | Example |
|---|---|---|---|
T |
No | Not needed | String name = 'Bob' |
T? |
Yes | Required | String? name |
4. Null Checks and Type Promotion
(1) Type Promotion
When the compiler confirms that a nullable variable is not null, it automatically promotes it to a non-nullable type.
▶ Example
> **Output:** Run in local DartPad or with `dart run`. All examples in this Dart course are based on Dart 3.x / Flutter 3.x, and results may vary slightly depending on the SDK version.
: if-check Promotion
void main() {
String? name = 'Bob';
// Before check - nullable
// print(name.length); // Error!
// After null check - promoted to non-nullable
if (name != null) {
print(name.length); // OK! name is promoted to String
print(name.toUpperCase()); // OK!
}
}
> **Output:** Run in local DartPad or with `dart run`. All examples in this Dart course are based on Dart 3.x / Flutter 3.x, and results may vary slightly depending on the SDK version.
▶ Example
> **Output:** Run in local DartPad or with `dart run`. All examples in this Dart course are based on Dart 3.x / Flutter 3.x, and results may vary slightly depending on the SDK version.
: Multiple Null Check Methods
void main() {
String? city;
// Method 1: if-null check
if (city != null) {
print(city.length); // Promoted
}
// Method 2: null-coalescing operator ??
String safeCity = city ?? 'Unknown';
print(safeCity.length); // Always safe
// Method 3: null-aware access ?.
int? length = city?.length;
print(length); // null
// Method 4: late initialization
late String resolvedCity;
resolvedCity = city ?? 'Unknown';
print(resolvedCity.length); // Safe
// Method 5: assert in debug mode
assert(city != null, 'City must not be null');
}
> **Output:** Run in local DartPad or with `dart run`. All examples in this Dart course are based on Dart 3.x / Flutter 3.x, and results may vary slightly depending on the SDK version.
| Check Method | Syntax | Promotion Effect | Safety |
|---|---|---|---|
| if-null | if (x != null) |
Promoted to T | Highest |
| ?? | x ?? default |
Returns T | High |
| ?. | x?.method() |
Returns T? | High |
| ! | x!.method() |
Treated as T | Low (may crash) |
5. The late Keyword
(1) Delayed Initialization
▶ Example
> **Output:** Run in local DartPad or with `dart run`. All examples in this Dart course are based on Dart 3.x / Flutter 3.x, and results may vary slightly depending on the SDK version.
: Basic Usage of late
class DataPipeline {
// late - will be initialized later, but guaranteed before use
late String outputPath;
late final DateTime startTime;
void configure(String path) {
outputPath = path; // First assignment
}
void start() {
startTime = DateTime.now(); // late final - set once
print('Pipeline started at $startTime');
}
}
void main() {
final pipeline = DataPipeline();
pipeline.configure('/tmp/reports');
pipeline.start();
// Late initialization with initializer
late final int maxRecords = _loadConfig();
// maxRecords is computed only on first access
print('Max records: $maxRecords');
}
int _loadConfig() {
print('Loading config...');
return 1000000;
}
> **Output:** Run in local DartPad or with `dart run`. All examples in this Dart course are based on Dart 3.x / Flutter 3.x, and results may vary slightly depending on the SDK version.
▶ Example
> **Output:** Run in local DartPad or with `dart run`. All examples in this Dart course are based on Dart 3.x / Flutter 3.x, and results may vary slightly depending on the SDK version.
: Risks of late
class RiskyPipeline {
late String config;
void process() {
// If config not set, throws LateInitializationError
print(config); // Potential runtime crash!
}
}
void main() {
final pipeline = RiskyPipeline();
// pipeline.process(); // LateInitializationError!
// Safe pattern: initialize in constructor
final safePipeline = SafePipeline('/etc/config.yaml');
safePipeline.process(); // OK
}
class SafePipeline {
late String config;
SafePipeline(String configPath) {
config = _loadConfig(configPath);
}
void process() => print('Config: $config');
String _loadConfig(String path) => 'Loaded from $path';
}
> **Output:** Run in local DartPad or with `dart run`. All examples in this Dart course are based on Dart 3.x / Flutter 3.x, and results may vary slightly depending on the SDK version.
late Form |
Initialization | Number of Assignments | Risk |
|---|---|---|---|
late T |
Before use | Multiple | Medium (crash if accessed before initialization) |
late final T |
Before use | 1 | Medium |
late final T = expr |
On first access | 1 (automatic) | Low |
6. The ! Operator
(1) Force Unwrapping
! tells the compiler "I'm sure this value is not null," skipping the null check. However, if the value is null, it will throw a TypeError.
▶ Example
> **Output:** Run in local DartPad or with `dart run`. All examples in this Dart course are based on Dart 3.x / Flutter 3.x, and results may vary slightly depending on the SDK version.
: Using !
void main() {
String? name = 'Bob';
// When you KNOW the value is non-null
print(name!.length); // 3 - OK because name is 'Bob'
// DANGEROUS: if null, crashes!
String? maybeNull;
// print(maybeNull!.length); // Runtime TypeError!
// Safe alternative: use ?? or if-null check
print(maybeNull?.length ?? 0); // 0 - safe
}
> **Output:** Run in local DartPad or with `dart run`. All examples in this Dart course are based on Dart 3.x / Flutter 3.x, and results may vary slightly depending on the SDK version.
(2) When to Use !
▶ Example
> **Output:** Run in local DartPad or with `dart run`. All examples in this Dart course are based on Dart 3.x / Flutter 3.x, and results may vary slightly depending on the SDK version.
: Reasonable Use Cases for !
class Order {
final String id;
final double amount;
Customer? customer; // Optional relationship
Order({required this.id, required this.amount});
// Use ! when business logic guarantees non-null
String get customerName {
// This is risky - customer might be null
// return customer!.name; // BAD
// Better: safe access with default
return customer?.name ?? 'Unknown Customer';
}
}
class Customer {
final String name;
Customer(this.name);
}
void main() {
final order = Order(id: 'ORD-001', amount: 1500.0);
print(order.customerName); // Unknown Customer
// After setting customer
order.customer = Customer('Alice');
print(order.customerName); // Alice
}
> **Output:** Run in local DartPad or with `dart run`. All examples in this Dart course are based on Dart 3.x / Flutter 3.x, and results may vary slightly depending on the SDK version.
| Use Case | Recommended | Not Recommended |
|---|---|---|
| Nullable variables | ?. / ?? / if-null |
! |
| API return values | Null check | ! |
Collection .first |
.firstOrNull ?? default |
.first! |
| After assertion | ! (assertion guarantees non-null) |
— |
7. Null Safety and Collections
▶ Example
> **Output:** Run in local DartPad or with `dart run`. All examples in this Dart course are based on Dart 3.x / Flutter 3.x, and results may vary slightly depending on the SDK version.
: Handling Null in Collections
void main() {
// List with nullable elements
List<String?> names = ['Alice', null, 'Bob', null, 'Charlie'];
// Filter out nulls - type promotion works
final nonNull = names.whereType``<String>``().toList();
print(nonNull); // [Alice, Bob, Charlie]
// Map with nullable values
Map<String, double?> revenue = {
'Electronics': 3600.0,
'Books': null,
'Clothing': 890.0,
};
// Filter entries with non-null values
final validRevenue = Map.fromEntries(
revenue.entries.where((e) => e.value != null),
);
print(validRevenue); // {Electronics: 3600.0, Clothing: 890.0}
// Safe access with default
final booksRevenue = revenue['Books'] ?? 0;
print(booksRevenue); // 0
}
> **Output:** Run in local DartPad or with `dart run`. All examples in this Dart course are based on Dart 3.x / Flutter 3.x, and results may vary slightly depending on the SDK version.
8. Bob's Scenario: Null Safety Design for DataPipeline
▶ Example
> **Output:** Run in local DartPad or with `dart run`. All examples in this Dart course are based on Dart 3.x / Flutter 3.x, and results may vary slightly depending on the SDK version.
: Null Safety Design for Optional Configurations
class PipelineConfig {
// Required fields - non-nullable
final String appName;
final String version;
// Optional fields - nullable with defaults
final String? outputPath;
final String? logLevel;
final double? customTaxRate;
final int? maxRetries;
// Computed from optional - non-nullable
String get effectiveOutputPath => outputPath ?? '/tmp/datapipeline/output';
String get effectiveLogLevel => logLevel ?? 'info';
double get effectiveTaxRate => customTaxRate ?? 0.08;
int get effectiveMaxRetries => maxRetries ?? 3;
const PipelineConfig({
required this.appName,
required this.version,
this.outputPath,
this.logLevel,
this.customTaxRate,
this.maxRetries,
});
String get summary => '''
$appName v$version
Output: $effectiveOutputPath
Log: $effectiveLogLevel
Tax: ${(effectiveTaxRate * 100).toStringAsFixed(1)}%
Retries: $effectiveMaxRetries
''';
}
void main() {
// Minimal config - only required fields
final minimal = PipelineConfig(
appName: 'DataPipeline',
version: '1.0.0',
);
print(minimal.summary);
// Full config
final full = PipelineConfig(
appName: 'DataPipeline',
version: '2.0.0',
outputPath: '/data/reports',
logLevel: 'debug',
customTaxRate: 0.10,
maxRetries: 5,
);
print(full.summary);
}
> **Output:** Run in local DartPad or with `dart run`. All examples in this Dart course are based on Dart 3.x / Flutter 3.x, and results may vary slightly depending on the SDK version.
9. Complete Example: Null-Safe Order Processing in DataPipeline
// ============================================
// DataPipeline Null-Safe Order Processing
// Complete demonstration of Null Safety patterns
// ============================================
class Customer {
final String name;
final String? email;
final Address? address;
Customer({required this.name, this.email, this.address});
String get displayName => email ?? name;
String get city => address?.city ?? 'Unknown City';
}
class Address {
final String city;
final String? state;
final String country;
Address({required this.city, this.state, required this.country});
String get fullRegion => state != null ? '$city, $state' : city;
}
class Order {
final String id;
final double amount;
final String status;
final Customer? customer;
final String? discountCode;
final double? discountPercent;
Order({
required this.id,
required this.amount,
required this.status,
this.customer,
this.discountCode,
this.discountPercent,
}) : assert(amount > 0, 'Amount must be positive');
double get effectiveDiscount => discountPercent ?? 0;
double get discountedAmount => amount * (1 - effectiveDiscount);
double get tax => discountedAmount * 0.08;
double get total => discountedAmount + tax;
String get customerName => customer?.name ?? 'Guest';
String get customerCity => customer?.city ?? 'Unknown';
String get formattedTotal =>
'\$${total.toStringAsFixed(2)} USD';
@override
String toString() =>
'Order($id, $customerName, ${formattedTotal}, $status)';
}
class OrderProcessor {
final List``<Order>`` _orders = [];
final List``<String>`` _warnings = [];
void addOrder(Order order) {
_orders.add(order);
// Null-safe checks with warnings
if (order.customer == null) {
_warnings.add('Order ${order.id}: No customer assigned');
}
if (order.discountCode != null && order.discountPercent == null) {
_warnings.add('Order ${order.id}: Discount code without rate');
}
}
double get totalRevenue =>
_orders.fold(0.0, (sum, o) => sum + o.total);
List``<Order>`` get ordersWithCustomers =>
_orders.where((o) => o.customer != null).toList();
Map<String, double> revenueByCity() {
final result = <String, double>{};
for (final order in _orders) {
final city = order.customerCity;
result.update(city, (v) => v + order.total, ifAbsent: () => order.total);
}
return result;
}
void printReport() {
print('=== DataPipeline Order Report ===');
print('Orders: ${_orders.length}');
print('Revenue: \$${totalRevenue.toStringAsFixed(2)} USD');
print('With customers: ${ordersWithCustomers.length}');
print('\nRevenue by City:');
for (final entry in revenueByCity().entries) {
print(' ${entry.key}: \$${entry.value.toStringAsFixed(2)} USD');
}
if (_warnings.isNotEmpty) {
print('\nWarnings:');
for (final w in _warnings) {
print(' $w');
}
}
}
}
void main() {
final processor = OrderProcessor();
processor.addOrder(Order(
id: 'ORD-001',
amount: 1500.0,
status: 'completed',
customer: Customer(
name: 'Alice',
email: 'alice@example.com',
address: Address(city: 'New York', state: 'NY', country: 'US'),
),
discountPercent: 0.10,
));
processor.addOrder(Order(
id: 'ORD-002',
amount: 3200.0,
status: 'completed',
// No customer - nullable field
));
processor.addOrder(Order(
id: 'ORD-003',
amount: 890.0,
status: 'pending',
customer: Customer(
name: 'Bob',
address: Address(city: 'London', country: 'UK'),
),
discountCode: 'SAVE20',
// discountPercent is null - warning!
));
processor.printReport();
}
> **Output:** Run in local DartPad or with `dart run`. All examples in this Dart course are based on Dart 3.x / Flutter 3.x, and results may vary slightly depending on the SDK version.
Output:
=== DataPipeline Order Report ===
Orders: 3
Revenue: $5172.36 USD
With customers: 2
Revenue by City:
New York: $1458.00 USD
Unknown: $3456.00 USD
London: $961.20 USD
Warnings:
Order ORD-002: No customer assigned
Order ORD-003: Discount code without rate
❓ FAQ
Q: What's the difference between Sound Null Safety and non-Sound? A: Sound Null Safety guarantees that non-nullable types can never be null throughout the entire program (the compiler performs global verification). Non-Sound mode (deprecated) allows certain paths to bypass checks. Dart 3 defaults to Sound.
Q: When should I use
lateinstead of a nullable type? A: Uselatewhen you are certain the variable will be initialized before use and you don't want it to be null. If the variable truly can be null (semantically optional), useT?.
Q: Should
!be completely avoided? A: Not completely, but use it sparingly. Reasonable scenarios include after assertions, in test code, or when business logic explicitly guarantees non-nullity. In most cases,??or?.is safer.
Q: Is null check promotion effective inside closures? A: Not always. If the variable might be modified outside the closure, the compiler cannot guarantee promotion. Capture it in a local
finalvariable to solve this.
Q: Are generic type parameters nullable by default? A: No.
Tis non-nullable by default, andT?is nullable. This ensures generic type safety.T?is actually a supertype ofT.
Q: How to handle many nullable fields in JSON parsing? A: It's recommended to use
??to provide default values, or use thejson_serializablepackage to automatically generate afromJsonmethod with default values. Avoid using!everywhere.
Q: What's the difference between
late finalandfinal? A:finalmust be assigned at declaration or in the constructor's initializer list.late finalcan be first assigned in the constructor body or a subsequent method.late finaldelays the timing of the assignment.
📖 Summary
- Sound Null Safety divides types into T (non-nullable) and T? (nullable), with compiler-wide guarantees
- After a null check, the compiler automatically performs type promotion, eliminating the need for manual type casts
lateenables delayed initialization, but accessing it before initialization causes a crash;late final+ an initialization expression is safest!force unwrapping is risky; prefer??or?.as alternatives- DataPipeline configuration design pattern: required fields are non-nullable, optional fields are nullable + getters provide default values
📝 Exercises
- Basic (Difficulty ⭐): Declare 5 variables (2 non-nullable, 3 nullable). Use if-null checks,
??, and?.to safely access the nullable variables in three different ways, and print the results. - Intermediate (Difficulty ⭐⭐): Design a
Configclass usinglateto delay the initialization of 3 fields, set them in aconfigure()method. Intentionally access the fields without callingconfigure()and observe theLateInitializationError. - Challenge (Difficulty ⭐⭐⭐): Implement a NullSafe API client where all network requests return a
Result<T?>type. Correctly handle three cases: the server returns null, a field is missing, and a type mismatch, all without using!.