Dart Programming Fundamentals

Null Safety

40 min Lesson 11 of 13

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.

Key Concept: With null safety, Dart distinguishes between types that can hold null and types that cannot. By default, all types are non-nullable. You must explicitly opt in to allow null values.

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
Pro Tip: Think of 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
Warning: Use ! 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
Important: If you access a 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
  }
}
Pro Tip: Type promotion only works with local variables and parameters. It does NOT work with class fields or top-level variables, because those could be changed by other code between the null check and the use. For fields, assign to a local variable first.
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;
Pro Tip: A good rule of thumb: start with non-nullable types. Only add ? 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

FeatureSyntaxPurpose
Nullable typeType?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 initlateDefer initialization
Required paramrequiredForce callers to provide value
Type promotionFlow analysisAuto-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.