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.