Skip to content
Blog

Dart Pattern Matching and Records: Cleaner Data Flow in Flutter Apps

Dart 3 introduced patterns and records — two features that quietly eliminate entire categories of boilerplate. Here is how they work and where they pay off in real Flutter code.

Published on • October 13, 2026

AI Assistant

Every Flutter developer eventually writes the same function: parse a string into a number, return the value if it works, and signal failure somehow. Before Dart 3, the “somehow” was a nullable return, a custom exception, or a wrapper class. With patterns and records — the headline features of Dart 3 — you can express the whole idea in one line, and the compiler enforces that you handled every case.

Records: anonymous structs you can return for free

A record is an immutable, anonymous aggregate of named and positional fields. You can hold several values together without declaring a class:

(String, int) parseUser(String input) {
  final parts = input.split(':');
  if (parts.length != 2) return ('', -1);
  final age = int.tryParse(parts[1]);
  if (age == null) return ('', -1);
  return (parts[0], age);
}

final (name, age) = parseUser('ada:36');

Records are value types: two records with the same fields are equal, they compare by content, and they are immutable. Their == and hashCode are derived from the fields, which makes them handy keys and cache entries. Because the type syntax is structural — (String, int) is the type — you never need a boilerplate DTO for small internal tuples.

A common pattern in Flutter state code is returning a value plus metadata:

({bool fromCache, Duration elapsed, List<Item> items}) loadItems() { ... }

final result = loadItems();
if (result.fromCache) {
  // render instantly, refresh in background
}

Named record fields read like a poor man’s data class, and for internal return values they are exactly the right weight.

Patterns: switch that actually checks its work

Pattern matching generalizes the switch statement into a full expression form with destructuring, guards, and exhaustiveness checking. The most impactful part for app code is exhaustiveness: when you switch over an enum or a sealed class, the compiler errors if you miss a case.

Sealed classes are the companion feature. They let you declare a closed set of subtypes in one library:

sealed class AuthState {}

class AuthLoading extends AuthState {}
class Authenticated extends AuthState {
  final User user;
  Authenticated(this.user);
}
class AuthError extends AuthState {
  final String message;
  AuthError(this.message);
}

Now rendering becomes a total function:

Widget build(BuildContext context) {
  final state = ref.watch(authProvider);
  return switch (state) {
    AuthLoading() => const Center(child: CircularProgressIndicator()),
    Authenticated(:final user) => HomeScreen(user: user),
    AuthError(:final message) => ErrorView(message: message),
  };
}

Remove a branch and the build breaks — no more “blank screen because a new state variant was added and nobody updated the widget.” The :final user syntax inside Authenticated(...) is object destructuring: it extracts fields directly in the match arm.

The full pattern toolbox

Dart’s patterns compose, which is where the real power shows up:

  • Constant patterns — case 0:, case null:, case Status.ok:
  • Variable patterns — case var x: binds whatever matched
  • Wildcard — case _:, the “don’t care” arm, useful for ignoring fields
  • List patterns — case [int first, ...int rest]: destructures lists with rest elements
  • Map patterns — case {'id': var id, 'name': var name}: extracts map entries and checks keys exist
  • Relational patterns — case > 100:, case <= 0:
  • Logical-and patterns — case [var a, var b] when a + b == total:

The when clause (a guard) turns a match into a predicate, and guards are checked before the arm body runs.

Where this pays off in Flutter

Parsing and deserialization. Map patterns shine when reading JSON you do not fully control:

Map<String, dynamic> parseConfig(Map<String, dynamic> json) => switch (json) {
  {'debug': bool d, 'retries': int r} => {...json, 'debug': d, 'retries': r},
  _ => defaultConfig,
};

Navigation and routing. Route arguments arrive as untyped Object?. A pattern match validates and destructures in one step instead of three casts.

Reducer-style state management. In Bloc/Cubit/Redux-like code, actions are naturally a sealed hierarchy, and reducers become exhaustive switches — impossible to forget a case, easy to test each arm.

Recursive tree walks. UI trees and expression ASTs are sealed types; list patterns and destructuring make visitor code dramatically shorter than the old is/as cascade.

A note on when not to reach for records

Records shine for small internal values with a clear positional order. When a value crosses module boundaries, is serialized, or grows past three fields, a real class (or a freezed-style sealed union) still wins: named APIs are more discoverable, and you keep the ability to add behavior. Records also cannot have methods — they are pure data.

Further reading

Patterns and records are not just syntax sugar. Together with sealed classes, they move a whole class of runtime errors — missing cases, unsafe casts, ad-hoc error returns — into compile-time failures. For Flutter apps, that is the cheapest reliability upgrade the language has ever shipped.