Dart Advanced Features

Pattern Matching in Depth (Dart 3)

55 min Lesson 7 of 16

What Is Pattern Matching?

Pattern matching, introduced in Dart 3.0, is a powerful mechanism for checking whether a value has a certain shape and extracting data from it in one step. Instead of writing chains of if/else if statements with type checks and casts, you describe the shape you expect and Dart does the matching, checking, and destructuring for you.

Pattern matching works in three contexts: switch statements and expressions, if-case statements, and variable declarations. Combined with records (from the previous lesson), pattern matching transforms how you write conditional logic in Dart.

Pattern Matching vs Traditional Approach

// Traditional approach: manual type checks and casts
void describeOld(Object value) {
  if (value is String) {
    print('String of length ${value.length}');
  } else if (value is int && value > 0) {
    print('Positive integer: $value');
  } else if (value is List<int> && value.length == 2) {
    print('Pair: (${value[0]}, ${value[1]})');
  } else {
    print('Something else: $value');
  }
}

// Pattern matching approach: declarative and concise
void describeNew(Object value) {
  switch (value) {
    case String s:
      print('String of length ${s.length}');
    case int n when n > 0:
      print('Positive integer: $n');
    case [int a, int b]:
      print('Pair: ($a, $b)');
    default:
      print('Something else: $value');
  }
}

Constant Patterns

The simplest pattern type matches a specific constant value. This is what traditional switch statements already did, but now it integrates with the full pattern system.

Constant Patterns

void handleStatusCode(int code) {
  switch (code) {
    case 200:
      print('OK');
    case 301:
      print('Moved Permanently');
    case 404:
      print('Not Found');
    case 500:
      print('Internal Server Error');
    default:
      print('Unknown status: $code');
  }
}

// Constants also work with strings, booleans, enums, etc.
void handleCommand(String cmd) {
  switch (cmd) {
    case 'start':
      print('Starting...');
    case 'stop':
      print('Stopping...');
    case 'restart':
      print('Restarting...');
    case '':
      print('Empty command');
    default:
      print('Unknown command: $cmd');
  }
}

Variable Patterns

A variable pattern matches any value and binds it to a new variable. You can use type annotations to add a type check.

Variable and Typed Variable Patterns

void process(Object value) {
  switch (value) {
    // Typed variable pattern: matches if value is String, binds to s
    case String s:
      print('Got string: "$s" (length: ${s.length})');

    // Typed variable pattern: matches if value is int, binds to n
    case int n:
      print('Got integer: $n (is even: ${n.isEven})');

    // Typed variable pattern with double
    case double d:
      print('Got double: ${d.toStringAsFixed(2)}');

    // Typed variable pattern with bool
    case bool b:
      print('Got boolean: $b');

    // Untyped variable pattern: matches anything (catch-all)
    case var other:
      print('Got something else: $other (${other.runtimeType})');
  }
}

void main() {
  process('Hello');  // Got string: "Hello" (length: 5)
  process(42);        // Got integer: 42 (is even: true)
  process(3.14);      // Got double: 3.14
  process(true);      // Got boolean: true
  process([1, 2]);    // Got something else: [1, 2] (List<int>)
}

Wildcard Patterns

The wildcard pattern _ matches any value but does not bind it to a variable. Use it when you need to match a position but do not care about the value.

Wildcard Pattern Usage

void main() {
  final list = [1, 2, 3];

  // Match a 3-element list but only care about the middle element
  switch (list) {
    case [_, int middle, _]:
      print('Middle element: $middle'); // Middle element: 2
  }

  // In record destructuring
  final record = ('ignored', 42, true);
  final (_, value, _) = record;
  print(value); // 42

  // Typed wildcard: match type without binding
  final Object obj = 'Hello';
  switch (obj) {
    case int _:
      print('It is an int (but I do not need the value)');
    case String _:
      print('It is a String (but I do not need the value)');
      // Prints: It is a String (but I do not need the value)
  }
}

List Patterns

List patterns match lists by their length and element patterns. They can destructure elements by position and use rest patterns (...) to match variable-length lists.

List Patterns

void describeList(List<int> list) {
  switch (list) {
    case []:
      print('Empty list');
    case [int single]:
      print('Single element: $single');
    case [int first, int second]:
      print('Two elements: $first, $second');
    case [int first, ...]:
      print('Starts with $first, has ${list.length} elements total');
  }
}

void main() {
  describeList([]);        // Empty list
  describeList([42]);      // Single element: 42
  describeList([1, 2]);    // Two elements: 1, 2
  describeList([1, 2, 3]); // Starts with 1, has 3 elements total

  // Rest pattern can capture remaining elements
  final numbers = [1, 2, 3, 4, 5];
  switch (numbers) {
    case [int first, int second, ...var rest]:
      print('First: $first, Second: $second');
      print('Rest: $rest'); // Rest: [3, 4, 5]
  }

  // Rest pattern at the beginning
  switch (numbers) {
    case [...var init, int last]:
      print('Init: $init, Last: $last');
      // Init: [1, 2, 3, 4], Last: 5
  }
}

Map Patterns

Map patterns match maps by checking for specific keys and matching their values against sub-patterns. Unlike list patterns, map patterns do not require the map to have only the specified keys -- extra keys are ignored.

Map Patterns

void processConfig(Map<String, dynamic> config) {
  switch (config) {
    case {'type': 'database', 'host': String host, 'port': int port}:
      print('Database at $host:$port');
    case {'type': 'cache', 'provider': String provider}:
      print('Cache provider: $provider');
    case {'type': String type}:
      print('Unknown config type: $type');
    default:
      print('Invalid config: missing type');
  }
}

void main() {
  processConfig({'type': 'database', 'host': 'localhost', 'port': 5432});
  // Database at localhost:5432

  processConfig({'type': 'cache', 'provider': 'redis', 'ttl': 3600});
  // Cache provider: redis (extra key 'ttl' is ignored)

  processConfig({'type': 'queue'});
  // Unknown config type: queue

  processConfig({'name': 'test'});
  // Invalid config: missing type
}

Record Patterns

Record patterns destructure records by matching positional and named fields, enabling powerful combinations with other pattern types.

Record Patterns

void main() {
  // Positional record pattern
  final pair = (42, 'hello');
  switch (pair) {
    case (int n, String s) when n > 0:
      print('Positive $n with string "$s"');
    case (0, String s):
      print('Zero with "$s"');
    case (int n, _):
      print('Negative $n with something');
  }

  // Named record pattern
  final user = (name: 'Edrees', age: 28, role: 'admin');
  switch (user) {
    case (name: String name, age: int age, role: 'admin'):
      print('Admin $name (age $age)');
    case (name: String name, age: int age, role: 'user'):
      print('User $name (age $age)');
    case (name: String name, :var age, :var role):
      print('Other: $name, $age, $role');
  }
  // Output: Admin Edrees (age 28)
}

Object Patterns

Object patterns match against class instances by checking the runtime type and then matching the object’s properties (getters) against sub-patterns.

Object Patterns

class Point {
  final double x;
  final double y;
  const Point(this.x, this.y);
}

class Circle {
  final Point center;
  final double radius;
  const Circle(this.center, this.radius);
}

class Rectangle {
  final Point topLeft;
  final double width;
  final double height;
  const Rectangle(this.topLeft, this.width, this.height);
}

String describeShape(Object shape) {
  return switch (shape) {
    Circle(center: Point(x: 0, y: 0), radius: var r) =>
      'Circle at origin with radius $r',
    Circle(center: var c, radius: var r) =>
      'Circle at (${c.x}, ${c.y}) with radius $r',
    Rectangle(width: var w, height: var h) when w == h =>
      'Square with side $w',
    Rectangle(topLeft: var tl, width: var w, height: var h) =>
      'Rectangle at (${tl.x}, ${tl.y}): ${w}x$h',
    _ => 'Unknown shape',
  };
}

void main() {
  print(describeShape(Circle(Point(0, 0), 5)));
  // Circle at origin with radius 5
  print(describeShape(Circle(Point(3, 4), 10)));
  // Circle at (3.0, 4.0) with radius 10
  print(describeShape(Rectangle(Point(1, 1), 5, 5)));
  // Square with side 5.0
  print(describeShape(Rectangle(Point(0, 0), 10, 20)));
  // Rectangle at (0.0, 0.0): 10.0x20.0
}

Switch Expressions

Dart 3 introduces switch expressions -- a concise way to return a value from pattern matching. Unlike switch statements, switch expressions use => instead of :, separate cases with commas, and produce a value.

Switch Expressions

// Switch expression returns a value
String httpStatus(int code) => switch (code) {
  200 => 'OK',
  301 => 'Moved Permanently',
  400 => 'Bad Request',
  401 => 'Unauthorized',
  403 => 'Forbidden',
  404 => 'Not Found',
  500 => 'Internal Server Error',
  _ => 'Unknown ($code)',
};

// Use in variable assignment
void main() {
  final status = httpStatus(404);
  print(status); // Not Found

  // Inline switch expression
  final dayType = switch (DateTime.now().weekday) {
    DateTime.saturday || DateTime.sunday => 'Weekend',
    _ => 'Weekday',
  };
  print(dayType);

  // Switch expression with records
  final point = (3, 4);
  final quadrant = switch (point) {
    (int x, int y) when x > 0 && y > 0 => 'Q1',
    (int x, int y) when x < 0 && y > 0 => 'Q2',
    (int x, int y) when x < 0 && y < 0 => 'Q3',
    (int x, int y) when x > 0 && y < 0 => 'Q4',
    _ => 'On axis',
  };
  print('($point) is in $quadrant'); // ((3, 4)) is in Q1
}

if-case Statements

The if-case construct lets you use a single pattern match as a condition. It is perfect when you only need to check one pattern instead of writing a full switch.

if-case Statements

void main() {
  // if-case with type check and destructuring
  final Object value = [1, 2, 3];

  if (value case [int first, ...var rest]) {
    print('First: $first, Rest: $rest');
    // First: 1, Rest: [2, 3]
  }

  // if-case with map pattern (great for JSON)
  final json = {'name': 'Edrees', 'age': 28, 'city': 'Riyadh'};

  if (json case {'name': String name, 'age': int age}) {
    print('$name is $age years old');
    // Edrees is 28 years old
  }

  // if-case with guard clause
  final score = 85;
  if (score case int s when s >= 90) {
    print('Excellent!');
  } else if (score case int s when s >= 80) {
    print('Very good!'); // Very good!
  } else {
    print('Keep trying!');
  }

  // Nullable value handling with if-case
  final String? maybeName = getMaybeName();
  if (maybeName case String name) {
    print('Got name: $name');
  } else {
    print('Name is null');
  }
}

String? getMaybeName() => 'Ahmed';

Guard Clauses (when)

The when keyword adds a boolean condition to a pattern. The pattern matches only if both the structural match succeeds and the guard condition is true. Variables bound in the pattern are available in the guard.

Guard Clauses

String classifyAge(int age) => switch (age) {
  int a when a < 0 => 'Invalid age',
  int a when a < 13 => 'Child',
  int a when a < 18 => 'Teenager',
  int a when a < 65 => 'Adult',
  _ => 'Senior',
};

String classifyTriangle(double a, double b, double c) {
  // Sort sides for easier comparison
  final sides = [a, b, c]..sort();
  final [s1, s2, s3] = sides;

  return switch ((s1, s2, s3)) {
    (double x, double y, double z) when x + y <= z =>
      'Not a valid triangle',
    (double x, double y, double z) when x == y && y == z =>
      'Equilateral',
    (double x, double y, double z) when x == y || y == z =>
      'Isosceles',
    _ => 'Scalene',
  };
}

void main() {
  print(classifyAge(5));   // Child
  print(classifyAge(15));  // Teenager
  print(classifyAge(30));  // Adult
  print(classifyAge(70));  // Senior

  print(classifyTriangle(3, 3, 3));   // Equilateral
  print(classifyTriangle(3, 3, 5));   // Isosceles
  print(classifyTriangle(3, 4, 5));   // Scalene
  print(classifyTriangle(1, 2, 10));  // Not a valid triangle
}

Exhaustive Switching

When you switch on a sealed class, an enum, or a boolean, Dart can verify at compile time that you have covered every possible case. This is called exhaustiveness checking. If you miss a case, the compiler tells you.

Exhaustive Switch with Sealed Classes

sealed class Shape {}
class Circle extends Shape {
  final double radius;
  Circle(this.radius);
}
class Square extends Shape {
  final double side;
  Square(this.side);
}
class Triangle extends Shape {
  final double base;
  final double height;
  Triangle(this.base, this.height);
}

// Dart GUARANTEES all cases are covered -- no default needed!
double area(Shape shape) => switch (shape) {
  Circle(radius: var r) => 3.14159 * r * r,
  Square(side: var s) => s * s,
  Triangle(base: var b, height: var h) => 0.5 * b * h,
};
// If you add a new Shape subclass without updating this switch,
// the compiler will produce an error!

// Exhaustive switch with enums
enum TrafficLight { red, yellow, green }

String action(TrafficLight light) => switch (light) {
  TrafficLight.red => 'Stop',
  TrafficLight.yellow => 'Slow down',
  TrafficLight.green => 'Go',
  // No default needed -- compiler knows all cases are covered
};

void main() {
  print(area(Circle(5)));            // 78.53975
  print(area(Square(4)));            // 16.0
  print(area(Triangle(6, 3)));       // 9.0
  print(action(TrafficLight.green)); // Go
}
Warning: Exhaustiveness checking only works with sealed classes, enums, and booleans. If you switch on a regular class or Object, Dart cannot know all possible subtypes, so you must include a default or wildcard _ case. Always prefer sealed over abstract when you have a fixed set of subtypes.

Logical Patterns (&& and ||)

Logical patterns combine multiple sub-patterns. The || pattern matches if either sub-pattern matches. The && pattern matches only if both sub-patterns match.

Logical OR and AND Patterns

void main() {
  // OR pattern: match any of several values
  for (final char in 'Hello World 123!'.split('')) {
    final type = switch (char) {
      'a' || 'e' || 'i' || 'o' || 'u' ||
      'A' || 'E' || 'I' || 'O' || 'U' => 'vowel',
      ' ' => 'space',
      String c when c.contains(RegExp(r'[0-9]')) => 'digit',
      String c when c.contains(RegExp(r'[a-zA-Z]')) => 'consonant',
      _ => 'other',
    };
    // ...
  }

  // AND pattern: combine type check with value constraint
  final value = 42;
  switch (value) {
    case int n && > 0:
      print('Positive int: $n'); // Positive int: 42
  }
}

Null-Check and Null-Assert Patterns

These patterns provide concise ways to handle nullable values within pattern matching.

Null-Check and Null-Assert Patterns

void main() {
  // Null-check pattern: matches non-null values, unwraps
  String? maybeName = 'Edrees';

  switch (maybeName) {
    case String name?:
      // The ? means "match if non-null"
      // Equivalent to: case String name when name != null
      // But name? only matches if the UNDERLYING value is non-null
      print('Name: $name'); // Name: Edrees
    case null:
      print('No name');
  }

  // Null-check in if-case
  final Map<String, int> scores = {'math': 95, 'science': 88};
  final int? mathScore = scores['math'];

  if (mathScore case int score?) {
    print('Math score: $score'); // Math score: 95
  }

  // Null-assert pattern: asserts non-null (throws if null)
  final (String name, int? age) = ('Edrees', 28);
  switch ((name, age)) {
    case (String n, int a!):
      // a! means "this MUST be non-null" -- throws if null
      print('$n is $a years old'); // Edrees is 28 years old
  }
}
Tip: Prefer the null-check pattern (?) over the null-assert pattern (!) in most cases. The null-check pattern safely falls through to the next case if the value is null, while the null-assert pattern throws a runtime exception. Use ! only when you are certain the value cannot be null and want to express that certainty.

Practical Example: JSON Parsing

Pattern matching excels at parsing and validating structured data like JSON. Here is a real-world example of parsing API responses.

Type-Safe JSON Parsing with Patterns

import 'dart:convert';

sealed class ApiResult {}
class Success extends ApiResult {
  final List<Map<String, dynamic>> users;
  Success(this.users);
}
class ApiError extends ApiResult {
  final String message;
  final int code;
  ApiError(this.message, this.code);
}

ApiResult parseResponse(String jsonString) {
  final json = jsonDecode(jsonString);

  return switch (json) {
    {'status': 'success', 'data': List users} =>
      Success(users.cast<Map<String, dynamic>>()),
    {'status': 'error', 'message': String msg, 'code': int code} =>
      ApiError(msg, code),
    {'status': 'error', 'message': String msg} =>
      ApiError(msg, 0),
    _ => ApiError('Unknown response format', -1),
  };
}

void main() {
  final successJson = '{"status":"success","data":[{"name":"Edrees","age":28}]}';
  final errorJson = '{"status":"error","message":"Not found","code":404}';

  final result1 = parseResponse(successJson);
  final result2 = parseResponse(errorJson);

  // Exhaustive switch -- compiler ensures all cases handled
  switch (result1) {
    case Success(users: var users):
      for (final user in users) {
        if (user case {'name': String name, 'age': int age}) {
          print('User: $name, Age: $age');
        }
      }
    case ApiError(message: var msg, code: var code):
      print('Error $code: $msg');
  }
}

Practical Example: State Machine

Pattern matching with sealed classes creates elegant, type-safe state machines.

State Machine with Sealed Classes and Patterns

sealed class AuthState {}
class Unauthenticated extends AuthState {}
class Authenticating extends AuthState {
  final String email;
  Authenticating(this.email);
}
class Authenticated extends AuthState {
  final String userId;
  final String token;
  Authenticated(this.userId, this.token);
}
class AuthError extends AuthState {
  final String message;
  final int attempts;
  AuthError(this.message, this.attempts);
}

sealed class AuthEvent {}
class LoginRequested extends AuthEvent {
  final String email;
  final String password;
  LoginRequested(this.email, this.password);
}
class LogoutRequested extends AuthEvent {}
class TokenExpired extends AuthEvent {}

AuthState handleEvent(AuthState state, AuthEvent event) =>
    switch ((state, event)) {
      // From unauthenticated: can only login
      (Unauthenticated(), LoginRequested(email: var e, :var password)) =>
        Authenticating(e),

      // From authenticating: simulate success/failure
      (Authenticating(email: var e), _) when e.contains('@') =>
        Authenticated('user_123', 'token_abc'),
      (Authenticating(), _) =>
        AuthError('Invalid email format', 1),

      // From authenticated: can logout or handle token expiry
      (Authenticated(), LogoutRequested()) =>
        Unauthenticated(),
      (Authenticated(), TokenExpired()) =>
        Unauthenticated(),

      // From error: can retry login
      (AuthError(attempts: var a), LoginRequested(:var email, :var password))
          when a < 3 =>
        Authenticating(email),
      (AuthError(attempts: var a), LoginRequested()) when a >= 3 =>
        AuthError('Too many attempts. Account locked.', a),

      // Default: ignore unexpected events
      (var currentState, _) => currentState,
    };

void main() {
  AuthState state = Unauthenticated();
  print('Initial: $state');

  state = handleEvent(state, LoginRequested('edrees@example.com', 'pass123'));
  print('After login request: ${state.runtimeType}');

  state = handleEvent(state, LogoutRequested()); // triggers auth success first
  print('After processing: ${state.runtimeType}');
}

Summary

Pattern matching in Dart 3 is a comprehensive system that transforms how you write conditional logic. From simple constant matching to complex nested object destructuring with guard clauses, patterns make code more declarative, safer, and easier to maintain. Combine patterns with sealed classes for exhaustive type checking, use switch expressions for concise value computation, and leverage if-case for single-pattern conditions. Master these tools and your Dart code will be significantly cleaner and more robust.

Note: Pattern matching is not just syntax sugar -- it changes how you design your code. When you know you can pattern match on sealed class hierarchies with exhaustiveness checking, you naturally design better type hierarchies and state machines. Think of patterns as a design tool, not just a coding convenience.