Sparround

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 as row.$1, row.$2
  • Named fields: ({int id, String name}) row = (id: 1, name: 'Ayan'); — accessed as row.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(); or final (: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 break is 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?).

ConstructExampleWhen 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.

📚 Sources and documentation