Skip to content
ExponaBLOG
FLUTTER · DAY 005 / 100

Sound null safety in Dart: practical tips and trade-offs

Null errors become compile errors.

Expona Admin8 min read

Sound null safety in Dart: practical tips and trade-offs

After five days of building Flutter fundamentals, you've likely encountered Dart's null safety system—or felt its strictness push back on your code. Sound null safety in Dart transforms null errors from runtime crashes into compile-time failures, which sounds perfect until you're staring at a cascade of type annotations and wondering whether to use ?, !, or late. This article walks through practical patterns that have proven reliable across production apps, from e-commerce checkouts to real-time dashboards, so you can write safer code without fighting the compiler.

Types are non-nullable by default

The most important shift in Dart's null safety is this: every type is non-nullable unless you explicitly allow null. This is the inverse of pre-null-safety Dart (and languages like JavaScript or older Java), where anything could be null and you had to defend against it constantly.

Here's what that means in practice:

dart

// Non-nullable — must have a value, never null
String userName = "Alice";
int age = 30;
User currentUser = User(name: "Bob");

// These will NOT compile
String userName = null;  // Error: can't assign null to String
int score;               // Error: must initialize before use
final User user;         // Error: must be initialized

This default non-nullability catches entire categories of bugs at compile time. In a trading system or payment flow, you can trust that a TransactionId field genuinely holds a value—the compiler verified it before your code ran. There's no guessing whether userId might be null after parsing JSON or fetching from a database.

The trade-off is discipline: you must initialize every variable before use, and you can't be lazy about it. But that friction is intentional—it forces you to think about what data is truly required versus what can be absent. In a ride-hailing app's passenger widget, if destination is non-nullable, the UI never crashes with "destination is null"—it crashes loudly and early if construction logic forgot to set it.

String? allows null explicitly

When you do need a value that can be absent, you mark it with ?. This creates a nullable type—a formal permission for that variable to hold null.

dart

// Nullable — can be a String or null
String? middleName;
String? errorMessage = null;

class UserProfile {
  String username;           // Required
  String? bio;               // Optional
  String? profileImageUrl;   // Can be null if user hasn't set one
}

final profile = UserProfile(username: "alice");
print(profile.bio);  // null is okay here

The question mark is your contract: "this field exists, but it might not have a value." This is essential for optional form fields, error states, or lazy-loaded data. In a SaaS dashboard, subscriptionEndDate? makes sense—some users don't have a subscription yet. In an event-tracking API, errorDetails? is natural—not every event represents an error.

Once you declare a nullable type, Dart requires you to check before using it:

dart

String? nickname = getCurrentUserNickname();

// This will NOT compile — Dart doesn't know nickname isn't null
print(nickname.length);

// You must check first
if (nickname != null) {
  print(nickname.length);  // Safe — flow-sensitive typing
}

// Or use null-aware operators
print(nickname?.length);   // null if nickname is null, otherwise the length
print(nickname ?? "Guest");  // Use "Guest" if nickname is null

This forced checking is the heart of null safety. You can't accidentally call a method on a nullable field. In a CRM system, if customer.phone? can be null, and you want to send an SMS, the compiler won't let you pass it directly to a method expecting a non-nullable String. You'll handle the null case—either skip the SMS, use a fallback, or explicitly assert the value exists.

! asserts non-null — use rarely

The exclamation mark operator ! tells Dart: "I know this nullable value is not null right now; treat it as non-nullable." It bypasses the type system's safety check. Use it sparingly.

dart

String? nickname = getCurrentUserNickname();

// Without ! — won't compile
String displayName = nickname;  // Error

// With ! — forces non-null
String displayName = nickname!;  // Compiles; crashes if nickname is actually null

The ! operator has legitimate uses. If you've just received data from a server API and the contract guarantees a field is present (like a POST response with a generated ID), you might use it to move forward without defensive checks:

dart

final response = await createOrder(cartItems);

// API contract: response.orderId is always present on success
final orderId = response.orderId!;
await updateOrderStatus(orderId, OrderStatus.confirmed);

But overusing ! defeats the purpose of null safety. Every ! is a potential crash site. If the API contract changes or a null slip occurs, your app explodes at runtime—exactly what null safety was supposed to prevent. In production e-commerce systems, we've seen teams add ! to move fast, then later struggle to debug crashes when assumptions broke.

A better habit: if you're reaching for !, pause and ask: "Can I restructure to avoid this?" Often you can use flow-sensitive typing (nested checks), null coalescing (??), or conditional logic to handle both cases safely.

late for values initialized later

Sometimes a variable is genuinely non-nullable, but you can't initialize it at declaration time. You know it will be set before use, but the initialization happens in a constructor body, a build method, or an async function. That's where late comes in.

dart

class DatabaseConnection {
  late Database db;

  DatabaseConnection() {
    _initializeDb();  // db gets set here, not in the initializer list
  }

  void _initializeDb() {
    db = Database(connectionString: "...");
  }
}

class UserWidget extends StatefulWidget {
  @override
  State createState() => _UserWidgetState();
}

class _UserWidgetState extends State {
  late User currentUser;

  @override
  void initState() {
    super.initState();
    _loadUser();  // currentUser gets set here
  }

  void _loadUser() async {
    currentUser = await fetchUser();
  }

  @override
  Widget build(BuildContext context) {
    return Text(currentUser.name);  // Safe — _loadUser runs before build
  }
}

late is a promise to Dart: "I promise this variable will be set before it's read." If you lie—if build() is called before _loadUser() finishes—Dart throws a runtime error: LateInitializationError: Field 'currentUser' has not been initialized. That error is explicit and debuggable, which is better than silent null, but it's still a runtime crash.

Use late when initialization order is guaranteed by framework structure (like Flutter's widget lifecycle) or when you've designed initialization carefully. In a Redux store or a service layer, late works well for dependencies injected early in app startup. But don't scatter late throughout your codebase as a workaround for unclear initialization order. That's a code smell.

An alternative to late is nullable with lazy initialization:

dart

class UserRepository {
  User? _cachedUser;

  User get cachedUser {
    _cachedUser ??= _loadUserFromDisk();
    return _cachedUser!;
  }
}

This is more defensive—you're explicit about lazy caching—but it requires the ! at the end. Choose based on context: late for framework-guaranteed initialization, nullable for truly optional or lazy-loaded data.

Common mistakes

Assuming null safety is automatic across package boundaries

Null safety is sound within your app and null-safe packages, but an older third-party package (built before null safety) can still return nullable types or values that bypass checks. Always inspect package documentation and test thoroughly if you're integrating legacy code into a null-safe app. Pin versions and consider creating wrapper types to sanitize external data.

Using ? everywhere instead of thinking about design

It's tempting to mark every field as nullable to "just make things work." But that defeats the purpose. If 99% of users have a phone number in a contact form, make phone non-nullable and require it. If nickname is truly optional, make it nullable. Thoughtful use of types makes code self-documenting.

Chaining null-aware operators without clarity

dart

// Hard to read and debug
String displayName = user?.profile?.nickname?.toUpperCase() ?? "GUEST";

// Better — explicit checks make intent clearer
String displayName;
if (user?.profile?.nickname != null) {
  displayName = user!.profile!.nickname!.toUpperCase();
} else {
  displayName = "GUEST";
}

// Or extract to a helper
displayName = _getUserDisplayName(user) ?? "GUEST";

Long chains of ?.?.? work, but they're hard to debug and test. Break them into helper methods when they span more than two or three levels.

Forcing late when nullable is safer

If initialization might not happen, or if the timing is unclear, nullable is better than late. late is a promise; break it and you crash. Nullable is a safe default that forces you to handle absence explicitly.

Key takeaways

  • Non-nullable is the default. Every type you declare is non-nullable unless marked with ?. This prevents null from sneaking into places you didn't expect.
  • Use String? (and int?, User?, etc.) for optional fields. The ? is your contract that this value may be absent, and Dart will force you to handle it.
  • The ! operator bypasses safety checks—use it only when you're certain a value isn't null and can justify it. Overuse turns null safety into a false sense of security.
  • late defers initialization but promises the value will exist before use. It's perfect for framework-managed lifecycle (Flutter widgets, dependency injection), but avoid it for unclear initialization order.
  • Let Dart's flow-sensitive typing help you. After you check if (nullable != null), Dart narrows the type in that block—no ! needed.
  • Think about nullability as part of your data model design. Nullable types should represent genuinely optional data, not a lazy way to avoid initialization.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles