Records, patterns and switch expressions

Return multiple values with records, destructure them with patterns, and use sealed classes for exhaustive switches.

Records and destructuring

// a positional record: a lightweight tuple with a real type
(int, String) pair = (1, 'one');
print(pair.$1);                          // 1
print(pair.$2);                          // 'one'

// named fields, which are self-documenting
({int status, String body}) response = (status: 200, body: 'ok');
print(response.status);

// records have structural equality and a useful toString
print((1, 'a') == (1, 'a'));              // true
print((1, 2).toString());                 // (1, 2)

// a record as a return type: no more out-parameters or a bespoke class
({double min, double max}) range(List<double> xs) {
  var lo = xs.first, hi = xs.first;
  for (final x in xs) {
    if (x < lo) lo = x;
    if (x > hi) hi = x;
  }
  return (min: lo, max: hi);
}

// destructuring with patterns
void main() {
  final r = range([3.0, 1.0, 4.0, 1.5]);
  final (lo, hi) = (r.min, r.max);        // positional destructuring
  final (:min, :max) = r;                 // shorthand for named fields
  print('$lo $hi $min $max');

  final (a, b) = (1, 2);
  print(a + b);

  // swapping without a temporary
  var x = 1, y = 2;
  (x, y) = (y, x);
  print('$x $y');

  // list and map patterns
  final [first, second, ...rest] = [1, 2, 3, 4];
  print('$first $second $rest');

  final {'name': name, 'age': age} = {'name': 'ada', 'age': 36};
  print('$name $age');
}
  • A record is immutable and has no methods. Use it for a short-lived grouping of values, not as a replacement for a domain class with behaviour.
  • Records are structural: (1, 'a') and (1, 'a') are equal. Two instances of a plain class with the same fields are not, unless you override ==.
  • The positional accessors are $1, $2 and so on. In a string template you must escape the dollar: '${pair.$1}'.
  • Do not use a record to return five values. Once a grouping has a name and a meaning, promote it to a class or a typedef of a record.

Switch expressions and pattern matching

sealed class Shape {}

class Circle extends Shape {
  Circle(this.radius);
  final double radius;
}

class Rectangle extends Shape {
  Rectangle(this.width, this.height);
  final double width;
  final double height;
}

class Triangle extends Shape {
  Triangle(this.base, this.height);
  final double base;
  final double height;
}

double area(Shape s) => switch (s) {
      Circle(:final radius) => 3.14159 * radius * radius,
      Rectangle(:final width, :final height) => width * height,
      Triangle(base: final b, height: final h) => 0.5 * b * h,
      // no default needed: the compiler knows Shape is sealed
    };

String describe(Object? value) => switch (value) {
      null => 'nothing',
      int n when n < 0 => 'negative $n',
      int n => 'int $n',
      double d => 'double $d',
      String s when s.isEmpty => 'empty string',
      String s => 'string "$s"',
      List(length: 0) => 'empty list',
      List(length: final n) => 'list of $n',
      (int a, int b) => 'pair (' + a.toString() + ', ' + b.toString() + ')',
      _ => 'unknown',
    };

void main() {
  print(area(Circle(2)));
  for (final v in [null, -1, 3, 'x', <int>[], (1, 2)]) {
    print(describe(v));
  }
}
PatternMatchesBinds
int nAny intn to the value
Circle(:final radius)A Circleradius from the getter
(int a, int b)A two-element recorda and b
[first, ...rest]A list with at least one elementfirst, rest
{'k': v}A map containing key kv
final x?A non-null valuex after the null check
_AnythingNothing: the wildcard
case int n when n > 0A guardn, only when the guard holds

A sealed class cannot be extended outside its own library, which is what lets the compiler prove a switch is exhaustive. If you add a subclass and forget a case, the build fails instead of silently falling through.

if-case and destructuring in statements

void main() {
  final json = <String, Object?>{'name': 'ada', 'age': 36};

  // if-case: match a pattern and bind in one step
  if (json case {'name': final String name, 'age': final int age}) {
    print('$name is $age');
  }

  // destructuring a nullable value in a condition
  String? maybe = 'text';
  if (maybe case final String s when s.isNotEmpty) {
    print('non-empty: $s');
  }

  // the classic null-check pattern replaces a cascade of null tests
  final config = <String, String>{'host': 'db'};
  if (config case {'host': final h, 'port': final p}) {
    print('$h:$p');
  } else {
    print('host and port are both required');
  }

  // a switch statement with patterns and multiple statements per case
  final value = Object();
  switch (value) {
    case int n when n > 100:
      print('big int');
    case int n:
      print('int $n');
    case final String s:
      print('string $s');
    default:
      print('other');
  }

  // patterns work in a for-in loop body too
  for (final (key, value) in {'a': 1, 'b': 2}.entries.map((e) => (e.key, e.value))) {
    print('$key=$value');
  }
}
💡
Prefer a switch expression over a chain of if statements when you are mapping one input to one output. It returns a value, so it can be assigned directly, and the compiler checks that every branch returns.

FAQ

When should I use a record instead of a class?
For a transient grouping inside one file or one function: a computed pair, a parsed token, the result of a lookup. Promote it to a class once it needs behaviour, validation or a name that appears in an API.
Why is my switch not exhaustive?
The type is not sealed, or the switch is over a nullable type and you have not matched null. Make the base class sealed, or add an explicit null and a wildcard case.

Classes, futures and streams Generics, extensions and operator overloading

Last refreshed 2026-09-18.