← All posts

6 min read

The Dart 3 Features I Use Every Day (With Before and After Code)

The Dart 3 features I actually use every day in Flutter apps: records, patterns, switch expressions, sealed classes, class modifiers and extension types.

  • Dart
  • Dart 3
  • Flutter
Cover illustration for The Dart 3 Features I Use Every Day (With Before and After Code)

Dart 3 quietly changed how I write Flutter code. Not in a “rewrite everything” way. More like the way you stop noticing how much better a new keyboard is until you go back to the old one and every key feels wrong.

When I open an older codebase now, I keep spotting the same patterns: helper classes that exist only to return two values, if/else ladders checking is types, casts on JSON maps that crash on bad data. Dart 3 has a cleaner answer for each of them.

These are the features I reach for daily, each with a before and after.

1. Records for returning more than one thing

Before Dart 3, returning two values meant a throwaway class, a List<dynamic> or a Map. All three are bad in different ways.

Before:

class PageResult {
  PageResult(this.items, this.hasMore);
  final List<Order> items;
  final bool hasMore;
}

Future<PageResult> fetchPage(int page) async { /* ... */ }

After:

Future<({List<Order> items, bool hasMore})> fetchPage(int page) async {
  final response = await _api.getOrders(page: page);
  return (items: response.orders, hasMore: response.nextPage != null);
}

final (:items, :hasMore) = await fetchPage(1);

Records are immutable and have value equality built in, so two records with the same fields compare as equal. That makes them great for map keys and test assertions too.

My rule: records for private or short-lived data that travels a function call or two. Once a shape shows up in three files or gets methods, it becomes a proper class. A record with five named fields passed across layers is a class pretending not to be one.

2. Destructuring

The final (:items, :hasMore) = ... line above is destructuring, and it works on more than records. I use it constantly for loops over map entries:

Before:

for (final entry in totals.entries) {
  print('${entry.key}: ${entry.value}');
}

After:

for (final MapEntry(:key, :value) in totals.entries) {
  print('$key: $value');
}

And for positional records, like coordinates:

(double, double) parseLatLng(String raw) { /* ... */ }

final (lat, lng) = parseLatLng(input);

Small thing, but it removes a lot of .first, .$1 and entry.value noise.

3. Switch expressions

A switch statement can’t produce a value, so every case has to return or assign something. The switch expression returns one directly.

Before:

Color colorFor(OrderStatus status) {
  switch (status) {
    case OrderStatus.pending:
      return Colors.orange;
    case OrderStatus.shipped:
      return Colors.blue;
    case OrderStatus.delivered:
      return Colors.green;
    case OrderStatus.cancelled:
      return Colors.grey;
  }
}

After:

Color colorFor(OrderStatus status) => switch (status) {
      OrderStatus.pending => Colors.orange,
      OrderStatus.shipped => Colors.blue,
      OrderStatus.delivered => Colors.green,
      OrderStatus.cancelled => Colors.grey,
    };

Because it’s exhaustive over the enum, adding a new status makes this a compile error until you handle it. That’s the real feature. The shorter syntax is a bonus.

Switch expressions also take relational and logical patterns, which cleans up range checks:

String describeStatus(int code) => switch (code) {
      >= 200 && < 300 => 'Success',
      401 || 403 => 'Not allowed',
      404 => 'Not found',
      >= 500 => 'Server error',
      _ => 'Unexpected response',
    };

4. Sealed classes and exhaustiveness

This is the one I’d keep if I could keep only one. A sealed class can only be extended in its own library, so the compiler knows every subtype and can check that a switch covers them all.

I use it for UI state, results and anything that’s “one of these things”:

sealed class LoginState {
  const LoginState();
}

final class LoginIdle extends LoginState {
  const LoginIdle();
}

final class LoginLoading extends LoginState {
  const LoginLoading();
}

final class LoginFailed extends LoginState {
  const LoginFailed(this.message);
  final String message;
}

final class LoginSucceeded extends LoginState {
  const LoginSucceeded(this.user);
  final User user;
}

Before, the widget did something like if (state is LoginLoading) ... else if (state is LoginFailed) ..., with a final else that silently caught any case someone forgot.

After:

Widget build(BuildContext context) => switch (state) {
      LoginIdle() => const LoginForm(),
      LoginLoading() => const Center(child: CircularProgressIndicator()),
      LoginFailed(:final message) => LoginForm(error: message),
      LoginSucceeded() => const SizedBox.shrink(),
    };

No else, no default. Add a LoginLocked state next month, and every switch that doesn’t handle it fails to compile. I lean on this heavily in state management code, whichever library sits around it. The Riverpod vs BLoC post shows the same idea inside a Cubit.

5. If-case for safe JSON parsing

Parsing JSON by hand used to mean a chain of casts that throw TypeError the moment the backend sends something unexpected.

Before:

final id = json['id'] as int;
final name = json['name'] as String;
final email = json['email'] as String?;

After:

User? parseUser(Map<String, dynamic> json) {
  if (json case {
    'id': int id,
    'name': String name,
  }) {
    return User(id: id, name: name, email: json['email'] as String?);
  }
  return null; // Shape didn't match. Log it, don't crash.
}

The map pattern checks the keys exist and the values have the right types, and binds them in one step. If anything doesn’t match, you land in the fallback instead of an exception.

For big models I still use code generation. For small payloads, webhook bodies and push notification data, if-case is perfect. It also pairs nicely with null checks:

if (user.address case final address?) {
  showAddress(address); // address is non-null here
}

Guards with when add extra conditions to any case:

final label = switch (cart) {
  Cart(items: []) => 'Your cart is empty',
  Cart(:final total) when total > freeShippingThreshold => 'Free shipping!',
  _ => 'Add more for free shipping',
};

6. Class modifiers

Dart 3 added final, base, interface, sealed and mixin class. Most app developers can ignore half of them, but two earn their place in my code.

abstract interface class for repository and service contracts. It says clearly: implement this, don’t extend it.

abstract interface class OrderRepository {
  Future<List<Order>> fetchOrders();
  Future<void> cancel(String orderId);
}

final class for concrete classes, especially state subtypes, that nobody should extend or implement outside the library. It keeps sealed hierarchies closed and tells readers there’s nothing hidden elsewhere.

base matters more for package authors who need to guarantee their implementation runs. In app code I rarely need it.

These modifiers are as much documentation as enforcement, which is why I bring them up in code review. They also make pragmatic clean architecture boundaries explicit instead of a convention people have to remember.

7. Extension types for IDs that can’t be mixed up

Extension types (Dart 3.3) give you a distinct compile-time type over an existing value, with no runtime wrapper.

Before:

Future<void> assignOrder(String orderId, String driverId) { /* ... */ }

assignOrder(driver.id, order.id); // Compiles. Wrong. Fun to debug.

After:

extension type const OrderId(String value) {}
extension type const DriverId(String value) {}

Future<void> assignOrder(OrderId orderId, DriverId driverId) { /* ... */ }

assignOrder(driver.id, order.id); // Compile error.

At runtime they’re just strings, so there’s no allocation cost. At compile time they can’t be swapped. I use them for IDs, currency amounts in minor units and anything else where two values share a primitive type but mean different things.

One caveat: because they’re erased at runtime, an is OrderId check really checks for String. Extension types are for compile-time safety, not runtime type tests.

What I don’t overuse

New syntax is fun, and it’s easy to get carried away. A few habits I avoid:

  • Deeply nested patterns that need a minute to read. If a pattern needs a comment, split it up.
  • Records with many fields flowing through the whole app.
  • Positional record fields ($1, $2) in public APIs. Named fields only there.
  • Switch expressions with long bodies in each arm. Pull them into methods.

The goal is code a teammate understands at a glance, not code that shows off every feature. The same principle runs through my Flutter best practices.

Takeaways

  • Use records for small, local multi-value returns, and promote them to classes when they spread.
  • Switch expressions plus enums or sealed classes turn forgotten cases into compile errors.
  • Sealed classes are the best way I know to model UI state in Flutter.
  • If-case with map patterns makes hand-written JSON parsing safe instead of crash-prone.
  • abstract interface class and final class document intent and keep hierarchies honest.
  • Extension types stop you from mixing up IDs at zero runtime cost.

None of these are flashy on their own, but together they’ve removed a whole category of bugs from my day.

Comments

Questions, corrections or your own experience — leave a comment below (GitHub sign-in).