β€’

tech

Records and Pattern Matching in dart - Complete Guide

Dart 3 introduces Records, Pattern Matching, and Sealed classes to the language. Learn everything on how to use records and patterns in dart 3.


Sandro Maglione

Sandro Maglione

Software development

Records and Pattern Matching are two new features introduced in Dart 3. These feature will radically change how you write Dart (and Flutter) apps in the future.

In this post we are going to learn how Records and Pattern Matching work in Dart 3:

  • Learn the new syntax and all the newest features
  • Learn what problems records and patterns solve
  • Learn how to use records and patterns with a concrete app example
Complete example on Github

Dart 3 is currently still in alpha. Make sure to switch to the flutter master channel to enable the new features in your app by running the following command:

flutter channel master

You also need to set Dart 3 in your pubspec.yaml file as sdk:

pubspec.yaml
environment:
  sdk: ">=3.0.0 <4.0.0"

Records and Patterns - A new way to model your application

The introduction of records and pattern matching changes how you model your classes and types in dart.

We are going to explore how these features work in a concrete app example.

We are going to model a Standard 52-card deck. This can then be used to implement all sort of card games.

52-card deck structure

A Standard 52-card deck is composed of 13 cards (called Rank) grouped in 4 Suit:

  • The cards Rank go from 1 (also called Ace) up to 10, Jack (11), Queen (12), and King (13)

  • The Suits instead are Club (♣), Diamond (♦), Heart (β™₯), and Spade (β™ )

52 card deck implemented using dart 3

This is the perfect example for using records and patterns: a set of finite "states", each grouped in a well-defined category.

Note: This is the same pattern of most app, where we usually have a series of finite states (State Machine)

There is more 🀩

Timeless coding principles, practices, and tools that make a difference, regardless of your language or framework, delivered in your inbox every week.

Sealed classes in dart

The first new feature is Sealed classes (sealed).

We define each Rank and Suit using the new sealed keyword.

suit.dart
sealed class Suit {}

class Club extends Suit {}
class Diamond extends Suit {}
class Heart extends Suit {}
class Spade extends Suit {}
rank.dart
sealed class Rank {}

class Ace extends Rank {}
class Two extends Rank {}
class Three extends Rank {}
class Four extends Rank {}
class Five extends Rank {}
class Six extends Rank {}
class Seven extends Rank {}
class Eight extends Rank {}
class Nine extends Rank {}
class Ten extends Rank {}
class Jack extends Rank {}
class Queen extends Rank {}
class King extends Rank {}

Marking a class as sealed has 2 main effects:

  1. The class is abstract (you cannot create a concrete instance of Suit)
  2. All subtypes must be defined in the same library

Sealed classes before dart 3

In dart 2 you use abstract instead of sealed:

suit.dart
/// Before dart 3.0 (no `sealed` classes)
abstract class Suit {}

class Club extends Suit {}
class Diamond extends Suit {}
class Heart extends Suit {}
class Spade extends Suit {}

An abstract class does not allow you to create a concrete instance of its type (it is not possible to do Suit()). This aspect is the same for sealed, since any sealed class is also abstract.

Instance of abstract class error in Dart 3 with sealed classes: instantiate_abstract_class

The main difference is that with an abstract class it is not possible to know at compile-time all the subtypes of the main class. sealed instead requires to define all subtypes (extends Suit) in the same library.

For example, if you create a suit1.dart file as follows:

suit1.dart
import 'suit.dart';

/// This works in Dart 2, since the class is not `sealed`
/// but it does not work in Dart 3 πŸ”’
class Extra extends Suit {}

This is perfectly possible in Dart 2. In Dart 3 instead using sealed you will get the following compile-time error (invalid_use_of_type_outside_library):

New error in Dart 3 with sealed classes: invalid_use_of_type_outside_library

Why using sealed classes in dart

With a sealed class the compiler knows all the possible subtypes of the class, since these are all defined inside the same library.

In practice, this unlocks exhaustive Pattern Matching: you will get a compile-time error when you forget to match a subtype.

Using switch you will get a compile-time error in dart 3 informing you when you forget to handle a subtype (since the compiler knows all the possible cases):

/// Did you forget `Spade` in your `switch`?
///
/// In Dart 2 no error when you forget a subtype πŸ˜•
///
/// In Dart 3 you get a compile-time error instead πŸŽ‰
switch (suit) {
  case Club():
    print("Club");
    break;
  case Diamond():
    print("Diamond");
    break;
  case Heart():
    print("Heart");
    break;
}

The code above would give the following error in dart 3 (non_exhaustive_switch_statement):

New error in Dart 3 with sealed classes: non_exhaustive_switch_statement

This feature rules out many tricky bugs in your app. You will see more and more sealed classes in dart in the future, be prepared 🀝.

This pattern is the same implemented by the freezed package (Union types and Sealed classes)

For more details, you can read the sealed types feature specification

There is more 🀩

Timeless coding principles, practices, and tools that make a difference, regardless of your language or framework, delivered in your inbox every week.

Records in dart

The second main new feature in Dart 3 is Records.

Why using records

In dart 2 when you want to bundle multiple objects into a single value you have 2 options.

The first option is creating a class containing the objects as attributes:

class DataClass {
  final int number;
  final String string;
  final double float;
  
  const DataClass(this.number, this.string, this.float);
}

The problem is that this strategy is verbose, especially when the class does not represent any meaningful object (good luck coming up with a readable name πŸ’πŸΌβ€β™‚οΈ).

The second option is using a collection like List, Map, or Set:

List<Object> collection = [1, 'a', 2.0];

The problem is that this option is not type-safe: List, Map, and Set can store a single type (List<int>, List<String>, or List<double>). If you need multiple types you usually fallback to List<Object> πŸ˜•.

Dart 3 and Records to the rescue πŸ¦Έβ€β™‚οΈπŸ‘‡

How Records work in Dart 3

Records solves all the problems above:

(int, String, double) record = (1, 'a', 2.0);

// Or without type annotation
final record = (1, 'a', 2.0);

The code above creates a record containing an int, a String, and a double, without the need to create a class nor use a collection.

Defining a record looks exactly like a defining a List, but with () instead of []

You can then access each value by its index (starting at 1), prefixed with $:

(int, String, double) record = (1, 'a', 2.0);

int number = record.$1;
String string = record.$2;
double float = record.$3;

You can also assign names to each value in the record:

({double float, int number, String string}) record = (number: 1, string: 'a', float: 2.0);

// Or without type annotation
final record = (number: 1, string: 'a', float: 2.0);

By doing this, a record works like a class without a name: you can access each field from its name.

{double float, int number, String string}) record = (number: 1, string: 'a', float: 2.0);

int number = record.number;
String string = record.string;
double float = record.float;

Not enough? Well, you can also use a combination of both named and unnamed fields:

(int, double, {String string}) record = (1, string: 'a', 2.0);

Furthermore, you can also destructure the record all in one line:

final (number, string, float) = record;

To learn more you can read the full Records Feature Specification

Using records in our app

We use the new record type to construct a Card as a combination of Suit and Rank:

card.dart
typedef Card = (Suit, Rank);

This new () syntax defines a Record (a Tuple in this case, since it is composed by 2 types). Before dart 3.0 this was not possible ☝️.

Note: typedef allows to give another name to a type, using Card instead of writing (Suit, Rank) every time.

Read more about typedef in the dart documentation.

This allows to avoid creating a class for simple types. Before dart 3.0, the same result required a class:

/// Before dart 3.0
class Card {
  final Suit suit;
  final Rank rank;

  const Card(this.suit, this.rank);
}

Using this new model (sealed and typedef record) we can construct our 52 cards deck:

deck.dart
const List<Card> deck = [
  // Club
  (Club(), Ace()),
  (Club(), Two()),
  (Club(), Three()),
  (Club(), Four()),
  (Club(), Five()),
  (Club(), Six()),
  (Club(), Seven()),
  (Club(), Eight()),
  (Club(), Nine()),
  (Club(), Ten()),
  (Club(), Jack()),
  (Club(), Queen()),
  (Club(), King()),

  // Diamond
  (Diamond(), Ace()),
  (Diamond(), Two()),
  (Diamond(), Three()),
  (Diamond(), Four()),
  (Diamond(), Five()),
  (Diamond(), Six()),
  (Diamond(), Seven()),
  (Diamond(), Eight()),
  (Diamond(), Nine()),
  (Diamond(), Ten()),
  (Diamond(), Jack()),
  (Diamond(), Queen()),
  (Diamond(), King()),

  // Heart
  (Heart(), Ace()),
  (Heart(), Two()),
  (Heart(), Three()),
  (Heart(), Four()),
  (Heart(), Five()),
  (Heart(), Six()),
  (Heart(), Seven()),
  (Heart(), Eight()),
  (Heart(), Nine()),
  (Heart(), Ten()),
  (Heart(), Jack()),
  (Heart(), Queen()),
  (Heart(), King()),

  // Spade
  (Spade(), Ace()),
  (Spade(), Two()),
  (Spade(), Three()),
  (Spade(), Four()),
  (Spade(), Five()),
  (Spade(), Six()),
  (Spade(), Seven()),
  (Spade(), Eight()),
  (Spade(), Nine()),
  (Spade(), Ten()),
  (Spade(), Jack()),
  (Spade(), Queen()),
  (Spade(), King()),
];

Finally, we can construct a Deck class containing those 52 cards:

deck.dart
class Deck {
  final List<Card> cards;
  const Deck._(this.cards);

  /// Initialize a deck with the 52 cards `List<Card>` defined above ☝️
  factory Deck.init() => Deck._(deck);

  @override
  String toString() => cards.join("\n");
}

Multiple return values using Records

Another feature unlocked by Records is returning multiple values.

A function can now return a Record, which gives access to 2 or more values.

For example, we want to add a function that extracts 1 card from Deck. This requires returning 2 values:

  1. The Card extracted from Deck
  2. A new instance of Deck without the extracted card

This is now possible using records:

deck.dart
class Deck {
  final List<Card> cards;
  const Deck._(this.cards);

  factory Deck.init() => Deck._(deck);

  /// Return 2 values: the extracted card and a new deck without that card
  (Card, Deck) get pickCard {
    final cardIndex = Random().nextInt(cards.length);
    return (
      cards[cardIndex],
      Deck._([
        ...cards.sublist(0, cardIndex),
        ...cards.sublist(cardIndex + 1),
      ])
    );
  }
}

Before Dart 3 this required creating another new class:

deck.dart
class PickCard {
  final Card card;
  final Deck deck;
  const PickCard(this.card, this.deck);
}

class Deck {
  final List<Card> cards;
  const Deck._(this.cards);

  factory Deck.init() => Deck._(_deck);

  PickCard get pickCard {
    final cardIndex = Random().nextInt(cards.length);
    return PickCard(
      cards[cardIndex],
      Deck._([
        ...cards.sublist(0, cardIndex),
        ...cards.sublist(cardIndex + 1),
      ]),
    );
  }
}

Destructuring Records

Dart 3 also allows destructuring values.

It is now possible to assign the result of calling pickCard directly to a variable (also called binding):

final (card, deck) = pickCard;

The code above defines 2 new variables, card and deck, that contain the result of calling pickCard, all in one line!

Before Dart 3 this process was a lot more verbose:

final cardAndDeck = pickCard;
final deck = cardAndDeck.deck;
final card = cardAndDeck.card;

Switch expression and destructuring

There is even more! It is now possible to use switch as an expression (and not simply a statement).

In practice this means that you can assign the result of calling switch directly to a variable.

Furthermore, destructuring is also possible in switch cases, so you can pattern match and extract a value all in one line.

Adding a Hand of cards

As an example, let's suppose we want to assign up to 3 cards to a user. We introduce a Hand class which can be in 4 states:

  • ThreeCards: the user has 3 cards in his hand
  • TwoCards: the user has 2 cards in his hand
  • OneCard: the user has 1 card in his hand
  • NoCard: the user has no cards in his hand

Since we can list all the possible cases, this is again a perfect usecase for a sealed class:

hand.dart
sealed class Hand {
  const Hand();
}

class ThreeCards extends Hand {
  final (Card, Card, Card) cards;
  const ThreeCards(this.cards);
}

class TwoCards extends Hand {
  final (Card, Card) cards;
  const TwoCards(this.cards);
}

class OneCard extends Hand {
  final Card card;
  const OneCard(this.card);
}

class NoCard extends Hand {}

Destructuring a class using pattern matching

We can now use pattern matching to extract the cards from a Hand.

We use the new switch expression syntax as well as record destructuring:

String howManyCards = switch (hand) {
  /// Match `ThreeCards` and extract each card (`card1`, `card2`, `card3`)
  ThreeCards(cards: (final card1, final card2, final card3)) => "3 cards: $card1, $card2, $card3",

  /// Match `TwoCards` and extract each card (`card1`, `card2`)
  TwoCards(cards: (final card1, final card2)) => "2 cards: $card1, $card2",

  /// Match `OneCard` and extract it (`singleCard`)
  OneCard(card: final singleCard) => "1 card: $singleCard",

  /// Match `NoCard` (all cases are matched βœ…)
  NoCard() => "Ain't no card πŸ™Œ",
};
  • howManyCards is a new variable containing the result of the switch expression (no more a simple statement ☝️)
  • switch uses pattern matching to check that all possible cases are covered (compile-time error otherwise) and extract the values from each subtype
  • In each pattern, we extract the cards/card value and destructure it (assigning these values to new variables card1, card2, card3, singleCard)

Enhanced if statements

There is even more!

A new syntax allows to also use pattern matching in if statements:

/// Pattern match and destructure directly inside `if` cases πŸš€
if (hand case OneCard(card: final singleCard)) {
  print("1 card: $singleCard");
}

There is more 🀩

Timeless coding principles, practices, and tools that make a difference, regardless of your language or framework, delivered in your inbox every week.

Many more features for pattern matching and records

An that's not even all! Pattern matching and Records in dart 3 are super powerful: here are some more examples of what is possible with them.

You can match any value with the wildcard _. This is useful since it acts as an "otherwise"/"in all other cases":

bool doWeHaveCards = switch (hand) {
  NoCard() => false,
  _ => true
};

You can use logical operators with pattern matching (|| or, && and):

enum ScreenSize { small, medium, large, extraLarge }

final device = switch (size) {
  ScreenSize.small || ScreenSize.medium => "Mobile",
  ScreenSize.large || ScreenSize.extraLarge => "Desktop",
};

You can add when in pattern matching to check also the value of a variable (and not only the type). This is called a Guard:

sealed class Result {}
class Error extends Result {}
class Success extends Result {
  final String value;
  Success(this.value);
}

final emoji = switch(result) {
  /// Check both type (`Success`) and `value`
  Success(value: final successValue) when successValue == "okay" => "πŸ‘Œ",

  Error() => "⛔️",
  
  _ => "πŸ€·πŸΌβ€β™‚οΈ"
};

You can use Relational Patterns to match any value using equality and relational operators (==, !=, <, >, <=, >=):

final dimension = switch(screen) {
  < 400 => "small",
  >= 400 && < 1000 => "medium",
  _ => "large"
};

You can match a non-nullable value in a pattern using ?:

final message = switch (maybeString) {
  final notNullString? => notNullString,
  _ => "No string here πŸ€·πŸΌβ€β™‚οΈ"
};

You can assign names to record values:

const (String name, int age) person1 = ('John doe', 32);
print('${person1.$1}, ${person1.$2}');

const ({String name, int age}) person2 = (name: 'John doe', age: 32);
print('${person2.name}, ${person2.age}');

You can read the Patterns Feature Specification to learn all the details


This feature is going to radically change how you implement apps in dart (and Flutter).

This is just the tip of the iceberg. We will go deeper in future articles, exploring real-world examples of apps using all these features to write robust and type-safe code.

If you learned something from this post, you can follow @SandroMaglione on Twitter and subscribe to my newsletter here below πŸ‘‡

Thanks for reading.

πŸ‘‹γƒ»Interested in learning more, every week?

Timeless coding principles, practices, and tools that make a difference, regardless of your language or framework, delivered in your inbox every week.