What is new in Dart 3? Exploring the new features: Switch Expressions, Patterns, Records, Class Modifiers, and More

Dart has experienced significant improvements and innovations with the release of its version 3. This update brings features and functionalities that make Dart a more efficient, modern, and user-friendly language for web and mobile application development.

Dart 3 was introduced by Google during Google I/O 2023 and was announced as the largest release to date.

100% Sound Null Safety

Starting from Dart 2.12, a new feature called null safety was introduced. This feature aims to improve code safety and stability by providing stronger null checks. If you attempt to assign a null value to a non-nullable variable, the compiler will generate a compile-time error. This helps reduce the possibility of null-related errors and enhances code robustness.

In Dart 3, the language is 100% sound null safety.

Records

A new feature in Dart 3 is Records, which allows a single object to contain multiple objects. One use case is when we want to return two or more values from a function.

Previously, when we wanted to return more than one object from a function, we had to create an extra class or use a package like Tuple.

The following code snippet shows that to return the age and name from a function, we had to create a class called User:

// Example without using records
void main() {
  final user = getUserInfo();

  print('Age: ${user.age}');
  print('Name: ${user.name}');
}

User getUserInfo() {
  return User(18, 'Yayo');
}

class User {
  final int age;
  final String name;

  User(this.age, this.name);
}

Now, let's see the difference using records:

// Example using records
void main() {
  final user = getUserInfo();

  print('Age: ${user.$1}');
  print('Name: ${user.$2}');
}

(int, String) getUserInfo() {
  return (18, 'Yayo');
}

We can see that we have saved a few lines of code using records, and there is no need to create the User class anymore.

Patterns

Let's go back and take a look at the previous example. A problem with using records is that without creating the User class, we would need to write user.$1 instead of user.name to access the name, which makes the code harder to understand. However, we can write code that is easy to comprehend using patterns.

Using patterns, we can improve the code readability in the previous example:

void main() {
  final user = getUserInfo();

  print('Age: ${user.age}');
  print('Name: ${user.name}');
}

({int age, String name}) getUserInfo() {
  return (age: 12, name: 'Yayo');
}

We can see that by using records and patterns, we can have more readable code without creating a User class and use user.name and user.age.

Patterns: Destructuring

We can even use destructuring, which is a technique in patterns to extract and assign values from a data structure to individual variables concisely and efficiently. The code would look like this:

void main() {
  final (:age, :name) = getUserInfo();

  print('Age: $age');
  print('Name: $name');
}

({int age, String name}) getUserInfo() {
  return (age: 12, name: 'Yayo');
}

Patterns: Pattern Matching

Pattern matching is a technique that allows you to check if a value matches a specific pattern and perform actions based on that. It performs more complex and flexible comparisons than simple equalities or inequalities.

In the following code snippet, we can see that now we can use conditions within a switch:

void main() {
  int number = 15;

  switch (number) {
    case 1:
      print('The number is 1');

    case > 1 && < 20:
      print('The number is greater than 10 and less than 20');

    case > 20:
      print('The number is greater than 20');
  }
}

We can also use patterns to check if case statements in a switch match a collection:

void main() {
  final users = ['Yayo', 'Carlos'];

  switch (users) {
    case ['Yayo', 'Carlos']:
      print('The list contains Yayo and Carlos');

    case ['Diego']:
      print('The list contains Diego');

    case ['Diana']:
      print('The list contains Diana');
  }
}

Validating JSON with Patterns

We can use patterns to validate a JSON and extract its values. Let's suppose we have the following JSON:

var json = {
  'user': ['Lily', 13]
};

var {'user': [name, age]} = json;

To avoid runtime errors, we need to perform several validations, such as checking if the data type is correct, if the JSON is not empty, etc. The code without using patterns would look like this:

// Example without using patterns
if (json is Map<String, Object?> &&
    json.length == 1 &&
    json.containsKey('user')) {
  var user = json['user'];
  if (user is List<Object> &&
      user.length == 2 &&
      user[0] is String &&
      user[1] is int) {
    var name = user[0] as String;
    var age = user[1] as int;
    print('User $name is $age years old.');
  }
}

But if we use patterns, we can validate the JSON in just three lines of code:

if (json case {'user': [String name, int age]}) {
  print('User $name is $age years old.');
}

All the necessary validations are performed within the if-case statement:

  • json is a map, because it must first match the outer map pattern to proceed.
    • And, since it’s a map, it also confirms json is not null.
  • json contains a key user.
  • The key user pairs with a list of two values.
  • The types of the list values are String and int.
  • The new local variables to hold the values are String and int.

Switch Expression

We are familiar with the switch case structure that helps us assign or execute specific logic based on the evaluated comparison result.

Let's see a simple example. Assuming that Monday is the first day of the week and is represented by the number one, Tuesday is the second day, represented by the number two, and so on until Sunday. Given an integer, we want to print the corresponding day. Using the classic switch case, the code would look like this:

// Example using the classic switch case
void main() {
  final currentDay = getDay(5);
  print('Today is $currentDay'); // Today is Friday
}

String getDay(int day) {
  switch (day) {
    case 1:
      return 'Monday';
    case 2:
      return 'Tuesday';
    case 3:
      return 'Wednesday';
    case 4:
      return 'Thursday';
    case 5:
      return 'Friday';
    case 6:
      return 'Saturday';
    case 7:
      return 'Sunday';
    default:
      return 'Invalid day';
  }
}

We can see that we had to write case and return multiple times. But with Dart 3 and switch expressions, we can make the code more concise and easier to read:

// Example using the new switch expression
void main() {
  final currentDay = getDay(5);
  print('Today is $currentDay'); // Today is Friday
}

String getDay(int day) {
  return switch (day) {
    1 => 'Monday',
    2 => 'Tuesday',
    3 => 'Wednesday',
    4 => 'Thursday',
    5 => 'Friday',
    6 => 'Saturday',
    7 => 'Sunday',
    _ => 'Invalid day',
  };
}

In this example, the expression day is evaluated and compared with different cases using the => syntax. If the value of day is 1, the result is 'Monday'. The underscore _ is used as a default case for any other value that doesn't match the previous cases.

And if we want to determine whether it's a "weekday" or a "weekend" day, we can write the following code:

void main() {
  final currentDay = getDay(4);
  print('Today is $currentDay'); // Weekday
}

String getDay(int day) {
  return switch (day) {
    >= 1 && <= 5 => 'Weekday',
    6 || 7 => 'Weekend',
    _ => 'Invalid day',
  };
}

Sealed Classes

In Dart 3, the keyword used to define a sealed class is sealed. Like in other programming languages, a sealed class in Dart is a class that cannot be inherited by other classes outside its declaration. This means that subclasses of the sealed class must be declared in the same file.

Sealed classes in Dart are often used in combination with switch expressions. The switch structure can exhaustively use the subclasses of a sealed class, ensuring that all possible cases are handled.

Here's an example of using a sealed class in a switch expression:

void main() {
  final result = evaluate(Success());
  print(result); // Success: Request successful
}

String evaluate(Result result) {
  return switch (result) {
    Success(message: final message) => 'Success: $message',
    Error(message: final message) => 'Error: $message',
  };
}

sealed class Result {
  final String message;

  Result(this.message);
}

class Success extends Result {
  Success() : super('Request successful');
}

class Error extends Result {
  Error() : super('Request unsuccessful');
}

Remember that

The above example works because the Result class is sealed. If it weren't sealed, we would get a non_exhaustive_switch_expression error.

Class Modifiers

Class modifiers are reserved words used to control the visibility and behavior of a class.

In Dart 3, the following class modifiers have been added:

  • base
  • final
  • interface
  • sealed

We won't go into detail about each of them in this article, but you can visit the documentation to learn more about how they work.

Conclusion

Dart 3 has introduced several features that make the lives of programmers much easier by making Dart a more expressive language.

Personally, the best additions are patterns and records because now I can do more with much less code.

I hope you enjoyed this article, and if you have any questions, don't forget to follow me on my social media.

Share this article