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

Think of a record like a sealed lunch tray with fixed compartments: one slot for rice, one for curry, one for salad. You didn't have to design and manufacture a whole new tray shape (that's what writing a 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:

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.

Record field names starting with $ (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.
A record groups multiple values into one value without declaring a class. Positional fields are read via $1, $2, …; named fields via their own name. A record's type is written the same shape as its literal.

2. Equality, identity & immutability

Two identical twin lunch trays, packed by two different people but with the exact same rice, curry, and salad, LOOK the same and taste the same — a record's == 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.

Records are immutable: every field is effectively 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.
Records compare by VALUE (structural equality + matching hashCode) automatically. Plain classes compare by IDENTITY by default. Records are immutable — no field setters exist, ever.

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.

QuestionRecordClass
Needs a declaration up front?No — just write the literalYes — class definition somewhere
EqualityStructural, automaticIdentity, unless you override ==
Mutable?Never — always immutableYou choose (final fields or not)
Methods / inheritance?NoYes
Best forA quick, local bundle of values (e.g. a function's return value)A reusable concept with identity, behavior, or that needs to be extended
Reach for a record when you just need to group a few values together, especially to return more than one value from a function. Reach for a class when the bundle needs a name that spreads across your codebase, behavior (methods), or inheritance.

4. What is pattern matching? Every pattern kind, one at a time

A pattern is like a stencil held up against a shape. If the shape fits through the stencil's cut-out, it's a match — and the stencil can also have little windows that let you read off individual measurements of the shape as it passes through (that's destructuring: pulling values out while matching). Some stencils only check a yes/no fact (does this number sit in this notch?); others also hand you back named pieces of what matched.

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.

A null-check pattern (?) 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.
Every pattern either matches-and-destructures or fails cleanly (with two exceptions that actively throw: the null-assert pattern ! 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:

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):

In an assignment swap (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):

If the pattern matches but the 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).

Not every pattern is allowed in every one of those five places. Dart splits patterns into two families: irrefutable patterns can never fail to match — they either succeed or (for two special cases) throw, but they never quietly say "no" — and refutable patterns CAN fail to match and fall through to something else. Variable declarations (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).
Writing a REFUTABLE pattern — a constant pattern like 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.
Patterns appear in declarations, assignment, 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

A sealed class is like a restaurant menu printed under glass on the table: there is a Circle option, a Square option, a Triangle option — and the glass makes it physically impossible for a waiter to sneak in a fourth, secret option without you noticing, because the WHOLE menu lives in one place. Because the "menu" (the sealed class and all its direct subtypes) is closed and fully visible to the compiler, the compiler can look at your 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):

Exhaustiveness checking is NOT fooled by a 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).

Sealed classes model a closed set of variants. A 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

Think of a class as a recipe in a cookbook. An ordinary recipe can be copied and tweaked by anyone (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.

ModifierExtend it from another library?Implement it from another library?Create an instance?Exhaustive switch over its subtypes?
(none) classyesyesyesno
interface classnoyesyesno
base classyes, but the subtype must itself be base, final or sealednoyesno
final classnonoyesno
sealed classnonono (it is implicitly abstract)yes
abstract interface classnoyesnono

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.
Pick the sticker by what you want to forbid: 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.

A map pattern only checks the keys YOU listed — it does not require the map to have exactly those keys and no others. Extra, unmentioned keys are silently ignored and still match. If a listed key is missing, or present with the wrong type, the pattern simply fails to match (no exception) — matching is what lets you cleanly fall through to a "this JSON is invalid" branch instead of crashing.

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):

The same shape shows up as a simplifier: a function 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).

A sealed hierarchy plus an exhaustive 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

ConceptSyntax / 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 equalityStructural == + matching hashCode, generated automatically; immutable, no field setters
Return multiple values({int min, int max}) minMax(...) { ...; return (min: lo, max: hi); }
Constant / variable / wildcard pattern2 => … / var n => … / _ => …
Typed / relational / logical patternString s => … / >= 0 => … / >= 75 && < 90 => …
Null-check / null-assert patternvar n? => … (fails cleanly on null) / var x! => … (throws TypeError on null)
Cast patternvar (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. irrefutableDeclarations & 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 / swapvar (a, b) = (1, 2); / (a, b) = (b, a);
if-case with guardif (v case pattern when cond) { ... } — guard false = whole match fails
for-in destructuringfor (final (a, b) in pairs) { ... }
Sealed classsealed class Shape {} — all direct subtypes must live in the same library; enables exhaustiveness checking
Class modifiersinterface 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
ExhaustivenessMissing a subtype's case (even with guards on the others) is a compile error; a wildcard _ or every case makes it exhaustive