A record is an anonymous, immutable, fixed-size group of values, like (200, 'ok') or (name: 'Ada', age: 36). Its type is its shape (the number of fields, their names and types), so (int, String) needs no declaration anywhere.
Positional fields: read with $1, $2, …; order is part of the type, so (int, String) and (String, int) are different types
Named fields: read by name; their order does not matter, (x: 1, y: 2) has the same type as (y: 2, x: 1)
Names in a positional type are documentation: in (int code, String body) the getter is still $1, not .code
Common mistake: (1) is just the number 1; a one-field record needs a trailing comma: (1,)
FlutterMiddle
Dart 3 Patterns & Records
Records, destructuring, switch expressions, if-case guards, sealed types, exhaustiveness
Questions closed
0 / 19
- 0Review
- 4Asked often
Questions
You write a pattern with the record's shape on the left: final (code, body) = fetch(); for positional fields, final (:name, :age) = user; for named ones. The same patterns work in assignments, for-in loops, switch and if-case.
**:name shorthand**: (:name) means (name: name): a variable named after the field; write (name: who) to pick another name
**_ skips a field**: final (_, body) = fetch(); binds only body
Pattern assignment: (a, b) = (b, a); swaps two existing variables without a temporary; only local variables and parameters may appear on the left, not fields or list[0]
A declaration must always match: the compiler checks the shape, so final (int a, String b) = someObject; is a compile error; when the match can fail, use if-case or switch
A switch statement runs code for the matching case; a switch expression evaluates to a value, so you can assign it, return it or pass it as an argument. Both match patterns, and both are checked for exhaustiveness on enums and sealed types.
Expression syntax: pattern => value, separated by commas: no case, no break, and the catch-all is _, not default
An expression must cover every input: it has to produce a value, so a switch on int or String needs a _ arm
Statements changed in Dart 3: a non-empty case no longer needs break and never falls through; empty cases still share the next body
Common mistake: a switch expression cannot stand alone as a statement: switch (x) { 1 => "a", _ => "b" }; is parsed as a switch statement and fails, so assign or return it
It prints true, true, false, Hello, null. A record's == and hashCode are built from its fields, so records with equal fields are equal, but only as far as each field's own == goes, and a List compares by identity.
**Second print: the order of named fields is not part of the shape, so the records are equal
Third print**: (int, int) and ({int x, int y}) are different shapes: never equal, and not a compile error either
**cache hits**: a new record instance with equal fields finds the entry, so records give you compound Map keys without writing hashCode
**byTags misses**: the two ['a'] literals are different objects, and [1] == [1] is false in Dart, so the records differ
Fix: make every field value-comparable: a String, a number, an enum or an immutable class with == and hashCode; and never mutate an object that is part of a key already in the map
Use a record for a small, short-lived group of values, typically two or three results of a private function: it is fully typed like a class and needs no declaration. It is the wrong choice once the data has a name in your domain, rules or behaviour.
**Over a Map: the shape is in the signature, so a mistyped key or a wrong type is an analyzer error rather than a failed cast at runtime
Over a class**: no declaration, and ==, hashCode and toString come for free, plus destructuring at the call site
Wrong for public APIs: a record has no name in docs or error messages, and adding a field changes its type at every call site
Wrong when there are rules or behaviour: no methods (only extensions), no constructor to validate input, no copyWith, no fromJson
Wrong in hierarchies: a record cannot implement an interface or be a subtype of a sealed class
Rule of thumb: a record for an internal "return two values"; a class as soon as it crosses a module boundary or the team starts calling it by a name
A pattern describes the shape a value should have; matching checks the value against it and binds the parts you name to new variables in one step. It replaces chains of is checks and casts, null checks followed by !, switch limited to constants, and hand-written field extraction.
Where patterns appear: switch statements and expressions, if (x case …), variable declarations, assignments and for-in loops
Kinds: constant (404, Status.active), variable (final n, int n), wildcard _, record (a, b), list [first, ...], map {'id': final id}, object User(:name), relational > 0, logical && / ||, null-check x?, null-assert x! and cast x as T
Refutable vs irrefutable: in switch and if-case a pattern may fail and the next case is tried; in a declaration it must always match, which the compiler checks
A bare name is a constant: case maxRetries: compares with ==; write case final n to bind a variable instead
The payoff: less code, and on enums and sealed types the compiler proves that a switch handles every case
A sealed class is an implicitly abstract class whose direct subtypes must all be declared in the same library, so the compiler knows the complete list. A switch over it that misses a subtype does not compile, which turns "forgot to handle the error state" from a runtime bug into a compile error.
Versus an abstract class: anyone can extend an abstract class from any file, so a switch over it can never be proven complete and needs a default
State modelling: each subtype carries only its own data (SignedIn(userName), AuthError(message)), so there is no isLoading flag next to a nullable error
**No default on purpose**: add Offline later and every switch that does not handle it turns red, giving you the to-do list
Rules: a sealed class cannot be instantiated; its subtypes are usually final class so nobody outside extends them further
Also exhaustive: enums, bool, nullable types (they need a null arm) and records built from these
It matches value against a pattern, binds the variables the pattern names, and then checks an extra boolean guard, all in one condition. It replaces "check for null or a type, then cast or add !", and the bound variable is a fresh local with an already-narrowed type.
Scope: the bound variables exist in the when guard and the then-branch only; in else nothing matched, so they do not exist there
Works where promotion does not: if (s.token case final token?) gives a non-null local even when token is a public mutable field that != null would not promote
One read: the field is read once, so nothing can change it between the check and the use
**when is any expression: it runs after the match and can use every variable the pattern bound
In a switch**: a failed guard just moves on to the next case; guarded arms do not count towards exhaustiveness
It first tests value is Cart, then calls the total getter and matches the result against > 100. Every name before a colon is a getter call, so the pattern pays whatever that getter costs, and an exception thrown inside it propagates out of the switch.
Getters, not just fields: computed, inherited and extension getters all work; Cart(:final items) is short for Cart(items: final items)
Named only: Point(1, 2) is a compile error, because object patterns have no positional form; const Point(1, 2) is a different thing, a constant pattern compared with ==
Order: the type test runs first, then subpatterns left to right, stopping at the first one that fails
**Called once per switch**: the result of each getter is cached across the arms of one switch, but every new switch or build calls it again
Keep getters cheap and pure: if total folds a long list or has side effects, read it once into a local and switch on that
It checks that the value is a List, checks its length (exactly the element count without a rest element, at least that count with one) and reads elements by index from both ends. A named rest (...final rest) copies the middle into a new list; a bare ... copies nothing.
Type test: against List<Object?>, or List<int> if you write <int>[…]; a Set or a lazy where() result never matches, so call toList() first
Length: [a, b] matches only two-element lists, [a, ...] matches one or more, [] matches only an empty list
At most one rest element: it can sit anywhere, but two would make the split ambiguous
Cost: ...final rest allocates a new list on every match, O(n); in a hot path use a bare ... and read what you need by index
Typical uses: empty / single / many UI states, command parsing like ['add', ...final files], head-and-tail recursion
A map pattern checks that the value is a Map, then looks up each key you listed and matches its value against a subpattern. Extra keys are ignored (it matches a subset), and a missing key or a value of the wrong type simply fails the match, without throwing.
Type checks live in subpatterns: {'age': final int age} fails when age is the string "36", which is how you validate JSON without as casts
Keys must be constants: they are compared with ==; for a key held in a variable, look it up first and match the value
No empty pattern, no size check, no rest element: {} is a compile error ("A map pattern must have at least one entry"); to test only the type write Map(); to require exactly these keys, add when json.length == 2
Missing versus null: {'email': final email} matches {'email': null} and binds null; {'email': final String email} does not
Silent failure: a missing key and a wrong type look the same, so a map pattern is good for choosing a branch by shape but poor at telling the caller which field was wrong; for API models keep a real fromJson
Relational patterns compare the value with a constant using ==, !=, <, <=, > or >=; logical patterns combine patterns with && and ||. Together they express ranges such as >= 18 && < 65. When the bound comes from a variable rather than a constant, use a when guard.
The operand must be a constant: case > minAge fails with "The relational pattern expression must be a constant"; when age > minAge accepts any expression
The operator is really called: the matched type must define it: a switch on a String cannot use > 'a'
**|| shares one body**: 'sat' || 'sun' => true; if the branches bind variables, each must bind the same names with the same types, so int n || double n is rejected; write num n
**&& binds tighter than ||**: the usual way to write a range such as >= 18 && < 65
Ranges do not count for exhaustiveness: < 0 plus >= 0 still needs a _ arm, because the checker does not reason about numbers
It prints big int, int, num 2.5, empty string, string hi, other. Arms are tried top to bottom, and the first one whose pattern and guard both match wins; later arms are never checked.
**42**: matches int n, and the guard n > 10 holds
**7**: the first arm's guard fails, so matching moves on to int(), a plain type test
**2.5**: not an int, but a double is a num
**''**: String(isEmpty: true) calls the isEmpty getter and compares the result with the constant true
**true: only the wildcard is left
Order trap**: put num n first and int() can never match; the analyzer warns "This case is covered by the previous cases", so order arms from specific to general, like catch clauses
The only Ok arm has a when guard, and guarded arms do not count towards exhaustiveness. The compiler sees Ok values with value <= 0 matching nothing and reports: "The type 'Result' isn't exhaustively matched by the switch cases since it doesn't match the pattern 'Ok()'".
Why guards are ignored: a guard is arbitrary code, so the compiler cannot know what it rejects and treats the arm as covering nothing
Ranges are not enough either: Ok(value: > 0) plus Ok(value: <= 0) is still reported; the checker reasons about types, enum values, bool, null and list lengths, not numbers
Fix: add an unguarded arm after the guarded one: Ok(:final value) => 'value $value'
**Do not fix it with _**: _ => compiles, but the day a third subtype is added it will be swallowed silently instead of turning this switch red
Loading => is not a type test: a bare type name in a pattern is a constant pattern that compares the state with the Type object Loading using ==. No LoadState ever equals a Type, so loading falls through to _ and shows the error text.
Why it compiles: a type literal is a valid constant, so the analyzer only warns: "The matched value type 'LoadState' can never be equal to this constant of type 'Type'"
Fix: write the object pattern Loading(), which checks state is Loading; Loading _ works too
**The _ arm hid it**: without a wildcard the compiler would have reported that the switch does not match Loading(); list every subtype of a sealed class instead of _, so a new subtype also turns the switch red
Catch it next time: treat warnings as errors in CI and enable the type_literal_in_constant_pattern lint, which flags exactly this; it usually creeps in when old case Foo: habits meet Dart 3 patterns
The pattern requires price to be a double, but jsonDecode turns 12 into an int and only 12.5 into a double. Every product with a whole-number price fails the match, and parseProduct silently returns null.
Why it fails quietly: a map pattern never throws: a type mismatch in a subpattern just makes the whole if-case false, so the bug looks like bad data, not bad code
Why tests may miss it: fixtures often use prices like 4.99; and on the web (dart2js) an integral number is both int and double, so the same code passes there and fails on mobile
Fix: match the type JSON can actually produce, num price, and convert with price.toDouble()
Catch it next time: keep a parser test with an integral value ("price": 12) for every numeric field, and log the raw JSON when parsing returns null
Dart 3.0 also brought class modifiers such as sealed and final; later releases added extension types (3.3), digit separators (3.6), wildcard _ variables (3.7), null-aware collection elements (3.8), dot shorthands (3.10) and primary constructors (3.13). Each removes a small, common piece of boilerplate.
Class modifiers (3.0): sealed, final, base, interface and mixin class control who may extend or implement a class; sealed is the one behind exhaustive switch
Extension types (3.3): extension type UserId(String value) is a typed wrapper with no runtime object, replacing wrapper classes for ids and units
Digit separators (3.6): 1_000_000 instead of counting zeros
Wildcards (3.7): a local or parameter named _ binds nothing, so (_, _) => 0 is legal and the (_, __) trick is no longer needed; reading _ is now an error
Null-aware elements (3.8): [?promo] and {'email': ?email} add an entry only when the value is non-null, replacing if (x != null) x
Dot shorthands (3.10): .center instead of MainAxisAlignment.center wherever the type is known from context
Primary constructors (3.13): class Point(final int x, final int y); declares the constructor and the fields in the class header, replacing the usual constructor-and-fields boilerplate
Opt-in by SDK constraint: features follow the package's language version (sdk: ^3.13.0), so raising the lower bound can surface new errors, such as old code that reads _
Every exhaustive switch over that type (in your code and in every package that depends on it) stops compiling until it handles the new case. That is the feature working, but it makes adding a subtype a breaking change: for a published package, a major version bump.
Why it breaks
The set is closed by design: all direct subtypes live in one library, so consumers are allowed to omit default, and the compiler holds them to it
Both forms are checked: switch statements and expressions, including nullable scrutinees that need a null arm; guarded arms cover nothing
Dart has no escape hatch: there is no equivalent of Swift's @unknown default or Rust's #[non_exhaustive] to say "more cases may come"
How to ship it
Inside one app: add the subtype and treat the red switches as the to-do list; the compiler found every place that must decide what the new case means
Published package: bump the major version, list the new subtype in the changelog and say what callers should render or do for it
Monorepo: land the subtype and all the new arms in one change, so no package is ever on a version that does not compile
When not to seal
Others must add cases: plugins, third-party payment providers, app-defined events: use an abstract interface class with a method each implementation overrides, so a new case brings its own behaviour and no switch breaks
You want to add cases freely: keep it a plain abstract class and accept that callers need a default
Common mistake: a reflexive _ => in core logic turns "does not compile" back into "silently does the wrong thing at runtime"
Matching and destructuring are type tests and getter reads, which allocate nothing; creating a record allocates an object, like any constructor call. The real costs hide in two places: a named rest element copies part of a list, and patterns call your getters and operators.
What allocates
Creating a record: one object per call; a const record is canonicalized and costs nothing per frame
Often optimized away: when a record is created and destructured in the same function and never escapes, the optimizing compiler usually removes the allocation; in debug mode it does not, so debug numbers say nothing
**...final rest**: a new list on every match, O(n); a bare ... copies nothing
**for (final MapEntry(:key, :value) in map.entries)**: one MapEntry per step; that is the iteration, not the pattern
What runs your code
Your code runs inside matching: getters, length, [], == and relational operators are called; see the object-pattern question for caching and cost
Exceptions are not a failed match: a throwing getter escapes the switch, and no later arm runs
How to check instead of guessing
Profile mode on a real device: flutter run --profile
DevTools Memory: trace allocations for the class and read counts per frame
DevTools Performance: check first whether build runs more often than it should; that is usually the real problem, not the pattern inside it