What’s New in Dart 3: Introduction

Dart 3 has been described as the largest Dart release to date.

This release introduces some major features such as:

  • patterns and records
  • enhanced switch and if-case statements
  • destructuring
  • sealed classes and other class modifiers

These features were first announced at Flutter Forward, and I am so happy that we can use them now that Flutter 3.10 and Dart 3.0 have been officially released.

In practice, Dart 3.0 allows us to write more expressive and elegant code, but there is a bit of a learning curve if you want to make the most of it.

And since there is so much to cover, I’ve decided to write a new series of articles showing how to use each feature in detail, along with examples and practical use cases that you may encounter in the real world.

And as far as this article goes, I want to give you a little taste of what Dart 3.0 has to offer.

In other words, this article is the appetizer. And the upcoming ones will be the main course (and dessert 🍰).

Ready? Let’s go! 🎯3️⃣

Enabling Dart 3 in your Flutter project

To use the new features, you'll need to enable Dart 3.0 in your pubspec.yaml file:

environment: sdk: '>=3.0.0 <4.0.0'

Dart 3.0 is enabled by default when you create a new project with Flutter 3.10.

With that taken care of, let's learn about the new language features. 🔥

Introduction to Records in Dart 3

Records are very versatile and can be used in many different scenarios.

For example, imagine you’re making a network request and getting some JSON data which looks like this:

final json = {'name': 'Andrea', 'age': 38, 'height': 184};

If you use the json variable as is, you lose type safety because Dart will infer it to be a Map<String, dynamic>.

To improve things, you can declare a Person class and implement the deserialization logic inside a factory constructor:

class Person { const Person({required this.name, required this.age, required this.height}); final String name; final int age; final int height; factory Person.fromJson(Map<String, dynamic> json) { // * error handling code missing for simplicity return Person( name: json['name'], age: json['age'], height: json['height'], ); } }

This works but it’s a bit verbose.

With Dart 3.0, there is a quicker way and you can use a record type to represent your data. This allows you to create functions with multiple return values:

(String, int, int) getPerson(Map<String, dynamic> json) { return ( json['name'], json['age'], json['height'], ); }

You can then assign the result to a record variable of type (String, int, int) and access each field like this:

final person = getPerson(json); print(person.$1); // 'Andrea' print(person.$2); // 38 print(person.$3); // 184

Alternatively, you can destructure the returned value like this:

final (name, age, height) = getPerson(json); print(name); // 'Andrea' print(age); // 38 print(height); // 184

But there’s more. 👇

Positional vs named arguments

In the example above, the record type only used positional arguments (in other languages, this is known as a tuple).

But Dart records can use named arguments too.

This means that if we wish, we can change the return type of our function:

// same example as above, now using named arguments ({String name, int age, int height}) getPerson(Map<String, dynamic> json) { return ( name: json['name'], age: json['age'], height: json['height'], ); }

As a result, we can obtain and print the record values like this:

final person = getPerson(json); print(person.name); // 'Andrea' print(person.age); // 38 print(person.height); // 184

And here’s the equivalent code using destructuring:

// * we'll cover the : syntax in a follow up article final (:name, :age, :height) = getPerson(json); print(name); // 'Andrea' print(age); // 38 print(height); // 184

Overall, think of records as a lightweight alternative to classes.

Records are “more” type safe than Map or List because they let you specify how many values there are, along with their type.

They support both named and positional arguments (or a combination of both), just like regular function arguments.

And as we have seen, you can use destructuring to simplify your code even more.

But if you want to use records correctly and prevent syntax errors, there are some rules to follow. So I’ll cover them in more detail in an upcoming article.

Introduction to Switch Expressions and Patterns

Suppose we’re writing a 2D game where the player can move up, down, left, and right.

This is easily represented as an enum:

enum Move { up, down, left, right }

Now, suppose we want to calculate certain offset (as a pair of x-y coordinates) depending on the current move.

Before Dart 3, we may have implemented the required code like this:

enum Move { up, down, left, right; Offset get offset { switch (this) { case up: return const Offset(0.0, 1.0); case down: return const Offset(0.0, -1.0); case left: return const Offset(-1.0, 0.0); case right: return const Offset(1.0, 0.0); } } }

All those case and return keywords sure make my eyes tired. 🥱

But with Dart 3, we get switch expressions, and we can rewrite the code above in a much more concise way:

enum Move { up, down, left, right; Offset get offset => switch (this) { up => const Offset(0.0, 1.0), down => const Offset(0.0, -1.0), left => const Offset(-1.0, 0.0), right => const Offset(1.0, 0.0), }; }

Wanna get the movement on the x-axis only? Then leverage pattern matching by using logical operators inside the switch:

double get xAxisMovement => switch (this) { up || down => 0.0, // logical OR operator with pattern matching left => -1.0, right => 1.0, };

You can do so much more with switch expressions in Dart 3.

So I’ll be covering them in detail in a separate article. 👍

Introduction to Sealed Classes

Sealed classes help you check for exhaustiveness so that you can handle all possible cases.

This is particularly important when handling exceptions in your code.

For example, you could use sealed classes to define all possible auth exceptions that could be returned by your backend:

sealed class AuthException implements Exception {} class EmailAlreadyInUseException extends AuthException { EmailAlreadyInUseException(this.email); final String email; } class WeakPasswordException extends AuthException {} class WrongPasswordException extends AuthException {} class UserNotFoundException extends AuthException {}

This lets you handle each possible exception type with a switch expression:

String describe(AuthException exception) { return switch (exception) { EmailAlreadyInUseException(email: final email) => 'Email already in use: $email', WeakPasswordException() => 'Password is too weak', WrongPasswordException() => 'Wrong password', UserNotFoundException() => 'User not found', }; }

The code above only works because the AuthException class is sealed.

If it weren’t, we would get a non_exhaustive_switch_expression error:

Switch expression will generate a non_exhaustive_switch_expression error if the base class is not sealed
Switch expression will generate a non_exhaustive_switch_expression error if the base class is not sealed

But if we mark the base class as sealed, the compiler knows that we’ve handled all possible cases.

Declaring a sealed class has two important implications:

  • The class becomes abstract (you can’t create a concrete instance of it)
  • All subclasses must be defined in the same library (file).

There is more to sealed classes than what I have presented here, so stay tuned for a more in-depth article about them. 🙂

Introduction to Class Modifiers in Dart 3

Before Dart 3, only two class modifiers were available: abstract and mixin.

But with the new release, we get six:

  • abstract
  • base
  • final
  • interface
  • sealed
  • mixin

Conveniently, the Dart website has a new page explaining how all these modifiers work:

I may cover these modifiers more in detail in the future, but for now, the official page is a good starting point.

Dart 3: Next Steps

With this article, I wanted to give you a glimpse of what you can do with Dart 3.

Hopefully, I’ve inspired you to try out the new language features in your projects.

But there is a lot more to unpack, including if-case statements, how to do pattern matching with nullable values, and other advanced use cases. And unless you have a good grasp of how they work, you may struggle.

For this reason, I’ll publish a whole series of articles diving deeper into all the new language features.

These articles will give you more confidence and a solid understanding of how to use Dart 3 effectively in your own apps.

If you don’t want to miss them, sign up for my newsletter. 👇

Want More?

Invest in yourself with my high-quality Flutter courses.

Flutter In Production

Flutter In Production

Learn about flavors, environments, error monitoring, analytics, release management, CI/CD, and finally ship your Flutter apps to the stores. 🚀

Flutter Foundations Course

Flutter Foundations Course

Learn about State Management, App Architecture, Navigation, Testing, and much more by building a Flutter eCommerce app on iOS, Android, and web.

Flutter & Firebase Masterclass

Flutter & Firebase Masterclass

Learn about Firebase Auth, Cloud Firestore, Cloud Functions, Stripe payments, and much more by building a full-stack eCommerce app with Flutter & Firebase.

The Complete Dart Developer Guide

The Complete Dart Developer Guide

Learn Dart Programming in depth. Includes: basic to advanced topics, exercises, and projects. Last updated to Dart 2.15.

Flutter Animations Masterclass

Flutter Animations Masterclass

Master Flutter animations and build a completely custom habit tracking application.