Records and Pattern Matching

Article · 14:00

Dart 3 added two features that work together: records, which are anonymous product types, and patterns, which take them apart. Between them they remove most of the ceremony that used to surround returning two things, and make a switch over sealed types exhaustive.

Records and Pattern Matching

Dart 3 added two features that work together: records, which are anonymous product types, and patterns, which take them apart. Between them they remove a large amount of the ceremony that used to surround "return two things" and "branch on the shape of this value".

Records

A record is a fixed-size, immutable bundle of values with its own type. No class declaration, and equality comes free.

(int, int) minMax(List<int> values) {
  var lo = values.first, hi = values.first;
  for (final v in values) {
    if (v < lo) lo = v;
    if (v > hi) hi = v;
  }
  return (lo, hi);
}

// Named fields make the meaning obvious at the call site.
({String name, int age}) parse(String line) {
  final parts = line.split(',');
  return (name: parts[0], age: int.parse(parts[1]));
}

void main() {
  final range = minMax([5, 2, 9, 1]);
  print(range);
  print('${range.$1} to ${range.$2}');     // positional fields are $1, $2, ...

  final person = parse('Ada,36');
  print('${person.name} is ${person.age}');

  // Structural equality: two records with equal fields are equal.
  print((1, 'a') == (1, 'a'));
  print(minMax([5, 2, 9, 1]) == (1, 9));
}

Use a record when the grouping is local and obvious. Use a class when the thing has a name, a meaning, or any behaviour of its own.

Destructuring

A pattern on the left of = pulls a value apart.

void main() {
  final (lo, hi) = (1, 9);
  print('$lo..$hi');

  final (name: who, age: years) = (name: 'Ada', age: 36);
  print('$who $years');

  // Lists and maps destructure too.
  final [first, second, ...rest] = [1, 2, 3, 4, 5];
  print('$first $second $rest');

  final {'host': host, 'port': port} = {'host': 'localhost', 'port': 8080};
  print('$host:$port');

  // Swapping without a temporary.
  var a = 'left', b = 'right';
  (a, b) = (b, a);
  print('$a $b');

  // In a for loop, which is what makes map iteration read well.
  final scores = {'ada': 10, 'grace': 12};
  for (final MapEntry(:key, :value) in scores.entries) {
    print('$key scored $value');
  }
}

switch on shape, with guards

A pattern in a switch matches structure, binds the parts, and can add a condition with when.

String describe(Object value) => switch (value) {
      0 => 'zero',
      int n when n < 0 => 'negative $n',
      int n => 'positive $n',
      String s when s.isEmpty => 'an empty string',
      String s => 'a string of ${s.length}',
      [int a, int b] => 'a pair adding to ${a + b}',
      [_, ...] => 'some other list',
      {'type': final String type} => 'a map of type $type',
      _ => 'something else',
    };

void main() {
  for (final value in <Object>[
    0, -3, 7, '', 'hello', [2, 3], [1, 2, 3], {'type': 'user'}, 3.5,
  ]) {
    print('$value -> ${describe(value)}');
  }
}

if-case, for the single branch that matters

void main() {
  final responses = <Object>[
    {'status': 200, 'body': 'ok'},
    {'status': 404},
    'not a map at all',
  ];

  for (final response in responses) {
    if (response case {'status': final int status, 'body': final String body}) {
      print('$status with body "$body"');
    } else if (response case {'status': final int status}) {
      print('$status with no body');
    } else {
      print('unrecognised: $response');
    }
  }
}

Sealed classes plus patterns: states you cannot forget

This is where the two features earn their place. A sealed class lists the possible shapes; an exhaustive switch forces every one to be handled; adding a new shape breaks compilation everywhere it matters, which is exactly when you want to hear about it.

sealed class Result<T> {
  const Result();
}

class Success<T> extends Result<T> {
  final T value;
  const Success(this.value);
}

class Failure<T> extends Result<T> {
  final String message;
  const Failure(this.message);
}

class Pending<T> extends Result<T> {
  const Pending();
}

String render(Result<int> result) => switch (result) {
      Success(:final value) => 'value $value',
      Failure(:final message) => 'failed: $message',
      Pending() => 'still working',
    };

void main() {
  const results = <Result<int>>[
    Success(42),
    Failure('timeout'),
    Pending(),
  ];
  results.map(render).forEach(print);
}

Delete the Pending() branch and the code stops compiling, with the analyzer naming the type you missed. That is the trade: a little more structure up front, in exchange for the compiler carrying the list of states for you.

What to take away

  • A record groups values without declaring a class, and compares by value.
  • $1, $2 for positional fields; named fields read better at a call site.
  • Destructuring works in assignments, for loops, switch and if case.
  • when adds a condition to a pattern; _ matches anything.
  • sealed plus switch gives exhaustiveness, so a new state is a compile error.
  • Records for local groupings, classes for things with a name and behaviour.