Records and pattern matching
A record is the lightweight, anonymous, immutable value type introduced in Dart 3. It lets you return several values together without declaring a class.
- Positional fields:
(int, String) row = (1, 'Ayan');— accessed asrow.$1,row.$2 - Named fields:
({int id, String name}) row = (id: 1, name: 'Ayan');— accessed asrow.id - Mixed:
(int, {String name})
Records have structural equality: (1, 'a') == (1, 'a') is true, with no ==/hashCode to write. They are immutable too — a field cannot be reassigned.
When a record, when a class? A record for local, unnamed, behaviour-free data: a function returning two values, grouping Future.wait results, an intermediate accumulator inside fold. A class for a domain concept: a type with a name, validation, methods and documentation. Returning a record from a public API is usually a mistake, because field names do not fully document intent and the shape is hard to evolve.
Pattern matching is the other half of records. A pattern is a construct that checks the shape of a value and destructures it into variables at the same time.
Where patterns appear:
- Destructuring declarations:
final (id, name) = fetchRow();orfinal (:id, :name) = row;(the shorthand for named fields) - `switch` expressions: they return a value, have no
break, and branches are separated by, - `switch` statements: since Dart 3
breakis no longer required (no fall-through) - `if-case`:
if (json case {'id': final int id}) { ... }— for checking a single shape - Guards (`when`):
case Circle(:final r) when r > 10 =>— an extra condition applied after the pattern matches
Pattern kinds: object patterns (Circle(radius: final r)), record patterns ((final a, final b)), list patterns ([final first, ...final rest]), map patterns ({'id': final id}), constant patterns (0), wildcards (_), logical-or (1 || 2), cast patterns (x as int) and null-check patterns (final x?).
| Construct | Example | When to use |
|---|---|---|
| `switch` expression | `final label = switch (s) { Loading() => 'wait', _ => 'ok' };` | Computing a value |
| `switch` statement | `switch (s) { case Loading(): show(); }` | Performing side effects |
| `if-case` | `if (json case {'id': final int id}) ...` | Only one shape is of interest |
| Destructuring declaration | `final (:width, :height) = size;` | Unpacking a record or object |
| Guard `when` | `case int n when n < 0 => 'negative',` | An extra condition after the shape matches |
Exhaustiveness — the most valuable part of this topic. When you switch over a sealed class hierarchy, the compiler verifies that every subtype is covered. If you later add a subclass, every uncovered switch becomes a compile error — the editor points you at every place that needs updating.
The typical shape is sealed class Result {}, then final class Ok extends Result {...} and final class Err extends Result {...} — both in the same file.
Writing a default or _ branch here switches exhaustiveness checking off — and that is the most common mistake. With a default, adding a new subtype leaves the compiler silent and the bug escapes to runtime. So do not write default over a sealed hierarchy.
sealed gives three things at once: the class becomes abstract, it cannot be extended or implemented outside its own library, and the compiler knows the complete list of subtypes. Related modifiers: final (cannot be extended), base (extend only, no implement) and interface (implement only).
Interview tip. "When do you use a record and when a class?" is a judgement question and you should have the answer ready: "A record for local, unnamed, behaviour-free data (returning two values, an intermediate accumulator); a class for a domain concept with a name, validation and methods. I do not return records from a public API."
The second question is almost always about exhaustiveness. The strongest answer is a concrete scenario: "I model state as a `sealed` hierarchy and switch over it with a `switch` expression; when I add a new state the compiler flags every uncovered site as an error. A `default` branch would throw that protection away — which is why I never write `default` over a `sealed` type." That answer signals you use the type system as a bug-finding tool.