Skip to content

Dart Records and Patterns — Destructuring and Pattern Matching

DodaTech Updated 2026-06-28 10 min read

In this tutorial, you will learn about Dart Records and Patterns. We cover key concepts, practical examples, and best practices to help you master this topic.

Dart records are lightweight anonymous data structures that group multiple values into a single object, while patterns provide destructuring and matching capabilities introduced in Dart 3.

What You Will Learn

  • Creating and using records with positional and named fields
  • Destructuring records, lists, and maps
  • Pattern matching in switch expressions and statements
  • If-case patterns for conditional matching
  • Guard clauses with when patterns
  • Using patterns with collections and JSON

Why It Matters

Records and patterns together transform how you write Dart code. Records eliminate the need to create small wrapper classes just to return multiple values from a function. Patterns make data extraction concise and readable. Switch expressions with pattern matching replace complex if-else chains with declarative, exhaustive matching. These features, introduced in Dart 3, reduce boilerplate and make code more expressive.

Real-World Use

The DodaTech app uses records extensively in API layer code. A function that fetches user data returns a (User?, String?) record representing (data, error), eliminating the need for a separate ApiResult class. Pattern matching in the UI layer destructures API responses and renders the appropriate widget.

Learning Path

flowchart LR
  A[Dart Extensions] --> B[Records and Patterns\nYou are here]
  B --> C[Flutter Setup]
  style B fill:#f90,color:#fff

Creating Records

Records group multiple values without defining a class:

void main() {
  // Positional record
  var person = ('Alice', 30, true);
  print('${person.$1} is ${person.$2} years old, active: ${person.$3}');

  // Named record
  var book = (title: 'Dart Guide', pages: 350, published: true);
  print('${book.title} has ${book.pages} pages');

  // Mixed positional and named
  var mixed = (42, label: 'answer', true);
  print('${mixed.\$1}, ${mixed.label}, ${mixed.\$3}');

  // Single field records
  var single = (42); // Just a parenthesized int, NOT a record
  var actualRecord = (42,); // Trailing comma makes it a record
  print('${single.runtimeType} vs ${actualRecord.runtimeType}');
}

Output:

Alice is 30 years old, active: true
Dart Guide has 350 pages
42, answer, true
int vs (int)

Positional fields are accessed with $1, $2, $3. Named fields are accessed by name. A trailing comma after the first field distinguishes a single-field record from a parenthesized expression.

Records as Return Types

Records are commonly used to return multiple values from functions:

// Function returning multiple values without a custom class
(String, int, double) analyzeList(List<int> numbers) {
  if (numbers.isEmpty) return ('empty', 0, 0.0);

  var sum = numbers.fold(0, (a, b) => a + b);
  var avg = sum / numbers.length;
  var status = sum > 100 ? 'large' : 'small';

  return (status, sum, avg);
}

void main() {
  var data = [10, 20, 30, 40, 50];
  var (status, sum, avg) = analyzeList(data);

  print('Status: $status');
  print('Sum: $sum');
  print('Average: $avg');
}

Output:

Status: small
Sum: 150
Average: 30.0

Destructuring with var (field1, field2, ...) = function() extracts the record fields into separate variables in one line.

Named Records for Clarity

Named records make the meaning of each field clear at the call site:

// Named record type
({String name, int age, String? email}) createUser(String name, int age) {
  return (name: name, age: age, email: null);
}

({double min, double max, double average}) computeStats(List<double> values) {
  if (values.isEmpty) return (min: 0, max: 0, average: 0.0);

  var min = values.reduce((a, b) => a < b ? a : b);
  var max = values.reduce((a, b) => a > b ? a : b);
  var avg = values.fold(0.0, (a, b) => a + b) / values.length;

  return (min: min, max: max, average: avg);
}

void main() {
  var user = createUser('Alice', 30);
  print('User: ${user.name}, ${user.age}, email: ${user.email}');

  var temps = [23.5, 25.1, 22.8, 26.3, 21.9];
  var stats = computeStats(temps);
  print('Temp range: ${stats.min} - ${stats.max}, avg: ${stats.average}');
}

Output:

User: Alice, 30, email: null
Temp range: 21.9 - 26.3, avg: 23.92

Named records solve the "what does field $1 mean?" problem. The field names are part of the type, so the compiler enforces correct usage.

Pattern Matching on Records

Patterns destructure records in assignments and switch cases:

void main() {
  var point = (3, 4);

  // Destructuring in variable declaration
  var (x, y) = point;
  print('x: $x, y: $y');

  // Destructuring in switch
  var description = switch (point) {
    (0, 0) => 'Origin',
    (0, _) => 'On Y-axis',
    (_, 0) => 'On X-axis',
    var (px, py) when px == py => 'On diagonal',
    var (px, py) => 'Point at ($px, $py)',
  };
  print(description);

  // Nested records
  var nested = ('Alice', (30, 'Engineer'));
  var (name, (age, job)) = nested;
  print('$name is $age and works as $job');
}

Output:

x: 3, y: 4
Point at (3, 4)
Alice is 30 and works as Engineer

Patterns destructure at multiple levels. The _ wildcard matches any value. The var keyword declares new variables. The when clause adds a condition.

List and Map Patterns

Patterns also destructure lists and maps:

void main() {
  // List patterns
  var list1 = [1, 2, 3];
  var list2 = [1, 2, 3, 4, 5];

  void describeList(List<int> list) {
    var description = switch (list) {
      [] => 'Empty',
      [var a] => 'Single element: $a',
      [var a, var b] => 'Two elements: $a, $b',
      [var a, var b, var c] => 'Three elements: $a, $b, $c',
      [var a, ..., var b] => 'First: $a, Last: $b, Length: ${list.length}',
    };
    print(description);
  }

  describeList([]);
  describeList([42]);
  describeList([1, 2]);
  describeList([1, 2, 3]);
  describeList([1, 2, 3, 4, 5]);

  // Map patterns
  var json = {'name': 'Alice', 'age': 30, 'role': 'admin'};
  if (json case {'name': var name, 'age': var age}) {
    print('Extracted: $name, $age years old');
  }
}

Output:

Empty
Single element: 42
Two elements: 1, 2
Three elements: 1, 2, 3
First: 1, Last: 5, Length: 5
Extracted: Alice, 30 years old

The ... rest element matches zero or more elements in the middle of a list. Map patterns match specific keys and extract their values.

If-Case Patterns

The if-case construct matches a single pattern without a full switch:

void main() {
  Object value = 'Hello, Dart!';

  // if-case with type check
  if (value is String) {
    print('String: ${value.toUpperCase()}');
  }

  // if-case with destructuring
  var point = (5, 12);
  if (point case (var x, var y) when x > 0 && y > 0) {
    print('First quadrant point at ($x, $y)');
  }

  // if-case on nullable
  String? maybeName = 'Bob';
  if (maybeName case var name?) {
    print('Name is: $name');
  }

  // if-case with relational patterns
  var age = 25;
  if (age case > 18) {
    print('Adult');
  }
}

Output:

String: HELLO, DART!
First quadrant point at (5, 12)
Name is: Bob
Adult

If-case is ideal when you need to match one specific pattern. It avoids the boilerplate of a full switch when only one case is relevant.

Relational and Logical Patterns

Dart 3 supports relational operators and logical combinators in patterns:

void main() {
  String classifyNumber(int n) {
    return switch (n) {
      > 0 && < 10 => 'Single digit positive',
      >= 10 && < 100 => 'Two digit positive',
      >= 100 => 'Large positive',
      < 0 && > -10 => 'Single digit negative',
      < -10 && > -100 => 'Two digit negative',
      0 => 'Zero',
      _ => 'Other',
    };
  }

  print(classifyNumber(5));
  print(classifyNumber(42));
  print(classifyNumber(-7));
  print(classifyNumber(0));
  print(classifyNumber(1000));

  // OR patterns with ||
  String describeColor(String color) {
    return switch (color) {
      'red' || 'green' || 'blue' => 'Primary color',
      'cyan' || 'magenta' || 'yellow' => 'Secondary color',
      _ => 'Unknown color',
    };
  }

  print(describeColor('red'));
  print(describeColor('cyan'));
  print(describeColor('purple'));
}

Output:

Single digit positive
Two digit positive
Single digit negative
Zero
Large positive
Primary color
Secondary color
Unknown color

Relational patterns use >, <, >=, <= operators. Logical patterns use && and || to combine conditions. These make switch statements as expressive as if-else chains.

Pattern Matching Types

Switch on types with pattern matching:

sealed class Animal {}
class Dog extends Animal {
  final String name;
  Dog(this.name);
}
class Cat extends Animal {
  final String name;
  Cat(this.name);
}
class Bird extends Animal {
  final String species;
  Bird(this.species);
}

String describeAnimal(Animal animal) {
  return switch (animal) {
    Dog(name: var n) => 'Dog: $n',
    Cat(name: var n) => 'Cat: $n',
    Bird(species: var s) => 'Bird: $s',
  };
}

void main() {
  var animals = [Dog('Buddy'), Cat('Whiskers'), Bird('Parrot')];
  for (var animal in animals) {
    print(describeAnimal(animal));
  }
}

Output:

Dog: Buddy
Cat: Whiskers
Cat: Whiskers

Type patterns in switch match the runtime type and destructure the object simultaneously. The sealed class ensures exhaustive matching: the compiler checks that every subclass has a case.

Patterns with Null Safety

Patterns integrate with null safety for concise null handling:

void main() {
  String? maybeString = 'Hello';

  // Pattern matching on nullable
  var result = switch (maybeString) {
    null => 'Got null',
    var s => 'String: $s',
  };
  print(result);

  // Null-check pattern
  if (maybeString case var s?) {
    print('Not null: $s');
  }

  // Null-value extraction with records
  (String?, int?) parseInput(String input) {
    var parts = input.split(',');
    var name = parts.length > 0 ? parts[0].trim() : null;
    var age = parts.length > 1 ? int.tryParse(parts[1].trim()) : null;
    return (name, age);
  }

  var (name, age) = parseInput('Alice, 30');
  print('${name ?? 'Unknown'}, ${age?.toString() ?? 'Unknown age'}');
}

Output:

String: Hello
Not null: Hello
Alice, 30

Null-check patterns (var s?) match non-null values and bind the unwrapped value. Patterns with nullable records extract only the non-null fields.

Common Mistakes

  1. Forgetting the trailing comma for single-field records: var record = (42); creates an int, not a record. Use var record = (42,); with a trailing comma.

  2. Confusing record field access with class fields: Record fields are accessed as $1, $2 for positional or by name for named. They are not class instances.

  3. Not handling all cases in exhaustive switch: When switching on a sealed class, every subtype must have a case. Add a _ => default or handle all variants.

  4. Over-nesting patterns: Deeply nested patterns (more than 2-3 levels) can be hard to read. Extract intermediate results into variables for clarity.

  5. Using positional records without considering readability: When returning records from functions, consider named fields for better documentation at the call site.

Practice Questions

  1. What is the difference between (int, String) and {int x, String y} records?
  2. How does if-case differ from switch with a single case?
  3. How do relational patterns (>, <) improve switch expressions?
  4. Why are records preferred over creating small wrapper classes for return values?
  5. Challenge: Write a function that takes a JSON-like Map<String, dynamic> and uses patterns to extract and validate user data. Return a record of (User?, List<String>) where the first field is the parsed user and the second is a list of validation errors.

Mini Project

Build a configuration parser with records and patterns:

  • Define a sealed class ConfigValue with StringValue, IntValue, BoolValue, ListValue subtypes
  • Parse a Map<String, dynamic> into Map<String, ConfigValue>
  • Use pattern matching to validate each value type
  • Use records to return Parsing results with accumulated errors
  • Implement a getValue<T> function that uses type patterns

FAQ

Are records value types?

Yes. Records implement value equality: two records with the same fields and values are equal. They are compared structurally, not by identity.

Can I use records as map keys?

Yes. Records implement == and hashCode, so they can be used as keys in maps and sets.

What is the maximum number of fields in a record?

Records can have an arbitrary number of fields, but practical limits are around 20-30 fields. For more fields, consider a class.

Do patterns work with inheritance?

Yes. Use type patterns with sealed classes for exhaustive matching. Patterns match the runtime type of the object.

Are records mutable?

No. Records are immutable. Use the spread operator to create a new record with modified fields.

What is Next

Now that you understand records and patterns, set up Flutter. Proceed to Flutter Setup Guide for Flutter SDK installation and first project. Then explore Flutter Widgets for building UI with the widget framework.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro