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
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
:
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 calledAce
) up to 10,Jack
(11),Queen
(12), andKing
(13) -
The
Suit
s instead areClub
(β£),Diamond
(β¦),Heart
(β₯), andSpade
(β )
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.
sealed class Suit {}
class Club extends Suit {}
class Diamond extends Suit {}
class Heart extends Suit {}
class Spade extends Suit {}
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:
- The class is
abstract
(you cannot create a concrete instance ofSuit
) - All subtypes must be defined in the same library
Sealed classes before dart 3
In dart 2 you use abstract
instead of sealed
:
/// 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
.
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:
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
):
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
):
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
:
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, usingCard
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:
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:
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:
- The
Card
extracted fromDeck
- A new instance of
Deck
without the extracted card
This is now possible using records:
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:
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 handTwoCards
: the user has 2 cards in his handOneCard
: the user has 1 card in his handNoCard
: 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:
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 theswitch
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 variablescard1
,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.