Dart 3 Patterns & Records
Irbisa · cheatsheetSeptember 13, 2026

Dart 3 Patterns & Records

Records, destructuring, switch expressions, if-case guards, sealed types, exhaustiveness

Middle Developer16 itemscompressed for a skim
  1. 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 .$1 come from?

    Easy

    A 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
    …
  2. 02

    Are (1, 2) and (x: 1, y: 2) the same record, and how do you pull the fields out of each?

    Easy

    No — 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
    …
  3. 03

    Why can you use a record as a Map key without writing a single line of boilerplate, and when does that key silently stop working?

    Easy

    Records implement == and hashCode structurally — same shape, and every field equal to its counterpart — so a Map or Set treats 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.
    …
  4. 04

    A helper returns {'code': 200, 'body': '...'} as a Map<String, dynamic> — what does a record buy you instead, and where does a class still win?

    Easy

    The 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
    …
  5. 05

    Your switch compiles as a statement but the same thing written with => arms will not — what is actually different between the two forms?

    Medium

    A 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 no case keyword 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.
    …
  6. 06

    In items.map((_) => _.length) the analyzer suddenly reports that _ is undefined — what changed, and where is _ still an ordinary variable?

    Medium

    Since 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
    …
  7. 07

    What does case [final first, ...rest, final last] actually check at runtime, and what does binding rest cost you?

    Medium

    It tests that the value is a List, reads length once and demands at least two elements, then binds by index from both ends — and rest is 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
    …
  8. 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?

    Medium

    That 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},
    };
    …
  9. 09

    In case Cart(total: > 100) what does the pattern actually read, and what happens when that getter is expensive or throws?

    Medium

    An object pattern is a type test followed by getter calls: Cart(total: > 100) checks value is Cart, then invokes value.total and 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. 10

    Your code null-checks a field and then reaches for ! on the next line — what does if (… case … when …) change, and where does the bound variable live?

    Medium

    if (value case <pattern>) tests, destructures and binds in one step, and the variables it binds are in scope in the when guard 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. 11

    What is allowed on the left of (a, b) = (b, a);, and what does for (final MapEntry(:key, :value) in map.entries) cost per step?

    Medium

    A pattern assignment writes into local variables and parameters only — no fields, no list[0], nothing final — and a pattern in a for-in is 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. 12

    Why does case > threshold not compile when threshold is a local, and why is case int n || double n rejected outright?

    Medium

    Two 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. 13

    A bare name in a case compiles only sometimes, and case Point(1, 2) never does — what is Dart actually reading in each position?

    Hard

    A bare identifier in a pattern is a constant pattern, not a binding: it is compared with ==, so it compiles when the name resolves to a const and 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. 14

    You add a fourth subtype to a sealed class and forty switches across three packages go red — is that the feature working, and how do you ship it?

    Hard

    That 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. 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?

    Hard

    The language now covers the union half for nothing — sealed plus an exhaustive switch gives you the closed set, the compile-time check and destructuring — so a generator is left doing the data half: copyWith, ==/hashCode, toString and 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. 16

    A reviewer claims that destructuring a record inside build allocates on every frame — is that true, and how would you settle it without guessing?

    Hard

    Destructuring does not allocate — final (min, max) = extent; is two getter reads, $1 and $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.
    …