Dart 3 Patterns & Records
Records, destructuring, switch expressions, if-case guards, sealed types, exhaustiveness
- 01
Nothing in the codebase declares a type for
(int, String), yet it type-checks everywhere — how does a record's type work, and where does.$1come from?EasyA record's type is its shape — how many positional fields it has, the names of its named fields, and each field's type — so
(int, String)needs no declaration and any record built with that shape already is that type.// A record type is a shape — nothing declares it anywhere. (int, String) parse(String raw) => (raw.length, raw.toUpperCase()); void main() { final r = parse('ada'); print(r.$1); // 3 — positional getters start at $1 … - 02
Are
(1, 2)and(x: 1, y: 2)the same record, and how do you pull the fields out of each?EasyNo — field names are part of a record's shape, so those are two different types,
(int, int)and({int x, int y}), that are neither assignable to one another nor ever equal.final positional = (1, 2); // (int, int) final named = (x: 1, y: 2); // ({int x, int y}) void main() { print(positional == named); // false — different shapes … - 03
Why can you use a record as a
Mapkey without writing a single line of boilerplate, and when does that key silently stop working?EasyRecords implement
==andhashCodestructurally — same shape, and every field equal to its counterpart — so aMaporSettreats two independently built records with equal fields as the same key.void main() { print((1, 'a') == (1, 'a')); // true — structural print((x: 1, y: 2) == (y: 2, x: 1)); // true — named order is not shape print((1, 2) == (x: 1, y: 2)); // false — different shape // A compound cache key, for free. … - 04
A helper returns
{'code': 200, 'body': '...'}as aMap<String, dynamic>— what does a record buy you instead, and where does a class still win?EasyThe record moves the shape into the signature:
(int code, String body)is checked at compile time, so a mistyped key or a wrong type is an analyzer error instead of a runtime null.// ❌ dynamic in, casts out — every caller re-guesses the shape Map<String, dynamic> fetchOld() => {'code': 200, 'body': 'ok'}; void useOld() { final r = fetchOld(); final code = r['code'] as int; // a cast, and 'cod' would still compile … - 05
Your
switchcompiles as a statement but the same thing written with=>arms will not — what is actually different between the two forms?MediumA switch statement runs bodies and produces nothing; a switch expression evaluates to a value, which is why its arms are
pattern => expression, separated by commas, with nocasekeyword and no statements inside.sealed class Job {} class Queued extends Job {} class Running extends Job { Running(this.pct); final int pct; } class Done extends Job { Done(this.path); final String path; } // Expression: arms are `pattern => value`, separated by commas. … - 06
In
items.map((_) => _.length)the analyzer suddenly reports that_is undefined — what changed, and where is_still an ordinary variable?MediumSince Dart 3.7 a local variable or parameter literally named
_is a wildcard: the declaration binds no name at all, so there is nothing called_left to read in the body.// Dart 3.7+: a local or a parameter named `_` binds nothing. void demo(List<String> items, List<(int, String)> rows) { // ❌ used to work; now "Undefined name '_'" // final lengths = items.map((_) => _.length).toList(); // ✅ name what you use … - 07
What does
case [final first, ...rest, final last]actually check at runtime, and what does bindingrestcost you?MediumIt tests that the value is a
List, readslengthonce and demands at least two elements, then binds by index from both ends — andrestis a freshly allocated sublist, not a view onto the original.void demo() { Object value = [1, 2, 3, 4]; // Type test + `length >= 2`, then index from both ends. if (value case [final first, ...final rest, final last]) { print('$first $rest $last'); // 1 [2, 3] 4 — rest is a new list … - 08
A map pattern listing two keys matches a JSON map that has ten — is that a bug, and what happens when one of the keys you listed is absent?
MediumThat is the defined behaviour: a map pattern matches a subset, so unlisted keys are ignored entirely, and a key you did list that is missing simply makes the match fail — no exception, the case is just skipped.
final json = <String, Object?>{ 'type': 'user', 'name': 'Ada', 'age': 36, 'meta': {'seen': true}, }; … - 09
In
case Cart(total: > 100)what does the pattern actually read, and what happens when that getter is expensive or throws?MediumAn object pattern is a type test followed by getter calls:
Cart(total: > 100)checksvalue is Cart, then invokesvalue.totaland matches the result against> 100.class Cart { Cart(this.items); final List<Item> items; // A getter — and the pattern below will call it. int get total => items.fold(0, (sum, i) => sum + i.price); … - 10
Your code null-checks a field and then reaches for
!on the next line — what doesif (… case … when …)change, and where does the bound variable live?Mediumif (value case <pattern>)tests, destructures and binds in one step, and the variables it binds are in scope in thewhenguard and the then-branch only.class Session { String? token; } // ❌ two reads of a mutable field, plus `!` to shut the compiler up void oldWay(Session s) { if (s.token != null && s.token!.length > 10) { send(s.token!); … - 11
What is allowed on the left of
(a, b) = (b, a);, and what doesfor (final MapEntry(:key, :value) in map.entries)cost per step?MediumA pattern assignment writes into local variables and parameters only — no fields, no
list[0], nothingfinal— and a pattern in afor-inis a declaration, so it must be irrefutable.void demo(Map<String, int> scores, List<(String, int)> rows) { var a = 1, b = 2; (a, b) = (b, a); // swap: the right side is evaluated first print('$a $b'); // 2 1 (a, _) = (9, 0); // `_` discards the second field … - 12
Why does
case > thresholdnot compile whenthresholdis a local, and why iscase int n || double nrejected outright?MediumTwo different rules: a relational pattern's right operand must be a compile-time constant, and every branch of a logical-or pattern must bind the same variables with the same types and the same finality.
const adult = 18; const retirement = 65; String bracket(int age) => switch (age) { < 0 => 'impossible', < adult => 'minor', … - 13
A bare name in a
casecompiles only sometimes, andcase Point(1, 2)never does — what is Dart actually reading in each position?HardA bare identifier in a pattern is a constant pattern, not a binding: it is compared with
==, so it compiles when the name resolves to aconstand fails with "The expression of a constant pattern must be a valid constant" when it does not.const maxRetries = 3; final int configuredLimit = readConfig(); // not const int backoffMs(int attempt) => switch (attempt) { 0 => 0, maxRetries => -1, // constant pattern: attempt == maxRetries … - 14
You add a fourth subtype to a
sealedclass and forty switches across three packages go red — is that the feature working, and how do you ship it?HardThat is precisely the feature working — and it also means adding a subtype to a public sealed type is a breaking change that costs a major version.
// lib/src/payment.dart — every subtype lives in this one library sealed class Payment {} final class Card extends Payment { Card(this.last4); final String last4; } final class Cash extends Payment {} // v2 adds: final class Wallet extends Payment { Wallet(this.provider); ... } … - 15
A three-case union in your app is two hundred lines of freezed output — what would you write today, and where does the generator still earn its keep?
HardThe language now covers the union half for nothing —
sealedplus an exhaustiveswitchgives you the closed set, the compile-time check and destructuring — so a generator is left doing the data half:copyWith,==/hashCode,toStringand JSON.// The union half — no build_runner, no generated file. sealed class Loaded<T> {} final class Idle<T> extends Loaded<T> { const Idle(); } final class Loading<T> extends Loaded<T> { const Loading(); } final class Data<T> extends Loaded<T> { … - 16
A reviewer claims that destructuring a record inside
buildallocates on every frame — is that true, and how would you settle it without guessing?HardDestructuring does not allocate —
final (min, max) = extent;is two getter reads,$1and$2— it is creating a record that allocates, and even that often disappears in optimized code.class _ChartState extends State<Chart> { var _extent = (0.0, 1.0); @override Widget build(BuildContext context) { // Two getter reads. Nothing is allocated by destructuring. …