Dart 3.0 · New Features


Dart 3.0 · New Features

Everything you need to know about Patterns, Records, and Switch expressions — explained simply with real Flutter examples.

Dart 3.0+  15 min read  Beginner to Advanced  Flutter ready
01

What are Patterns?

Before Dart 3, extracting data from complex objects required multiple lines of manual work. It was repetitive and error-prone.

Think of a Pattern like a shape stencil. Hold it against some data — if the data fits the shape, you extract pieces right there inline. If it doesn't match, the pattern fails.

ℹ️

Official definition: Patterns are a syntactic category in Dart, like statements and expressions. A pattern represents the shape of a set of values that it may match against actual values.

Patterns do two things:

  • Match — check whether a value has a certain structure or content
  • Destructure — pull out parts of a value into separate variables
Variable
Binds a value to a new variable
case final x
Record
Destructures a record type
(final a, final b)
Object
Matches a class & extracts fields
User(:name, :age)
List
Matches and destructures a List
[first, ...rest]
Map
Matches and extracts Map keys
{'key': val}
Logical
Combines patterns with && or ||
x && y, a || b

Key takeaway: Patterns are not just a syntax trick. They fundamentally change how you write conditional logic — more expressive, safer, and often much shorter.

02

Variable Patterns

A variable pattern binds a matched value to a new variable. It is the building block of every other pattern. Use final (preferred) or var.

Dart
// Typed variable — only matches if value IS that type
switch (response) {
  case final String message:
    print('Got a string: $message');
  case final int number:
    print('Got a number: $number');
  case final List<String> items:
    print('Got ${items.length} items');
}

// Typed final in if-case
if (getAnything() case final String text) {
  // only enters if getAnything() returned a String
  print(text.toUpperCase());
}

// var — can reassign (rarely needed)
if (getValue() case var result) {
  result = result.toString().toUpperCase();
  print(result);
}

Key takeaway: Always prefer final over var in patterns. Think of it as: match it, bind it, use it — don't change it.

03

if-case Statement

The if-case lets you match a pattern and bind variables inside a single if condition — without a full switch block. “If this value matches this shape, enter the block with these bound variables.”

if ( expression  case final  variable ) {  // use variable here }
if keyword expression (any value) case keyword pattern → binds variable variable usable inside block
❌ Before Dart 3
Old
// Two-step: store then check
final result = fetchUser();
if (result != null) {
  print(result.name);
}

// Calls getMap() TWICE
if (getMap().containsKey('x')) {
  final val = getMap()['x'];
  print(val);
}
✅ Dart 3 if-case
New
// Match, bind, use — one step
if (fetchUser() case final user) {
  print(user.name);
}

// Called ONCE, result bound
if (getMap() case final m
    when m.containsKey('x')) {
  print(m['x']);
}
Dart — type check + bind in one shot
dynamic apiResponse = fetchData();

// Only enters if apiResponse IS a Map
if (apiResponse case final Map<String, dynamic> data) {
  print(data['name']); // typed as Map inside
}

// Only enters if it IS a String
if (apiResponse case final String error) {
  showError(error);
}

// Type AND condition together
if (apiResponse case final int code when code > 400) {
  showHttpError(code);
}

Key takeaway: Use if-case when you have a single pattern. It replaces: “call function → store in temp var → check temp var.” Now it's one line.

04

Guard Clauses (when)

A pattern checks structure and type. The when keyword adds an extra value condition on top. Both must pass for the block to execute.

if ( expr  case final  x  when  x.isValid && x.age > 18  ) {}
pattern binds x when: extra condition using x both must pass to enter
Dart
// when in if-case
if (getScore() case final int score when score >= 90) {
  print('Grade A: $score');
}

// when in switch statement
switch (user) {
  case final User u when u.isAdmin && u.isActive:
    grantAdminAccess(u);
  case final User u when u.isActive:
    grantBasicAccess(u);
  case _:
    denyAccess();
}

// when in switch expression
String label = switch (temperature) {
  final t when t < 0   => 'Freezing',
  final t when t < 15  => 'Cold',
  final t when t < 25  => 'Comfortable',
  _                    => 'Hot',
};
⚠️

Important: The when clause can only use variables bound by the pattern. External variables belong in a regular if after the block.

Key takeaway: Pattern = shape check. When = value check. Without when, you'd need a nested if inside the block.

05

Switch Expressions

Before Dart 3, switch was a statement — it ran code but produced no value. The new switch expression returns a value, perfect for final x = switch (...) { ... }.

❌ Old switch statement
Old
String label;
switch (status) {
  case 'active':
    label = 'Active';
    break;
  case 'inactive':
    label = 'Inactive';
    break;
  default:
    label = 'Unknown';
}
✅ Switch expression
New
// One line, label is final
final label = switch (status) {
  'active'   => 'Active',
  'inactive' => 'Inactive',
  _          => 'Unknown',
};
Dart — advanced switch expression
// With type patterns
Widget buildIcon(dynamic v) => switch (v) {
  final String s      => Text(s),
  final int i         => Text(i.toString()),
  final IconData icon => Icon(icon),
  _                   => const SizedBox.shrink(),
};

// With guard clauses
Color statusColor(int code) => switch (code) {
  final c when c >= 200 && c < 300 => Colors.green,
  final c when c >= 400 && c < 500 => Colors.orange,
  final c when c >= 500              => Colors.red,
  _                                  => Colors.grey,
};

// Returning a Record (multiple values at once)
(String, Color) getInfo(int code) => switch (code) {
  200 => ('OK', Colors.green),
  404 => ('Not Found', Colors.orange),
  500 => ('Server Error', Colors.red),
  _   => ('Unknown', Colors.grey),
};

Key takeaway: Switch expressions replace long if-else chains for value mapping — status codes to colors, enum cases to widgets, API states to UI states.

06

Switch Statements with Patterns

The old switch only matched exact values. In Dart 3, cases can use full patterns — type checks, destructuring, guards, everything.

Dart
// Pattern matching on type
void handleResponse(dynamic response) {
  switch (response) {
    case final Map<String, dynamic> data:
      print('Map: ${data.length} keys');
    case final List<dynamic> list:
      print('List: ${list.length} items');
    case final String text:
      print('String: $text');
    case null:
      print('Got null');
    default:
      print('Unknown type');
  }
}

// Exhaustive switch on sealed class — no default needed!
sealed class Shape {}
class Circle    extends Shape { final double radius; Circle(this.radius); }
class Rectangle extends Shape { final double w, h; Rectangle(this.w, this.h); }
class Triangle  extends Shape { final double b, h; Triangle(this.b, this.h); }

double area(Shape s) => switch (s) {
  Circle(:final radius)      => 3.14 * radius * radius,
  Rectangle(:final w, :final h) => w * h,
  Triangle(:final b, :final h)  => 0.5 * b * h,
};

Sealed classes + switch = exhaustive checking. When you switch on a sealed class, Dart verifies all subclasses are handled. Add a new subclass and forget the switch — it's a compile error, not a runtime crash.

Key takeaway: No more if (x is Circle) { final c = x as Circle; ... }. The pattern checks the type, casts, and extracts fields in a single case.

07

Record Types

Records are Dart 3's new built-in type for grouping multiple values together without defining a class. Anonymous, immutable, typed tuples.

❌ Before — awkward workarounds
Old
// Overkill: dedicated class
class NameAge {
  final String name;
  final int age;
  NameAge(this.name, this.age);
}

// Not type-safe: Map
Map<String, dynamic> getUser() =>
    {'name': 'Ali', 'age': 25};
// caller must cast: map['age'] as int
✅ Dart 3 Records
New
// Named record — zero boilerplate
({String name, int age}) getUser() =>
    (name: 'Ali', age: 25);

final u = getUser();
print(u.name); // typed — no cast!
print(u.age);
Dart — record syntax
// Positional — access with .$1, .$2
(String, int) pos = ('Flutter', 3);
print(pos.$1); // Flutter
print(pos.$2); // 3

// Named — access with .fieldName
({String lang, int version}) named = (lang: 'Flutter', version: 3);
print(named.lang);    // Flutter
print(named.version); // 3

// Mixed
(int, {String label}) mixed = (42, label: 'Answer');
print(mixed.$1);    // 42
print(mixed.label); // Answer

// Built-in value equality
print(('a', 1) == ('a', 1));       // true
print((name: 'x') == (name: 'x')); // true

Key takeaway: Use Records when returning 2–3 related values. Built-in equality, fully type-safe, zero boilerplate. Don't create a class just to return two things.

08

Record Destructuring

Destructuring extracts record fields into separate named variables in a single line.

Dart
// Positional record
(String, int) getCoords() => ('London', 51);
final (city, latitude) = getCoords();
print(city);     // London
print(latitude); // 51

// Named record
({String name, int age, bool isAdmin}) getUser() =>
    (name: 'Sara', age: 30, isAdmin: true);

final (:name, :age, :isAdmin) = getUser();
print(name);    // Sara
print(isAdmin); // true

// In a for loop
final users = [(name: 'Alice', score: 95), (name: 'Bob', score: 82)];
for (final (:name, :score) in users) {
  print('$name scored $score');
}

// Inside if-case
if (fetchStatus() case (final int code, final String msg)
    when code == 200) {
  print('Success: $msg');
}

Key takeaway: Destructure once at the top instead of repeating .fieldName everywhere. Cleaner, less repetition.

09

Object Patterns

Object patterns match against a specific class type and destructure its fields — all in one step.

Dart
class User {
  final String name;
  final int age;
  User(this.name, this.age);
}

// Match type + extract fields in one case
void greet(Object obj) {
  switch (obj) {
    case User(:final name, :final age) when age >= 18:
      print('Hello adult $name!');
    case User(:final name, :final age):
      print('Hello $name, you are $age');
    default:
      print('Not a user');
  }
}

// :final name  =  shorthand for  name: final name
// (extract 'name' field into a var also called 'name')

// Rename if needed
case User(name: final displayName, age: final userAge):
  print('$displayName is $userAge years old');

// Nested object patterns
switch (order) {
  case Order(
    customer: User(:final name),
    total: final t,
  ) when t > 1000:
    print('High-value order from $name: $$t');
}

Key takeaway: Replaces if (x is Foo) { final f = x as Foo; ... } completely. Type check + cast + field extraction in a single case.

10

List & Map Patterns

List patterns

Dart
// Exactly 2 elements
if (coords case [final lat, final lng]) {
  print('Lat: $lat, Lng: $lng');
}

// First element + rest
if (items case [final first, ...final rest]) {
  print('First: $first, remaining: ${rest.length}');
}

// Match by length / structure
switch (args) {
  case []:                 print('No arguments');
  case [final s]:          print('One: $s');
  case [final a, final b]: print('Two: $a and $b');
  case [final f, ...]:     print('Many, first: $f');
}

Map patterns

Dart
// Match specific keys
if (json case {'name': final String name, 'role': 'admin'}) {
  print('Admin: $name');
}

// API response parsing
void handle(Map<String, dynamic> r) {
  switch (r) {
    case {'status': 'success', 'data': final Map data}:
      processData(data);
    case {'status': 'error', 'message': final String msg}:
      showError(msg);
    case {'status': 'pending'}:
      showLoader();
    default:
      showGenericError();
  }
}

Key takeaway: Map patterns check that keys exist AND match a value pattern. Extra keys are ignored — perfect for parsing JSON.

11

Logical Patterns (&&, ||)

Combine patterns with && (and) or || (or) to create composite patterns without nesting.

Dart
// OR — same body for multiple cases (replaces fallthrough)
String classify(int n) => switch (n) {
  1 || 2 || 3 => 'small',
  4 || 5 || 6 => 'medium',
  7 || 8 || 9 => 'large',
  _           => 'out of range',
};

// OR — match either type
switch (shape) {
  case Circle() || Square():   print('Simple');
  case Triangle() || Hexagon(): print('Complex');
}

// OR with when
switch (user) {
  case Admin() || Moderator() when user.isActive:
    showManagementPanel(user);
  case RegularUser() when user.isActive:
    showUserDashboard(user);
  default:
    showInactiveScreen();
}

Key takeaway: Use || to handle multiple cases with one outcome (replaces stacked case labels). Use && when a value must satisfy two patterns simultaneously.

12

Real-world Flutter / Bloc Examples

1. if-case: call a function only once

❌ Before
Old
// Called TWICE
} else if (_checkDuplicates()
               .values.first) {
  int id = _checkDuplicates()
               .keys.first;
  return getError(id);
}
✅ After
New
// Called ONCE, bound to 'dup'
} else if (_checkDuplicates()
    case final dup
    when dup.values.first) {
  int id = dup.keys.first;
  return getError(id);
}

2. Switch expression: BLoC state → Widget

Dart — Flutter Widget
sealed class NomineeState {}
class NomineeLoading extends NomineeState {}
class NomineeLoaded extends NomineeState {
  final List<Nominee> nominees;
  NomineeLoaded(this.nominees);
}
class NomineeError extends NomineeState {
  final String message;
  NomineeError(this.message);
}

Widget buildContent(NomineeState state) => switch (state) {
  NomineeLoading()               => const CircularProgressIndicator(),
  NomineeLoaded(:final nominees)
    when nominees.isEmpty         => const EmptyNomineeView(),
  NomineeLoaded(:final nominees) => NomineeListView(nominees: nominees),
  NomineeError(:final message)   => ErrorView(message: message),
};

3. Record: return multiple values from a method

Dart — BLoC
({bool isValid, String? error}) _validate(Nominee n) {
  if (n.name.isEmpty) return (isValid: false, error: 'Name required');
  if (n.share <= 0)  return (isValid: false, error: 'Share must be positive');
  return (isValid: true, error: null);
}

// Destructure the result
final (:isValid, :error) = _validate(nominee);
if (!isValid) {
  emit(state.copyWith(errorMessage: error));
  return;
}

4. Map pattern: parse JSON API response

Dart
void processResponse(Map<String, dynamic> json) {
  switch (json) {
    case {'status': 'success',
          'data': {'nominees': final List nominees}}:
      emit(NomineeLoaded(nominees.map(Nominee.fromJson).toList()));
    case {'status': 'error', 'message': final String msg}:
      emit(NomineeError(msg));
    case {'status': 'token_expired'}:
      emit(NomineeSessionExpired());
    default:
      emit(NomineeError('Unexpected response'));
  }
}

5. Quick reference

PatternSyntaxUse when
Variablecase final xBind any value
Typed variablecase final String xCheck type AND bind
if-caseif (expr case pat)Single inline pattern check
Guardcase pat when condExtra condition on pattern
Switch expressionfinal x = switch(v){}Map value to another value
Record(String, int)Return multiple typed values
Record destructurefinal (:a, :b) = recExtract record fields inline
Object patterncase Foo(:final x)Type check + field extraction
List patterncase [a, b, ...rest]Match list structure
Map patterncase {'k': final v}Match map keys/values
Logical ORcase A() || B()Match either pattern
Logical ANDcase A() && B()Must match both patterns

Final takeaway: Start with if-case and switch expressions — easiest wins. Then adopt Records for multi-value returns, and object patterns for sealed class hierarchies.

Written to help Flutter developers adopt Dart 3 Patterns confidently — with real examples from production code.

Dart 3.0+ · Flutter 3.10+ · All examples tested

Dart 3 Flutter Patterns Records BLoC if-case Switch Expression

Post a Comment

0 Comments