Records, Patterns & Sealed Classes (Dart 3)
By the end of this lesson you will be able to bundle several values into a lightweight record without writing a class, read and write every kind of pattern Dart 3 offers (matching AND pulling values out in one step), place those patterns everywhere Dart allows them (declarations, assignment, switch, if-case, for-in), and model a fixed, closed set of variants with a sealed class so the compiler — not a bug report — catches a forgotten case.
1. Records: bundling values without a class
class is) — Dart hands you a ready-made tray the instant you list the items. A record is a quick, anonymous, immutable bundle of values: perfect when you just need to group a few things together and don't need methods, inheritance, or a name for the "shape".Dart has two record styles, and you can mix them in one record:
- Positional —
(1, 'a'). Fields are accessed by an auto-generated name based on position:$1,$2, and so on. - Named —
(x: 1, y: 2). Fields are accessed by the name you gave them:.x,.y. Order doesn't matter for named fields. - Mixed —
(10, 'x', y: 20)— positional fields first, named fields after.
The type of a record is written the same way: (int, String) is the type of any positional record with an int then a String; ({int x, int y}) is the type of a named record with those two fields.
The same thing as runnable Dart — positional, named and mixed records, their types, how they print, and the two compile errors you meet first (both confirmed with dart analyze) (the // => lines are the exact output, checked by verify/d11.dart):
Input size → what's feasible: a record has a fixed number of fields written in your source (2 to 5 is typical), so creating one and reading a field are both O(1); when the count varies at runtime (n = 106 values), use a List instead.
$ (like $1) are NOT string interpolation — they are ordinary Dart identifiers. $ is a legal character to start an identifier with; Dart auto-generates $1, $2, … for positional fields so you always have a way to reach them, even though you didn't name them yourself.$1, $2, …; named fields via their own name. A record's type is written the same shape as its literal.2. Equality, identity & immutability
== compares what's in the tray (its fields), not which physical tray it is. A plain class, by contrast, is more like comparing two people's ID cards by default: even identical twins have different ID numbers, so == says "different" unless the class author teaches it otherwise.Records automatically get a structural == and a matching hashCode: two records are equal exactly when they have the same shape (same field names/positions) and every field is ==. This is generated for free — you never write == or hashCode for a record yourself.
The same thing as runnable Dart — value equality for records, identity equality for a plain class, and what const guarantees (the // => lines are the exact output, checked by verify/d11.dart):
Input size → what's feasible: == on two records compares their k fields, O(k); comparing 106 pairs of 2-field records is about 2·106 field comparisons.
final. There is no way to write myRecord.$1 = 5; — the analyzer rejects it immediately with an error like The setter '$1' isn't defined for the type '(int, String)'. If you need to "change" a record, you build a brand-new one instead (often by copying the fields you keep and swapping in the ones you don't).The same thing as runnable Dart — the rejected assignment (kept as a comment with its exact error), the "build a new record" way to change one, and the one catch: a mutable object stored inside a record can still change (the // => lines are the exact output, checked by verify/d11.dart):
That last line is the difference between shallow and deep immutability: the record's own fields can never be re-pointed, but they can point at something mutable. Lesson D22 · Mutable vs Immutable explains this properly, including how to make a deep copy.
The same thing as runnable Dart — because records compare by value they work as Set elements and Map keys, which a plain class (no ==) cannot (the // => lines are the exact output, checked by verify/d11.dart):
Input size → what's feasible: building a Set<(int, int)> of n = 106 cells costs about 106 hash computations of 2 fields each — fine for a 1000 × 1000 grid search.
== answers "do these have the same value?" while identical() answers "are these literally the same object in memory?". For records these can disagree: two records built separately with equal fields are always ==. Whether they are also identical() is a different question — and here the Dart language spec deliberately gives NO guarantee either way for plain (non-const) records: an implementation is free to treat two equal non-const records as the same object or as different objects, because records are immutable values with no notion of a persistent identity. Today's Dart (the SDK you're running) happens to build a brand-new object every time, so in practice identical() comes back false for two separately built non-const records — but a program should never rely on that as a promise, only observe it as current behavior. The one case with an actual GUARANTEE is const records: the compiler canonicalizes them, so two const records with equal fields are REQUIRED to be the exact same object (identical() == true), the same guarantee Dart already makes for other const values.3. Returning multiple values; records vs. classes
Before records, returning "two things" from a function usually meant writing a small class just to carry them, or (worse) stuffing them into a List and hoping everyone remembers which index is which. A record lets a function return several values directly, with each one clearly named:
The same thing as runnable Dart — the same one-pass minMax (full source in question q14), used two ways: by field name and destructured (the // => lines are the exact output, checked by verify/d11.dart):
Input size → what's feasible: returning a record adds O(1) to whatever the function does: minMax is one pass, ≈ 107 reads for n = 107; sorting instead would cost ≈ 2.3·108 steps.
| Question | Record | Class |
|---|---|---|
| Needs a declaration up front? | No — just write the literal | Yes — class definition somewhere |
| Equality | Structural, automatic | Identity, unless you override == |
| Mutable? | Never — always immutable | You choose (final fields or not) |
| Methods / inheritance? | No | Yes |
| Best for | A quick, local bundle of values (e.g. a function's return value) | A reusable concept with identity, behavior, or that needs to be extended |
4. What is pattern matching? Every pattern kind, one at a time
So "pattern matching" always does up to two things at once: (1) test whether a value has a certain shape, and (2) destructure it — bind the pieces you care about to new variable names, in the same step. Dart checks each pattern against a value and answers only true (matched — with any bindings filled in) or false (no match, nothing bound).
Dart 3 has fourteen kinds of pattern (the logical pattern counts once for && and ||). Step through each one below and watch it tested against a value that matches, then one that doesn't:
The same thing as runnable Dart — the first seven kinds — constant, variable, wildcard, typed, relational, logical (&& and ||) and parenthesized — each tried on a value that matches and one that does not (the // => lines are the exact output, checked by verify/d11.dart):
The same thing as runnable Dart — the null-check, null-assert and cast patterns, including the exact TypeError messages the two throwing ones produce (the // => lines are the exact output, checked by verify/d11.dart):
The same thing as runnable Dart — the four structural kinds — list (with ...rest and a bare ...), map, record and object — plus the :name shorthand (the // => lines are the exact output, checked by verify/d11.dart):
Input size → what's feasible: constant, relational, typed, null and wildcard tests are O(1); a list pattern with ...rest copies the remaining elements, so for n = 106 it costs ≈ 106 element copies — harmless once, 1012 steps if repeated inside a 106-iteration loop. Map patterns do one hash lookup per key you list.
?) and a null-assert pattern (!) look similar but behave completely differently on null: the null-check pattern politely reports "no match" and lets Dart try the next case, while the null-assert pattern throws a TypeError at runtime — "Null check operator used on a null value" — the moment it sees null. Only reach for ! when you are certain, from context, that the value cannot actually be null.! and the cast pattern as, both of which are runtime crashes if the value is the wrong shape/type — use them only when you're certain).5. Where patterns appear
Patterns aren't only for switch. Dart lets you write them in five places:
- Variable declarations —
var (a, b) = (1, 2);destructures immediately. - Assignment —
(a, b) = (b, a);is a clean, temp-variable-free swap. switchstatements & expressions — tests a value against a list of patterns, top to bottom (you saw the basics of this in D06; this lesson adds the remaining pattern kinds).if-case—if (value case pattern) { ... }tries ONE pattern without a fullswitch; can carry an optionalwhenguard.for-in—for (final (a, b) in pairs) { ... }destructures every element as it's pulled from an iterable.
The same thing as runnable Dart — declaration and assignment patterns, a wrong-length list in a declaration (it throws), and the refutable-pattern compile error (the // => lines are the exact output, checked by verify/d11.dart):
(a, b) = (b, a);, Dart fully evaluates and builds the ENTIRE right-hand-side record (b, a) before assigning anything back into a and b. That ordering is exactly why this is a safe swap — if a were overwritten first, the old value needed for b would already be lost.An if-case can carry an optional when guard — an extra boolean condition checked only after the pattern itself matches:
The same thing as runnable Dart — httpBucket and two more if-case forms: a guard on a typed pattern, and an else branch (the // => lines are the exact output, checked by verify/d11.dart):
when guard evaluates to false, the entire if-case is treated as not matched — execution does NOT partially proceed; it falls straight through to whatever comes after, exactly as if the pattern itself had failed. A guard is not a second independent check; it's a veto on an already-successful match.for-in can destructure each element as it's pulled out of any Iterable, including a List of records:
The same thing as runnable Dart — for-in destructuring of a list of records, of map entries (an object pattern on MapEntry) and of .indexed (the // => lines are the exact output, checked by verify/d11.dart):
And the two forms of switch that take patterns. A switch expression produces a value and every case ends in =>; a switch statement runs statements and never falls through to the next case, so there is no break. In both, cases are tried top to bottom and the first match wins, so put the specific cases (with guards) before the general ones:
Input size → what's feasible: a destructuring for-in is the same O(n) as a plain one; a switch with c cases tests at most c patterns per value, so 107 values × 8 cases ≈ 8·107 pattern tests (about a second).
var pattern = expr;) and for-in destructuring only accept IRREFUTABLE patterns, because there is nowhere for a "no match" to go in a bare declaration — the variable pattern, wildcard, record/list/map/object patterns (as long as everything nested inside them is also irrefutable), the cast pattern (as), and the null-assert pattern (!) all qualify, since each of those either binds a value or throws, but never returns a silent "false". switch and if-case accept BOTH families, because they have a built-in "what if it doesn't match" path (the next case, or the code after the if).2, a relational pattern like >= 0, or a null-check pattern (?) — directly in a variable declaration is a COMPILE-TIME error, not a runtime one. For example var (x?) = maybe; fails to compile with exactly this analyzer message: Refutable patterns can't be used in an irrefutable context. Try using an if-case, a 'switch' statement, or a 'switch' expression instead. The fix is always the same: move that pattern into a switch or an if-case, where a failed match has somewhere to go.switch, if-case, and for-in — but declarations and for-in only accept IRREFUTABLE patterns (never silently fail; at worst they throw), while switch/if-case also accept REFUTABLE ones (constant, relational, null-check, logical-or) because they have a fallback path for "no match". A when guard is an all-or-nothing veto on a match, not a separate branch.6. Sealed classes & exhaustiveness checking
switch and prove, with certainty, whether you've covered every possible option — something it can never promise for an ordinary open-ended class hierarchy, where anyone, anywhere, could add a new subclass.sealed class Shape {} declares that every direct subclass of Shape must be declared in the same library (file). This turns Shape into a closed set of variants — often called an algebraic data type in other languages — and unlocks exhaustiveness checking: a switch over a sealed type must handle every subtype (or include a wildcard _), or it fails to compile.
The same thing as runnable Dart — the exhaustive area switch from the player (full classes in question q16), with the two compile errors kept as comments (the // => lines are the exact output, checked by verify/d11.dart):
when guard. Even if your guarded cases logically cover every real possibility (say, every Circle has r > 0), the analyzer cannot evaluate a runtime condition at compile time — so a case pattern with a guard does not, by itself, count as "this subtype is handled". You still need a plain (unguarded) case or a wildcard _ to make the switch exhaustive. We test this exact trap in the Expert tier of the interview bank below.The same thing as runnable Dart — what the compiler counts as exhaustive: every enum value, both bool values, every combination of a record of bools, every subtype plus null for a nullable sealed type, and the guard trap and its fix (the // => lines are the exact output, checked by verify/d11.dart):
Input size → what's feasible: exhaustiveness is checked once by the analyzer, so it costs nothing at runtime; at runtime a switch over k subtypes does at most k type tests, O(k).
switch over one must be exhaustive, checked at compile time. Adding a brand-new subtype later immediately (and correctly) breaks every existing exhaustive switch over that type until you add a case for it — a deliberate safety net, not a bug.7. Class modifiers: sealed, final, base and interface
extends), or imitated from scratch by someone who just promises the same dish (implements). Class modifiers are stickers you put on the recipe to say what other kitchens may do with it. interface: "imitate the dish if you like, but don't copy my method." base: "copy my method if you like, but you must not merely imitate it, and whatever you create from it gets the same sticker." final: "neither copy nor imitate; use the recipe as it is." sealed: "this cookbook page lists every variation that will ever exist, so a cook can prove they've handled each one."First, one word: a library in Dart is, for our purposes, one .dart file (and everything it pulls in with part). The modifiers below only restrict what code in other libraries may do; inside the file that declares the class, you can always do everything. That is why the rules need two files to show an error and why the panel below runs in one file and quotes the errors from a two-file test.
| Modifier | Extend it from another library? | Implement it from another library? | Create an instance? | Exhaustive switch over its subtypes? |
|---|---|---|---|---|
(none) class | yes | yes | yes | no |
interface class | no | yes | yes | no |
base class | yes, but the subtype must itself be base, final or sealed | no | yes | no |
final class | no | no | yes | no |
sealed class | no | no | no (it is implicitly abstract) | yes |
abstract interface class | no | yes | no | no |
The same thing as runnable Dart — one declaration of each modifier and what you can do with it inside the declaring file. The cross-library errors are quoted from dart analyze on a two-file test package (the // => lines are the exact output, checked by verify/d11.dart):
Input size → what's feasible: modifiers are compile-time rules, so they cost nothing at runtime; sealed adds the exhaustiveness check described in section 6.
final is not the same as sealed. A final class only forbids outsiders from extending or implementing it; it creates no closed list of variants, so a switch over it gets no exhaustiveness help. And final on a field or variable (final int x) is a different, older meaning: "assigned once". The same word, two jobs.interface stops inheritance of code, base stops imitation (so your invariants survive), final stops both, sealed stops both and hands the compiler the full list of subtypes so it can check your switch. All of them only restrict code in other libraries.8. Real use: parsing JSON safely
Decoded JSON in Dart usually arrives as Map<String, Object?> — the value types aren't known until you check them. A map pattern checks the presence AND type of specific keys in one step, which is exactly what safe JSON parsing needs:
The same thing as runnable Dart — a describeJson switch with five map-pattern cases tried in order: extra keys, a wrong-typed value falling through, a nested map pattern, and a list pattern inside a map pattern (the // => lines are the exact output, checked by verify/d11.dart):
Input size → what's feasible: a map pattern does one hash lookup per key you list, so checking 3 keys costs ≈ 3 steps whether the map has 10 keys or 106.
9. Real use: an expression evaluator & state machines
A tiny calculator's expression tree is a classic use for a sealed hierarchy: an Expr is either a literal number, an addition, a multiplication, or a negation — nothing else. Evaluating it is then just a switch that recurses into the sub-expressions:
The same thing as runnable Dart — building the tree from the player, printing it, evaluating it, and simplifying (classes and functions are in questions q21 and q22) (the // => lines are the exact output, checked by verify/d11.dart):
Expr simplify(Expr e) that simplifies the children first and then pattern-matches on the simplified pair, with shapes like (Lit(value: 0), var b) — an object pattern nested inside a record pattern — to spot and collapse algebraic identities like "anything plus zero is just that thing" or "anything times zero is zero", without ever touching the parts of the tree that don't match. You'll build and test this exact simplifier in the interview bank (Hard tier).Sealed classes are also a natural fit for a state machine: a traffic light is either red, yellow, or green (modelled here with a plain enum, which gets the very same exhaustiveness checking as a sealed class), and a transition function is one exhaustive switch mapping each state to the next. You'll trace a full red → green → yellow → red cycle in the interview bank (Medium tier) — the same "closed set of cases, checked at compile time" idea scales from three colors up to arbitrarily complex sealed hierarchies.
The same thing as runnable Dart — the red → green → yellow → red cycle driven by one exhaustive switch (LightColor and nextLight are in question q17) (the // => lines are the exact output, checked by verify/d11.dart):
Input size → what's feasible: evalExpr visits each node once, so n = 106 nodes is about 106 steps, but recursion depth equals the tree height: keep the height below about 2000 (stack limits vary by platform; a local Dart 3.11 test overflowed only between 20,000 and 50,000 nested calls, so 2000 is a safe margin) or use an explicit stack. A state machine transition is O(1).
switch is the idiomatic Dart 3 way to write interpreters, validators, and state machines: the compiler actively helps you keep every code path in sync as the model grows.Quiz
Interview questions
Cheat sheet
| Concept | Syntax / rule |
|---|---|
| Positional record | (1, 'a') — fields via $1, $2, … |
| Named record | (x: 1, y: 2) — fields via .x, .y |
| Record type | (int, String) positional, ({int x, int y}) named |
| Record equality | Structural == + matching hashCode, generated automatically; immutable, no field setters |
| Return multiple values | ({int min, int max}) minMax(...) { ...; return (min: lo, max: hi); } |
| Constant / variable / wildcard pattern | 2 => … / var n => … / _ => … |
| Typed / relational / logical pattern | String s => … / >= 0 => … / >= 75 && < 90 => … |
| Null-check / null-assert pattern | var n? => … (fails cleanly on null) / var x! => … (throws TypeError on null) |
| Cast pattern | var (a as int, b) = pair; — throws TypeError if the cast is wrong |
| List / map / object / record pattern | [a, b, ...rest] / {'k': v} / Point(x: var px, y: var py) / (int a, String b) |
| Parenthesized pattern | (> 5) && (< 10) — groups sub-patterns |
| Refutable vs. irrefutable | Declarations & for-in need IRREFUTABLE patterns only (never silently fail); refutable patterns (constant, relational, null-check, logical-or) are compile errors there — use switch/if-case instead |
| Declaration / swap | var (a, b) = (1, 2); / (a, b) = (b, a); |
if-case with guard | if (v case pattern when cond) { ... } — guard false = whole match fails |
for-in destructuring | for (final (a, b) in pairs) { ... } |
| Sealed class | sealed class Shape {} — all direct subtypes must live in the same library; enables exhaustiveness checking |
| Class modifiers | interface class (implement only), base class (extend only, subtypes stay base/final/sealed), final class (neither), sealed class (neither + abstract + exhaustive switch); they restrict code in OTHER libraries |
| Exhaustiveness | Missing a subtype's case (even with guards on the others) is a compile error; a wildcard _ or every case makes it exhaustive |