Pattern Matching in Depth (Dart 3)
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
}
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
}
}
?) 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.