Skip to content
writing

March 10, 2025 · 3 min read

Modern Dart in Practice: The Features That Matter

Modern Dart — records, patterns, sealed classes, sound null safety — is far more than 'the language you learn for Flutter.' The features I use every day, with examples.

Dart is often described as "the language you learn on the way to Flutter." That undersells it. Modern Dart — especially since Dart 3 — is an expressive, sound, null-safe language with pattern matching and records that make everyday code shorter and safer. Here are the features I lean on daily.

Sound null safety

Every type is non-nullable unless you opt in with ?. The compiler forces you to handle absence, which kills a whole class of runtime crashes.

String greet(String? name) {
  // name could be null, so we must handle it before use.
  return 'Hello, ${name ?? 'stranger'}';
}

// The `!` operator asserts non-null — use it only when you're certain.
final user = findUser(id)!; // throws if null, by design

Prefer ??, ?., and pattern matching over !. A bang operator is a promise to the compiler; if you're wrong, it crashes.

Records: lightweight, structural tuples

Records let you return multiple values without inventing a class.

(int, int) minMax(List<int> xs) {
  xs.sort();
  return (xs.first, xs.last);
}

final (lo, hi) = minMax([3, 1, 9, 4]); // destructured
print('$lo..$hi'); // 1..9

// Named fields read even better:
({double lat, double lng}) location() => (lat: 36.19, lng: 44.01);

Pattern matching and switch expressions

Dart 3's patterns turn branching logic into something declarative.

sealed class Shape {}
class Circle extends Shape { Circle(this.r); final double r; }
class Square extends Shape { Square(this.side); final double side; }

double area(Shape shape) => switch (shape) {
  Circle(:final r) => 3.14159 * r * r,
  Square(:final side) => side * side,
};

Because Shape is sealed, the compiler knows every subtype and will error if you forget a case — exhaustiveness checking you get for free.

Async: Futures and Streams

Future is a single value that arrives later; Stream is many values over time.

Future<User> fetchUser(String id) async {
  final res = await api.get('/users/$id');
  return User.fromJson(res.data);
}

Stream<int> countdown(int from) async* {
  for (var i = from; i >= 0; i--) {
    await Future.delayed(const Duration(seconds: 1));
    yield i;
  }
}

Rules of thumb: don't await inside a hot loop when the calls are independent — use Future.wait. And always handle errors, either with try/catch around await or .catchError.

final results = await Future.wait([
  fetchUser('1'),
  fetchUser('2'),
]);

Extension methods

Add behavior to types you don't own without subclassing.

extension StringCasing on String {
  String get capitalized =>
      isEmpty ? this : '${this[0].toUpperCase()}${substring(1)}';
}

'flutter'.capitalized; // "Flutter"

Where to go next

Learn const constructors (they enable compile-time canonicalization and Flutter rebuild skipping), the collection-if/for syntax, and Iterable lazy evaluation. Dart rewards you for leaning into its type system — the more precise your types, the more the compiler works on your behalf.

New writing, now and then

Essays on Flutter, Dart and the decisions behind real systems. No schedule, no filler.

One click to unsubscribe, in every email.