Dart/Flutter - Patterns Matching & Destructuring Examples

This tutorial explains how to use patterns in Dart for matching and destructuring objects.

One of the new features in Dart 3 is patterns matching and destructuring. It enables you to check that an object has certain characteristics and extract the values with a more compact code. Therefore, if you understand Dart's patterns, you can code quicker while also improving the readability of your code. Below are the explanations and examples of what you can do with the new feature.

Pattern Matching

Pattern matching is used to test whether a value matches certain characteristics. It can test whether a value equals a constant, has a certain shape and/or type, or matches the specified criteria. It simplifies the process of checking that an object must have a certain shape or type. With recursive support, it can perform matching on the properties of the object or the elements of a collection. For example, we want to check whether an object is a List with two elements. In the traditional way, you have to manually check that the object is a List and the number of elements is 2.

  var isListWithTwoElements = obj is List && obj.length == 2;

Below is the pattern matching approach which is much simpler. The conditional checking above can be replaced with [a, b] pattern.

  void checkObject(Object obj) {
    switch (obj) {
      case [a, b]:
        print('2-element List');
  }

Pattern Destructuring

Pattern destructuring is a feature that allows us to extract data from an object in a more convenient way. If an object matches a pattern, you can destructure the properties or elements of the object to variables.

  var values = [1, 2, 3];

For assigning the value of each element to variables, a common solution is by getting the element by index one by one.

  var a = values[0];
  var b = values[1];
  var c = values[2];

With pattern destructuring, it's possible to assign all elements to variables using one statement.

  var [a, b, c] = values;

Usage Examples

Switch Statements & Expressions

A common usage for pattern is in the switch statements and expressions. The most basic usage is matching against constant values. The values can be defined directly in the case statement or by passing a const variable.

  void checkObject(Object obj) {
    const myValue = 'A';
  
    switch (obj) {
      case 1:
        print('Integer 1');
      case myValue:
        print('String A');
    }
  }

It's also possible to match that a value has a certain shape and destructures the values. In the example below, the first case checks whether an object is a List with two elements of any type. If matches, the elements will be destructured to variables a and b. In the other example, it matches a record with two positional fields and destructures the values of the fields.

  void checkObject(Object obj) {
    const myValue = 'A';
  
    switch (obj) {
      case [var a, var b]:
        print('2-element List');
      case (var a, var b):
        print('Record with 2 positional fields');
    }
  }

The next example is a bit similar to the previous one, but it also checks the type of the destructured values. The first case matches Lists with two elements whose type is integer. The other case matches records whose both of the positional fields are double.

  void checkObject(Object obj) {
    const myValue = 'A';
  
    switch (obj) {
      case [int a, int b]:
        print('List with 2 int elements: $a & $b');
      case (double a, double b):
        print('Record with 2 double positional fields');
    }
  }

Switch also supports matching using custom criterias. For example, you can use logical-or operator in a case statement. So, if you want to share a common code for a list of values, you don't need to repeat the case multiple times. It's also possible to destructure the properties of an object and check whether the values match certain criterias.

  void checkObject(Object obj) {
    const myValue = 'A';
  
    switch (obj) {
      case Color.red || Color.green || Color.blue:
        print('Basic color');
      case Item(price: var itemPrice) when itemPrice > 500:
        print('expensive item, price: $itemPrice');
    }
  }

Variable Declaration

You can use patterns for declaring variables. On the left hand side of a variable declaration, define a pattern containing the variables to be assigned. The pattern must match the value on the right hand side. If not, the code won't be compiled. By doing so, Dart will destructure the values and assign them to the corresponding variables.

For example, there is a List with two elements. To get the element values assigned to new variables in one statement, create a pattern that matches the object type. In this case [a, b] with a and b are the variables to be assigned. It's preceded by a var or final keyword which indicates whether the variables can be reassigned (if using var) or not (if using final).

  var [a, b] = [1, 2];
  print('a: $a; b: $b');

Output:

 a: 1; b: 2

It can also be applied on a Map to match against a key of the Map.

  var response = {
    'data': ['Ivan', 100]
  };
  var {'data': [name, score]} = response;
  print('name: $name, score: $score');

Output:

 name: Ivan, score: 100

If the key in the pattern is not found. it may throw Bad state: Pattern matching error when the code is executed.

The record type introduced in Dart 3 also supports patterns. You can assign the values of the fields to variables by defining a matching pattern.

  var (x, y) = (2, 3);
  print('x: $x; y: $y');

Output:

  x: 2; y: 3

Another example, we have a class named Item

  class Item {
    String name;
    double price;
  
    Item({
      required this.name,
      required this.price,
    });
  }

If there is an instance of Item, we can directly destructure the properties. To destructure an instance of a class, the pattern is the class name followed by a parentheses containing the list of properties separated by comma. The format for each property is propertyName: variableName.

  final item = Item(name: 'A', price: 100);
  var Item(:name, :price) = item;
  print('name $name, price $price')

Output:

  name A, price 100.0

In usage, it can be very common to have the same name for the property and the variable name. In the example above, the name: name part is redundant. Fortunately, Dart is able to infer the property name from the variable name. It can be replaced with :name only.

  var Item(:name, price: itemPrice) = item;

Variable Assignment

Patterns can also assign to existing variables. It works by destructuring the object on the right hand side of an assignment. Then, the values are assigned to existing variables. The difference with the variable declaration is you don't need to add var or final keyword since the variables are already declared before. Below is an example using a List. You can also do the similar thing for other object types such as Map, record, etc.

  var val1, val2;
  [val1, val2] = ['one', 'two'];
  print('$val1 $val2');

Output:

  one two

For Loop

You can also use pattern to destructure the iterated elements in a for loop. Below is a for-in loop. In the past, it's only possible to define a variable name in the left hand side of the in which represents the iterated element. Now, it's possible to destructure the element by defining a pattern. In the example below, we destructure the MapEntry element to get the key and value properties.

  Map<String, Object> options = {
    'color': 'teal',
    'size': 100
  };

  for (var MapEntry(: key, value: val) in options.entries) {
    print('Option: $key, value: $val');
  }

Output:

  Option: color, value: teal
  Option: size, value: 100

If Case

Let's reuse the response object above.

  var response = {
    'data': ['Ivan', 100]
  };

We have a case to check that the Map object has a key named data whose properties is a List with two elements. In addition, the first element of the List must be a string, while the second element must be an integer. Without patterns, the solution is by writing a code like below.

  if (response.containsKey('data')) {
    var data = response['data'];
    if (data is List<Object> &&
        data.length == 2 &&
        data[0] is String &&
        data[1] is int
    ) {
      var name = data[0] as String;
      var score = data[1] as int;
      print('Player $name has a score of $score.');
    }
  }

By using pattern in an if-case statement, the code can be simplified.

  if (response case {'data': [String name, int score]}) {
    print('Player $name has a score of $score.');
  }

Summary

Dart's pattern matching and destructuring are really helpful for making the code more compact and readable. They can be used in switch statements & expressions, variable declaration & assignment, for loop, and if case. Various data types such as List, Map, Record, or any custom object are compatible to use patterns. Therefore, you should utilize patterns to make coding in Dart more enjoyable.

This feature requires Dart v3.0 or above. For Flutter, you have to upgrade the Flutter SDK version to v3.10.0 or above.

You can also read about: