Pattern matching in Dart-Flutter: A Developer’s Playground

Happy Makadiya
8 min readFeb 7, 2024

--

Banner Credit: Kaival Patel 😎

Pattern matching and destructuring:

  • Patterns are a new and powerful feature of Dart 3.0 that allows you to express how data should be shaped and extract specific parts from it in a readable way.
  • They offer two main functionalities: A pattern may match a value or destructure a value.

1. Matching value:

  • A pattern defines the characteristics of a value it can match against.
  • It determines whether a value fits a specific type: Value matching with patterns.
  • This feature streamlines conditional logic and replaces lengthy if-else if statements with patterns.
// if, else if
if (user is Admin) {
return AdminDashboard();
} else if (user is VerifiedUser) {
return UserHome();
} else {
return WelcomeScreen();
}

// Type Matching
switch (user) {
case Admin admin:
return AdminDashboard(admin);
case VerifiedUser verifiedUser:
return UserHome(verifiedUser);
default:
return WelcomeScreen();
}

2. Destructuring

Remember the days of manually extracting properties from complex data structures, line by line? Those days are fading fast with the power of destructuring

  • When a pattern matches a value, it can simultaneously extract and bind specific parts of that value to variables.
  • This eliminates the need for manual checks and extraction, making your code cleaner and less prone to errors.
final user = {'id': 1, 'name': 'Happy', 'email': 'xyz@gmail.com'};

// Before:
final userId = user['id'];
final userName = user['name'];
final userEmail = user['email'];

// Now: Using Patterns
// Here uID, uName and uEmail are new variable declarations
// which holds respective values of [user] data
// For more, see Record and Object pattern type below
final {'id':uId, 'name':uName, 'email':uEmail} = user;
print("$uId - $uName - $uEmail");

That’s all! In a single line, you’ve extracted each property and assigned it to its corresponding variable. It’s concise, readable, and efficient.

Here is another example:

// This case pattern matches and destructures a two-element list
// whose first element is 'a' or 'b':
var list = ['a', 'x'];

switch (list) {
// 1. Check if list is type of List
// 2. Check if the 1st element is 'a' or 'b'
// 3. And store 2nd element to c of var type.
case ['a' || 'b', var c]:
print(c); // Print x
}

Pattern types:

  • Just like mathematical operators, patterns also have a hierarchy of precedence—a set of rules that determine which patterns are evaluated first.
  • Precedence Matters: The order in which patterns are evaluated can significantly impact the behavior of your code.
  • Parenthesized Power: Use parentheses to explicitly control precedence, similar to how you use them with operators.
  • Below is a list of the pattern types in ascending order of precedence:

Logical-or: subpattern1 || subpattern2

switch (color) {
case Color.red || Color.yellow || Color.blue:
}

Logical-and: subpattern1 && subpattern2

switch (shape) {
case Shape.circle && != null:
}

Relational: ==, !=, <, >, <=, and>=.

String asciiCharType(int char) {
const space = 32;
const zero = 48;
const nine = 57;

//Switch expression
return switch (char) {
< space => 'control',
== space => 'space',
> space && < zero => 'punctuation',
>= zero && <= nine => 'digit',
_ => ''
// _ denotes Default case
};
}

Cast: foo as String

(num, Object) record = (1, 's'); // Record Type
var (i as int, s as String) = record; // Pattern Destructuring

Null — Check: subpattern?

String? maybeString = 'Hello, Good Morning';
switch (maybeString) {
// Matches if maybeString != null.
// Skip the case if its null
case var s?:
print(s);
}

Null-assert: subpattern!

List<String?> row = ['user', null];
switch (row) {
case ['user', var name!]: // Throws if the matched value is null.
}

Constant: 123, null, 'string', math.pi, SomeClass.constant, const Thing(1, 2), const (1 + 2)

switch (number) {
case 1: // Matches if 1 == number.
}

Variable: var bar, String str, final int _

switch ((1, 2)) {
// Match if switch type is record and then match individual variable type
// a and b are variable patterns that bind to 1 & 2 respectively.
case (var a, var b):
print("Sum is: ${a+b}");
// 'a' and 'b' are in scope in the case body.
}

switch ((1, 2)) {
// Does not match internal type.
case (int a, String b): print(a);

// Do match.
case (num a, int b): print(a);
}

Identifier: foo, _

//1. Declaration:
var (a, b) = (1, 2);

//3. Assignment:
(a, b) = (3, 4);

//3. Matching
const c = 1;
switch (2) {
case c:
print('match $c');
default:
print('no match'); // Prints "no match".
}

//4. Wildcard
case [_, var y, _]: print('The middle element is $y');

Parenthesized: (subpattern)

var x = true;
var y = true;
var z = false;

x || y && z => 'matches true', //logical-and patterns have higher precedence than logical-or
(x || y) && z => 'matches false',

List: [subpattern1, subpattern2]

var obj = ['a', 'b'];
const a = 'a';
const b = 'b';
switch (obj) {
// Matches obj first if obj is a List with 2 fields
// then if its fields match the constant subpatterns 'a' and 'b'.
case [a, b]:
print('$a, $b');
}

Rest element: List patterns can contain one rest element (…) which allows matching lists of arbitrary lengths.

var [a, b, ..., c, d] = [1, 2, 3, 4, 5, 6, 7];
print('$a $b $c $d'); // Prints "1 2 6 7"

var [a, b, ...rest, c, d] = [1, 2, 3, 4, 5, 6, 7];
print('$a $b $rest $c $d'); // Prints "1 2 [3, 4, 5] 6 7".

var [a, d] = [1];
print('$a $d'); // Throw run time error as array length does

var [a, ...restEle] = [1];
print('$a $restEle'); // Prints "1 []".

Map: {"key": subpattern1, someConst: subpattern2}


var mapVar = {'a': 10, 'b': 20};
switch(mapVar){
// Does not match as c key is not matched
case {'a': var a, 'c': var c}: print("a is $a");

// Do match
case {'b': var b}: print("b is $b");
}

Record: (subpattern1, subpattern2), (x: subpattern1, y: subpattern2)

var (latitude: lat, longitude: bar) = (latitude: 10.789, longitude: -8.856);
print("$lat $long"); // 10.789 -8.856
var (untyped: untypedVar, typed: int typedVar) = record;
var (latitude: lat, longitude: double long) = (latitude: 10.789, longitude: -8.856);

//---------------------------------------------

var (:untypedVar, :int typedVar) = record;
var (:lat, :double long) = (lat: 10.789, long: -8.856);

//---------------------------------------------

switch (record) {
case (untyped: var untypedVar, typed: int typedVar):
case (:var untypedVar, :int typedVar):
}

// Record pattern with null-check and null-assert subpatterns:
switch (record) {
case (checked: var checked?, asserted: var asserted!):
case (:var checked?, :var asserted!):
}

// Record pattern with cast subpattern:
var (untyped: untypedVar as int, typed: typedVar as String) = record;
var (:untypedVar as int, :typedVar as String) = record;

Object: SomeClass(x: subpattern1, y: subpattern2)

switch (shape) {
// Matches if shape is of type Rect, and then against the properties of Rect.
case Rect(width: var w, height: var h):
}

// Binds new variables x and y to the values of Point's x and y properties.
var Point(:x, :y) = Point(1, 2);
class Candidate{
Candidate({
required this.name,
required this.yearsExperience,
});

final String name;
final int yearsExperience;
}

//Different ways for Pattern matches and destructure in Object type
var candidate = Candidate(name: "Happy", yearsExperience: 2);

var Candidate(name: cName, yearsExperience: int uYears) = candidate;
var Candidate(:name, :int yearsExperience) = candidate;

switch (candidate) {
case Candidate(name: var uName, yearsExperience: int uYears):
case Candidate(:String name, :int yearsExperience):
}

switch (candidate) {
case Candidate(name: var uName?, yearsExperience: var uYears!):
case Candidate(:var name?, :var yearsExperience!):
}

var Candidate(name: uName as String, yearsExperience: uYears as int) = candidate;
var Candidate(:name as String, :yearsExperience as int) = candidate;

Object patterns don’t require the pattern to match the entire object. If an object has extra fields that the pattern doesn’t destructure, it can still match.

  var Candidate(:name) = Candidate("Happy", 10);
print(name);

Wildcard: _

var list = [1, 2, 3];
var [_, two, _] = list;

switch (record) {
case (int _, String _):
print('First field is int and second is String.');
}

Patterns in several places in the Dart language

1. Local variable declarations:

  • The pattern matches the value on the right of the declaration. Once matched, it destructures the value and binds it to new local variables.

Note: A pattern variable declaration must start with either var orfinal, followed by a pattern.

// Declares new variables a, b, and c.
var (a, [b, c]) = ('str', [1, 2]);

2. Variable assignment:

  • A variable assignment pattern falls on the left side of an assignment. First, it destructures the matched object. Then it assigns the values to existing variables instead of binding new ones.
//swap the values of two variables without declaring temporary one:

var (a, b) = ('10', '20');
(b, a) = (a, b); // Swap.
print('$a $b'); // Prints "20 10".

3. for and for-in loops

// Example 1: List
List<Candidate> candidates = [
Candidate('Alice', 3),
Candidate('Bob', 5),
Candidate('Charlie', 2)
];

//Method 1: Before
for (Candidate candidate in candidates) {
print('${candidate.name} has ${candidate.yearsExperience} of experience.');
}

//Method 2: Now: Using Pattern
for (final Candidate(:name, :yearsExperience) in candidates) {
print('$name has $yearsExperience of experience.');
}


// Example 2: Map
Map<String, int> hist = {
'a': 23,
'b': 100,
};

for (var MapEntry(key: key, value: count) in hist.entries) {
print('$key occurred $count times');
}

for (var MapEntry(:key, value: count) in hist.entries) {
print('$key occurred $count times');
}

3. if-case

  • Dartif statements supportcase clauses followed by a pattern:
final pair = [10, 20];

if (pair case [int x, int y]){
return Point(x, y); // Point(10,20)
}

4. Switch case:

//Switch statement:
switch (charCode) {
case slash || star || plus || minus: // Logical-or pattern
token = operator(charCode);
case comma || semicolon: // Logical-or pattern
token = punctuation(charCode);
case >= digit0 && <= digit9: // Relational and logical-and patterns
token = number();
default:
throw FormatException('Invalid');
}

//Switch Expression:
token = switch (charCode) {
slash || star || plus || minus => operator(charCode),
comma || semicolon => punctuation(charCode),
>= digit0 && <= digit9 => number(),
_ => throw FormatException('Invalid')
};

5. Guard clause:

  • To set an optional guard clause after a case clause, use the keyword when.
  • Guards evaluate an arbitrary boolean expression after matching. This allows you to add further constraints on whether a case body should execute.
// Switch statement:
switch (pair) {
case (int a, int b) when a > b:
// ^^^^^^^^^ Guard clause.
body;
}

// Switch expression:
var value = switch (pair) {
(int a, int b) when a > b => body,
// ^^^^^^^^^^^ Guard clause.
}

// If-case statement:
if (something case (int a, int b) when a > b) {
// ^^^^^^^^^^ Guard clause.
body;
}

6. Control flow in collection literals

var nav = ['Home', 'Furniture', 'Plants', if (login case 'Manager') 'Inventory'];

7. Validating incoming JSON

  • Map and list patterns work well for destructuring key-value pairs in JSON data:
var json = {'user': ['Lily', 13]};
var {'user': [name, age]} = json;
print("$name is $age years old"); // Lily is 13 years old
final json = {'user': ['Happy', 23];

//Before: Without patterns, validation is verbose:
if (json is Map<String, Object?> &&
json.length == 1 &&
json.containsKey('user')) {
var user = json['user'];
if (user is List<Object> &&
user.length == 2 &&
user[0] is String &&
user[1] is int) {
var name = user[0] as String;
var age = user[1] as int;
print('User $name is $age years old.');
}
}


//Now: With Pattern: less verbose method of validating
if (json case {'user': [String name, int age]}) {
print('User $name is $age years old.');
}

Wrap

Pattern matching is like a secret decoder for code, making it easier to understand and work with. It’s like having a special tool that lets you break down complex data into simpler pieces, write more organized if statements, and catch errors before they happen.

So, whether you’re a coding pro or just starting out, don’t forget to add pattern matching to your toolbox! It’s like a magic wand for writing cleaner, more reliable code, and it’ll help you create awesome things in Dart 3.0 and beyond.

Thank you…

References:

Other Account: Happy Makadiya

Banner Credit: Kaival Patel

--

--

Happy Makadiya
Happy Makadiya

Responses (1)