Three years of shipping Dart 3 records and patterns: where they genuinely help, why positional fields turn into a trap, and why sealed classes are the real win.
Dart 3 landed records and pattern matching in May 2023, and the reaction was exactly what you'd expect: tuples at last, destructuring at last, exhaustive switches at last. Within a week half the Flutter blogosphere had rewritten a Pair<A, B> class into (A, B) and called it a win. Three years on, having shipped this stuff across 234 Dart files in my portfolio site, the whole devShakib tools family, and production code at Shpper, I've landed on a much narrower opinion than the release notes wanted me to have.
Here's the short version: the headline feature is records, but the feature that actually changed how I write Dart is sealed classes plus exhaustive switch expressions. Records are a convenience with a readability cliff about two fields wide. Exhaustiveness is a correctness tool that converts a whole category of "I added a state and forgot to handle it" bugs from runtime surprises into compile errors. Those two things shipped in the same release and get discussed in the same breath, and they are not remotely the same kind of feature.
This post is where I've actually let records in, where I've thrown them straight back out in review, why positional fields are a trap past the second one, how records interact with equality and const in ways that will bite you in Riverpod, and how I'd migrate an existing codebase — which, spoiler, mostly means not migrating it.
The honest use case is the one everybody cites first, and it's real: returning two or three related values from a private function without inventing a class that exists only to be immediately taken apart.
Before Dart 3, a helper that needed to hand back a filename and a MIME type gave you three bad options — an out parameter, a two-field class you'd never use anywhere else, or a Map<String, String> with stringly-typed keys and no type safety. All three are worse than the language-level answer:
(String, String) get _downloadMeta => switch (_format) { 1 || 2 => ('blob.css', 'text/css'), 3 => ('blob_path.dart', 'text/plain'), _ => ('blob.svg', 'image/svg+xml'), };That's real code from the blob maker on my tools site. A private getter, one call site, two values that only make sense together. Inventing a _DownloadMeta class for it would have been ceremony for its own sake. (I'll come back to this snippet in a minute, because it also demonstrates exactly the mistake I'm about to warn you about.)
The second half of the value is at the call site, where the record gets taken apart in a single line:
final (filename, mime) = _downloadMeta;downloadBytes(bytes, filename: filename, mimeType: mime);
No temporary variable holding a wrapper object, no .item1, no meta.filename chain. The destructuring pattern is the variable declaration. When the function that produced the record is three lines above the function that consumes it, this genuinely reads better than the alternative.
So the sweet spot is narrow and specific: private scope, few call sites, values with no independent meaning, and a shape you can hold in your head. A local helper inside a State class. A (min, max) bounds pair. A (width, height) measurement. Something that gets created and destructured within the same screenful of code.
The moment any of those conditions stop holding — it escapes the file, it grows a fourth field, someone wants to attach a method to it — you're past the point where the record was the right tool, and the fix is to give the thing a name.
Now the part that actually matters. A sealed class tells the compiler that the complete set of subtypes is known and closed, which means the compiler can verify that a switch over that type handles every case. Miss one and the build fails. Not a runtime exception in front of a user — a red squiggle before you commit.
Here's the shape, from the CSS formatter on my site:
sealed class _CssNode {}class _CssDecl extends _CssNode { /* prop, value, important */ }class _CssRule extends _CssNode { /* selector, children */ }class _CssComment extends _CssNode { /* text */ }class _CssRaw extends _CssNode { /* @import, @charset, ... */ }The serializer switches over that hierarchy. When I added _CssRaw — because @import statements have no body and were being mangled by the rule serializer — I didn't have to go hunting for every place that handled CSS nodes. The analyzer handed me the list. Four compile errors, four places to fix, done. That's the entire pitch, and it's worth more than every record in the language put together.
The same pattern maps directly onto UI state, which is where most Flutter developers will feel it:
sealed class PostsState {}class PostsLoading extends PostsState {}class PostsData extends PostsState { PostsData(this.posts); final List<Post> posts; }class PostsEmpty extends PostsState {}class PostsError extends PostsState { PostsError(this.message); final String message; }Widget build(BuildContext context) => switch (state) { PostsLoading() => const _SkeletonList(), PostsData(:final posts) => _PostList(posts), PostsEmpty() => const _EmptyState(), PostsError(:final message) => _ErrorState(message), };Compare that to the boolean soup it replaces — if (isLoading) ... else if (error != null) ... else if (posts.isEmpty) ... — where the states aren't mutually exclusive at the type level, where isLoading && error != null is representable and meaningless, and where adding an offline state means auditing every if chain by hand.
Note PostsData(:final posts) — that's an object pattern with a named field shorthand. It matches the type and binds the field in one move, so you never write (state as PostsData).posts. This is pattern matching pulling real weight, and it has nothing to do with records.
Here is the mistake I have made, and reviewed out of other people's code, more than any other in this area: the moment you add a default: or a _ => wildcard to a switch over a sealed type, you have deleted exhaustiveness checking.
// Looks defensive. Is actually a silent runtime bug factory.Widget build(BuildContext context) => switch (state) { PostsLoading() => const _SkeletonList(), PostsData(:final posts) => _PostList(posts), _ => const SizedBox.shrink(), // <-- never write this };That compiles forever. Add PostsOffline next quarter and it renders a blank box in production while the analyzer says nothing. The entire value proposition of sealed is that the compiler nags you, and a wildcard is you telling the compiler to stop. If you feel the urge to add one "just in case", that urge is the feature working correctly — it means you have a case you haven't decided about yet, and the right move is to decide.
The corollary: switch on the sealed supertype, not on some derived value. switch (state.runtimeType) or switch (state.kind) gets you none of this. The exhaustiveness check is a property of the static type you're switching over.
Now let me go back and criticise my own snippet, because it's the cleanest example of the problem I know.
(String, String) get _downloadMeta => ...
Two fields. Both String. Which one is the filename and which one is the MIME type? The type signature does not tell you. The call site does not tell you. Nothing tells you except reading the body of the getter, and this compiles perfectly:
final (mime, filename) = _downloadMeta; // silently backwards
No error, no warning, and the bug ships as a downloaded file named text/css. Two positional fields of the same type is not a convenience, it's an unlabelled contract, and every future reader pays the tax of going and looking. The fix costs nine characters:
({String filename, String mime}) get _downloadMeta => switch (_format) { 1 || 2 => (filename: 'blob.css', mime: 'text/css'), 3 => (filename: 'blob_path.dart', mime: 'text/plain'), _ => (filename: 'blob.svg', mime: 'image/svg+xml'), };Now the signature is self-documenting, the destructuring is order-independent (final (:mime, :filename) = _downloadMeta; works fine), and adding a third field doesn't renumber anything. Elsewhere in the same codebase I got this right the first time, in the CSS parser:
({List<_CssNode> nodes, int unclosed, int extraClose}) _parseCss(String src)You can read that signature cold and know exactly what comes back. That's the bar.
So the rule I now apply without exceptions:
(Offset, double) for a point and a radius. Even then, named costs you nothing.$1 or $2 appears anywhere outside a destructuring pattern, the review comment writes itself. Reaching for result.$2 means you've built an anonymous struct with numbers for field names, and you should have built a real one.Two more patterns that pull their weight in real code.
case ... when lets you put the branch condition alongside the branch itself, instead of nesting an if inside a case body:
String label(Duration d) => switch (d.inSeconds) { < 60 => '${d.inSeconds}s', final s when s < 3600 => '${s ~/ 60}m', final s when s < 86400 => '${s ~/ 3600}h', final s => '${s ~/ 86400}d', };That reads top-to-bottom as a decision table, which is what it is. The relational patterns (< 60) and the guards (when s < 3600) are doing the work a chain of if/else if used to do, with less punctuation and no chance of a dangling else.
Parsing is the one place I think records-and-patterns genuinely improve safety rather than just ergonomics. The classic Dart JSON parse is a pile of casts:
final slug = json['slug'] as String; // TypeError at runtime if wrongfinal tags = (json['tags'] as List).cast<String>();
Every one of those as is a runtime bomb. The pattern version turns a shape mismatch into a branch instead of a throw:
Post? parsePost(Object? json) { if (json case { 'slug': final String slug, 'title': final String title, 'tags': final List<Object?> rawTags, }) { return Post(slug: slug, title: title, tags: rawTags.whereType<String>().toList()); } return null; // malformed doc: skip it, don't crash the list}For a Firestore collection where one bad document shouldn't take down the whole blog index, that difference is the difference between a missing post and a white screen.
But don't oversell it, because I've seen people treat this as schema validation and it isn't. A map pattern checks that the key is present and the value has the right static type. It does not distinguish "key absent" from "key present with a null value" in any way you'd call ergonomic, it doesn't validate ranges or formats, and final List<Object?> rawTags tells you nothing about the elements — hence the whereType<String>(). Map patterns are a safe cast, not a validator. If you need real validation, you still need real validation.
Three places, and I hold these lines hard in review.
Not as a substitute for a domain type that deserves a name. If the thing has invariants, if it has behaviour, if it appears in more than one file, or if you'd struggle to explain it without using a noun — it wants to be a class. A record can't implement an interface, can't declare a method (extensions on records are a smell, not a solution), can't be subtyped, can't have a constructor that validates, and doesn't show up usefully in a stack trace. A six-line class with named parameters and a const constructor costs you almost nothing and gives you a name, a doc comment, a home for methods, and somewhere to put copyWith.
Not in a public API surface. This is the one that turns into a support burden. A positional record in a published signature is an unreadable contract for every consumer, and worse, it's a frozen one: adding a field to (String, int) is a breaking change for every destructure in every downstream package. Named record fields help with readability but not with evolution, and — this catches people out — you cannot attach a doc comment to an individual record field. Your /// block has to describe the whole shape in prose. For anything crossing a package boundary, define the class.
Not past three fields. Records have no copyWith, which means changing one field of a four-field record means retyping the whole literal at every call site. There's no fromJson/toJson, no freezed codegen, no @immutable annotation to lean on. Every one of those gaps gets more painful as the field count climbs, and the crossover point where the class would have been less work is a lot earlier than people expect.
Records have structural equality built in: (1, 'a') == (1, 'a') is true, and hashCode is derived from the fields the same way. That's genuinely useful — records make excellent Map keys and memoisation keys, and they work correctly as Riverpod family arguments where a hand-rolled class without == would silently create a new provider on every rebuild.
const works too, as long as the fields are const: const ('blob.css', 'text/css') is a compile-time constant, and named-field records const the same way.
Now the trap. Record equality is only as good as the equality of the fields inside it. Dart's List compares by identity, not contents, so:
(1, [2]) == (1, [2]) // false — two different list instances
This is the bug that shows up as "why is my widget rebuilding on every frame". You return (posts, isLoading) from a provider or a BlocSelector, the list is rebuilt each time, the record's == says "different", and your rebuild avoidance goes out the window — with no error and no obvious cause. The record looks like it gave you value semantics. It gave you value semantics one level deep.
Same applies to Map, Set, and any class of your own that hasn't overridden ==. If you're putting a record where equality matters, every field in it needs meaningful equality, or you need to compare on something else entirely. It's the same discipline you'd apply to a hand-written operator ==; the record just makes it easy to forget you're writing one.
My advice here is blunt: don't migrate. Adopt.
There is no runtime payoff. A record is not measurably faster than a small final class, it doesn't shrink your bundle, it doesn't reduce allocations in any way you'll ever profile. Rewriting working Pair/Tuple2 classes into records is churn with a nonzero bug rate and zero user-visible benefit. I have never once regretted leaving a working two-field class alone.
What I do instead:
The one migration I do push for eagerly, and would prioritise over everything else in this post: convert your abstract class + is checks state hierarchies to sealed. That one is a genuine correctness upgrade rather than a style change, and it's mechanical:
sealed. All subtypes must live in the same library — if yours are spread across files, that means a part/part of arrangement or consolidating them into one file. This is usually the only fiddly step.default: and _ => from switches over that type.Step 3 is where you find the bugs. Every case the compiler flags is a state that was previously falling into a default branch and doing something generic — a blank widget, a swallowed error, a stale value. On the state machine I converted first, the analyzer surfaced two genuine handling gaps that had been shipping quietly for months.
The team rule I settled on, if you want one line for your style guide: records are allowed in private scope, must use named fields past a single value, are capped at three fields, and never appear in a public signature. Sealed classes with exhaustive switches are the default for anything that models a closed set of states, and a wildcard case in one of those switches is a review blocker.
default: or _ => in a switch over a sealed type deletes exhaustiveness checking entirely, which makes it the single most damaging line you can add to an otherwise well-modelled state machine.List compares by identity and will quietly defeat your rebuild avoidance in Riverpod or Bloc.TypeError, but they don't check ranges, formats, or element types.sealed instead.Records are a good small feature that got marketed like a large one. They remove a specific, real annoyance — the throwaway two-field class — and they introduce a specific, real risk in exchange, which is anonymous structs leaking into places that needed a name. Keep them local, keep them named, keep them short, and they'll pay for themselves quietly. Meanwhile the feature that shipped alongside them, and got a fraction of the attention, is the one that will actually stop you shipping a bug: model your closed sets as sealed classes, switch over them exhaustively, and never let a wildcard talk the compiler out of helping you.