Dart 3 records, pattern matching, and sealed classes in production Flutter: real before/after refactors, exhaustive switches, if case JSON parsing, and when to stop.
When Dart 3 landed, a lot of the coverage treated records and pattern matching as syntax sugar — nice-to-haves you'd sprinkle in occasionally, right next to spread operators and collection-if. After shipping all three across a production Flutter app at Shpper for the better part of a year, I disagree, hard. These features aren't cosmetic. They change how you model state, they move whole categories of bugs from "found in QA" to "won't compile," and they quietly delete the defensive boilerplate that used to make our data layer read like a warzone.
I want to be specific about that, because "cleaner code" is a claim everyone makes and nobody proves. So this is a walk through what actually earned its place in my day-to-day, with before/after refactors pulled from real code — the products list, the Firestore reads, the validation helpers — plus the places where I decided a feature was making things worse and backed off. If you're evaluating whether Dart 3 is worth the migration effort on a real Flutter team, this is the honest version.
One thing up front: Dart 3 also flipped sound null safety from opt-in to mandatory and raised the language version floor. If your pubspec.yaml still carries an old lower bound, that's the migration cost — the language features below are the payoff. Everything here works on any Flutter build running on Dart 3.0 or later, and none of it pulls in a single package.
For years, whenever a function needed to return two things, I had exactly three bad options: create a throwaway class, return a Map<String, dynamic> and pray, or abuse a List and remember which index meant what. All three are noise. The class option is the "correct" one and it's still annoying — you write a constructor, maybe an equality override, and you name a thing that doesn't deserve a name. Records fix this.
Before — a helper that validated a form field and needed to return both a result and a message:
class ValidationResult { final bool isValid; final String? error; ValidationResult(this.isValid, this.error);}ValidationResult validateEmail(String input) { if (input.isEmpty) return ValidationResult(false, 'Email is required'); if (!input.contains('@')) return ValidationResult(false, 'Invalid email'); return ValidationResult(true, null);}After — the throwaway class disappears entirely:
({bool isValid, String? error}) validateEmail(String input) { if (input.isEmpty) return (isValid: false, error: 'Email is required'); if (!input.contains('@')) return (isValid: false, error: 'Invalid email'); return (isValid: true, error: null);}final result = validateEmail(email);if (!result.isValid) showError(result.error!);Records are structurally typed and immutable, with real value equality out of the box — two records with the same fields are ==, and their hashCode is derived for you. That last part matters more than it sounds. It means a record works correctly as a Map key or in a Set with zero boilerplate, which is exactly where hand-rolled classes silently break because someone forgot to override hashCode. "Structurally typed" is the key phrase: a record's type is its shape. ({int width, int height}) is the same type everywhere that shape appears, with no declaration and no import — the compiler matches by fields, not by name.
You get three flavors and it's worth knowing when to use which. Positional records (int, int) are perfect for genuinely anonymous pairs — a coordinate, a min/max. Named fields ({int width, int height}) are for anything where the reader would otherwise have to guess what .$1 means. You can even mix them: (String, {bool ok}). My default is named, because six months later size.width reads and size.$1 doesn't.
// Positional access via $1, $2...final point = (12, 40);final distance = point.$1 + point.$2;// Named access reads like a real object({double lat, double lng}) location = (lat: 25.19, lng: 55.27);map.moveTo(location.lat, location.lng);One gotcha worth internalizing early: a single positional field needs a trailing comma. (12) is just the integer 12 in parentheses; (12,) is a one-element record. The comma is the record, not the parentheses — the same way it works for a single-element tuple in other languages.
My rule of thumb: if a data shape is used in exactly one place and doesn't deserve a name, it's a record. The moment it starts showing up in three files or growing behavior — a method, a computed property, validation of its own — promote it to a class. Don't let records become anonymous blobs threaded through your whole app. A record passed through six function signatures is just Map<String, dynamic> with better tooling, and it will rot the same way.
The other place records shine is destructuring at the call site, which is where they stop being "a return type" and start being ergonomic:
final (isValid, error) = validateEmail(email); // positional destructurefinal (:lat, :lng) = getCurrentLocation(); // named destructure
That (:lat, :lng) shorthand — binding a variable to the field of the same name — is the small syntax that made me stop reaching for temporary variables. It shows up everywhere once your eye is trained for it. It also composes with the rest of the pattern grammar, which is the real story of Dart 3: records and patterns are two halves of the same feature.
switchHere's the misconception I had at first: patterns are a switch thing. They're not. You can destructure in plain variable declarations, in if-case, in for loops, and in switch — and the same pattern grammar works in all of them. Once that clicks, you start seeing patterns as a general tool for taking a shape apart and asserting things about it in one move.
It helps to hold the two categories in your head. Irrefutable patterns always match and just bind — the destructuring assignments above are these. Refutable patterns can fail, which is what powers if-case and switch: a failed match is a control-flow decision, not an error. Every pattern below is one of those two things.
The if-case form is the one I reach for constantly when pulling typed data out of dynamic JSON or handling nullable maps — which, in a Firebase app, is roughly every other line of the data layer.
Before:
final data = snapshot.data();if (data != null && data['status'] == 'active' && data['plan'] is String) { final plan = data['plan'] as String; activatePlan(plan);}After:
if (snapshot.data() case {'status': 'active', 'plan': String plan}) { activatePlan(plan);}The pattern checks the map isn't null, checks its shape, matches the literal 'active', confirms plan is a String, and binds it — all in one line, with no casts and no as. If any part fails, the block is skipped. This deleted a genuinely surprising amount of defensive null-and-type checking around our Firestore reads. I did a rough count during one refactor: a repository file went from about 210 lines to 140, almost entirely by collapsing these guard pyramids.
You can bolt a guard onto if-case too, which is where it starts replacing multi-line validation:
if (json case {'age': int age} when age >= 18) { grantAccess(age);}Maps aren't the only thing you can tear apart. List patterns handle the "parse this loosely structured array" case that JSON APIs love to hand you:
final parts = version.split('.');if (parts case [final major, final minor, ...]) { print('v$major.$minor');}The ... rest element matches "and however many more," so you can pin the elements you care about and ignore the tail. You can also bind the rest — [final first, ...final others] gives you the head and the remainder as a fresh list, which is the pattern-matching version of head/tail decomposition. Combine list patterns with type checks and you can validate the shape of a payload and extract from it in a single expression — the kind of thing that used to be a helper function with three early returns.
A small one that adds up. When you iterate a map's entries or a list of records, you can destructure in the loop header instead of pulling fields apart on the first line of the body:
for (final MapEntry(:key, :value) in headers.entries) { request.setHeader(key, value);}MapEntry(:key, :value) here is an object pattern — it matches the type and pulls out the key and value getters in one move. The same object-pattern syntax is what makes sealed-class switches read so cleanly, which is the next section.
This is the feature I'd fight to keep. If someone told me I could have only one Dart 3 feature, everything above goes in the bin and I keep this.
Anyone doing state management — Bloc, Riverpod, Cubit, or a hand-rolled ValueNotifier — has modeled UI state as a set of variants: loading, data, error, maybe empty. Before Dart 3, the compiler had no idea those were the only variants. State was usually an enum plus a bag of nullable fields, and the "which fields are valid together" contract lived entirely in your head and a code comment nobody trusted. Adding a fourth state meant grepping the codebase for every switch that needed a new branch. Miss one and you get a silent fallthrough — the app renders the wrong thing, or nothing, and no tool warns you.
sealed fixes exactly this. A sealed class is implicitly abstract and can only be extended or implemented within its own library, so the compiler knows the complete set of subtypes and can enforce exhaustive switches. It's a small keyword with an outsized effect on how safe refactors feel. (If you want the closed-hierarchy guarantee but still need to instantiate the base type, final and base give you related restrictions — but for state modeling, sealed is the one you want.)
Before — an enum plus loose fields, the classic "impossible states are representable" trap:
class ProductsState { final bool isLoading; final List<Product>? products; final String? error; ProductsState({this.isLoading = false, this.products, this.error});}// Consumer has to guess which fields are valid together:Widget build(BuildContext context) { if (state.isLoading) return const Spinner(); if (state.error != null) return ErrorView(state.error!); return ProductList(state.products ?? []); // is products ever null here?}Look at that last line. state.products ?? [] is a lie the code tells to survive. If products is null here, showing an empty list hides a real bug. The type system permitted a state that should never exist — loading and errored and dataless all at once — so every consumer has to defend against nonsense.
After — each state is its own type carrying exactly the data it needs, and nothing it doesn't:
sealed class ProductsState {}class ProductsLoading extends ProductsState {}class ProductsData extends ProductsState { final List<Product> products; ProductsData(this.products);}class ProductsError extends ProductsState { final String message; ProductsError(this.message);}Widget build(BuildContext context) { return switch (state) { ProductsLoading() => const Spinner(), ProductsData(:final products) => ProductList(products), ProductsError(:final message) => ErrorView(message), };}Two things happened here that matter in production:
products and error set at the same time, or to reach ProductList with a null list. ProductsData carries a non-nullable List<Product>, full stop. The types make illegal combinations unrepresentable, so the ?? [] lie has nowhere to live.ProductsEmpty state to show a proper empty-cart illustration, the app failed to compile until I handled it everywhere it was switched on. That's the compiler doing my grep for me — and it caught two consumers I'd genuinely forgotten about, one of them a widget in a rarely opened settings screen that would have shipped broken.Note the switch expression — the one that returns a value with => — versus the older statement form with case: and break. As an expression it must be exhaustive, which is precisely the property you want for building UI from state. The statement form doesn't force exhaustiveness the same way, so for state I always use the expression. And ProductsData(:final products) is an object pattern: it matches the type and destructures the field in one move, so you never write (state as ProductsData).products.
This shape generalizes well past UI state. Anywhere you have a closed set of outcomes — an API result (Success / Failure / Unauthorized), a domain event, a navigation intent, the result of parsing — a sealed hierarchy plus an exhaustive switch gives you a Result-style type without a package and without a default branch quietly hiding a case you forgot.
For a long time the Flutter answer to this was the freezed package — union types via code generation. It's a fine library and I still have it in older modules. But sealed covers a large fraction of what I used freezed unions for, with zero build_runner step, zero generated files in the diff, and stack traces that point at code I actually wrote. For state that's just "a closed set of shapes I switch on," plain sealed classes are now my default, and the generator only comes out when I want the copyWith/JSON serialization extras it also provides. The trade-off is real and worth naming: sealed gives you exhaustiveness and destructuring for free; freezed still wins when you need deep copyWith, when/map helpers, or generated fromJson/toJson on the same union. Pick per module, not per religion.
Two small features that come up fast once you start writing switches. Guard clauses with when let you branch on a runtime condition without nesting an if inside the case:
final label = switch (order) { Order(:final total) when total > 1000 => 'Priority', Order(:final total) when total > 100 => 'Standard', Order() => 'Economy',};Order matters here — cases are tried top to bottom, and a guard that fails falls through to the next case, so put your most specific conditions first. A guard failing is not the same as the pattern failing: the guard only runs once the pattern already matched, and a false guard moves on to the next case rather than throwing.
And _ is the wildcard fallback. Reach for it sparingly on sealed types. An explicit _ or default case disables the exhaustiveness check, which throws away the entire benefit — add a new subtype later and the compiler stays silent because the wildcard "handles" it. I've watched a default: branch swallow a brand-new state and render a blank screen in staging. I only use _ for genuinely open types like int, String, or an enum I don't own — the cases where the compiler can't prove exhaustiveness anyway.
Not everything should become a pattern, and Dart 3 makes it very easy to get drunk on this stuff. A few things I now actively avoid.
Deeply nested destructuring. A pattern that reaches three levels into a nested map is clever and unreadable. case {'user': {'profile': {'name': String name}}} looks impressive in a talk and is a puzzle in a pull request. Past two levels I go back to a couple of plain, named steps.
Records that should be classes. The class tax feels heavy right up until the shape earns identity. If two different things in the domain both happen to be (String, int) but mean completely different things — a (name, age) and a (city, population) — records let the type system conflate them, because structural typing matches on shape, not meaning. That's a footgun. Name them.
Guards doing real logic. A when clause with a function call and two boolean operators is a branch pretending to be a pattern. If the condition needs a comment, it needs a method.
My heuristics after living with Dart 3 in a real, shipping app:
if-case for pulling typed values out of dynamic JSON and nullable maps. This is where the boilerplate savings are largest.sealed + exhaustive switch for anything with a fixed, known set of variants — UI state, API results, domain events, navigation intents. This is the highest-leverage feature in the language, full stop.(x,)).switch — the same grammar works in variable declarations, if-case, and for loops, and it collapses defensive null/type/shape checks around dynamic JSON into a single expression.if-case with map and list patterns is the biggest boilerplate win in a Firebase/JSON-heavy data layer.switch expressions.default/_ on sealed types — it silently disables the exhaustiveness check that makes the whole pattern worth using.freezed only when you need copyWith, when/map, or generated JSON on a union; plain sealed covers the rest with no code generation.Dart 3's real gift isn't terseness — it's making the compiler enforce your intent. Sealed classes turn "did I handle every case?" from a code-review question and a mental grep into a hard build error. Records let you stop paying the class tax for trivial data while keeping value equality for free. Pattern matching collapses the defensive boilerplate that grows around messy, dynamic data like our Firestore reads.
If you adopt one thing this week, model a single piece of state as a sealed class and switch on it exhaustively as an expression. The first time you add a variant and the compiler marches you through every place you forgot to handle it, you'll stop thinking of this as syntax sugar. It's the type system finally working for you instead of just getting out of your way.