Dart 3 in Practice: What the Major Language Update Will Bring Us

Dart 3 in Practice: What the Major Language Update Will Bring Us
27 min read

Dart 3 is the most significant language update since Null Safety, and it will change a lot about how we write code and what libraries we use. You can listen to a podcast to understand what exactly is changing in the language.

Records, Anonymous Data Structures

How often have you had to combine two dissimilar structures into a separate class with a name like UserAndCar or CutletWithFlies just to return them together from a single function? Records came to fix this situation.

Records are a simple way to combine several types into one data structure. At the same time, the combining structure remains anonymous, or unnamed. Let's consider an example where we need to return two values at once from the mixCutletsAndFlies function:

class Cutlet {}

class Fly {}

class CutletAndFly {
  final Cutlet cutlet;
  final Fly fly;
  
  const CutletAndFly(this.cutlet, this.fly);
}

CutletAndFly mixCutletsAndFlies() => CutletAndFly(Cutlet(), Fly());

To return two values from a function in the usual way, you need to create a separate class and also name it. If everything happens inside the class and does not go beyond its limits, naming becomes meaningless. Moreover, in the class name, we simply repeat the name of the mixed classes. If you have to return three or four values, the class name becomes completely absurd: CutletAndFlyAndAppleAndOrange.

For this case, there is a quite universal solution: the tuple package from Google. But now there will be no need for it, because in Dart 3 you can write it like this:

(Cutlet, Fly) mixCutletsAndFlies() => (Cutlet(), Fly());

To extract a value from such a structure, you can do it this way:

void main() {
  final cutletsAndFlies = mixCutletsAndFlies();
  final cutlet = cutletsAndFlies.$1;
  final fly = cutletsAndFlies.$2;
}

Or more elegant way:

void main() {
  final (cutlet, fly) = mixCutletsAndFlies();
}

The way of defining variables from the last example is called destructuring. It is already available in many other programming languages. For example, in JavaScript, you can destructure not only analogs of Records, but also full-fledged named data structures. There is also an equivalent in Kotlin. In the Swift language, there is already a full-fledged equivalent of Records - tuples (yes, the name is exactly the same as that of the Google library).

Greetings to our colleagues from React Native: in Dart, it is now possible to implement a full-fledged syntactic equivalent of Hook.

(T, void Function(T)) useState<T>(T state) {
 return (state, (T newState) => state = newState);
}

void main() {
 var (state, setState) = useState(0);
 print(state);
 setState(1);
 print(state);
}

The Flutter framework has its own implementation of hooks, which, apparently, will also switch to the new syntax.

In addition, you can also specify named parameters in a record, and it works roughly the same way as in a class constructor:

(String, {double age}) getPerson() {
 return ("Mark", age: 24);
}

Small change in the type system

Your chances of getting a tricky question during an interview have doubled, as now Record is a supertype of Never and a subtype of Object and dynamic. That is, the type Record behaves roughly the same way as Function in the Dart type system.

More limitations in OOP

Dart is a very liberal language in terms of limitations on the use of OOP. Those who switch to Dart from languages like Kotlin and Swift may be surprised, for example, by the absence of advanced access modifiers (private, public, protected) or the fact that absolutely any class can be turned into an interface - this has literally become a hallmark of Dart.

All this speaks not so much of the flexibility of Dart as of the permissiveness, which is often incompatible with "adult" production development or the development of large libraries. To fix this, Dart has added several new features for working with classes. And it's not as easy as it might seem at first glance.

Real contracts

Very often at Surf, we use contracts - also known as interfaces or protocols. Usually, it looks like an abstract class without a base implementation, to which we add the prefix I - Interface:

abstract class ICubeMenuWm extends IWidgetModel {
 StateStreamable<AuthStatusState> get authStatusState;

 void onSideTap(int index);

 void onKeyPreesed();
}

Actually, contracts play the role of header files in C. It is a separate structure with a description of the parameters of the class with which you can interact "from the outside". They hide the implementation and make the user of the class look only at those components that are really necessary.

In other mobile development languages - Kotlin and Swift - there are full-fledged contracts. In Swift, the interface is named protocol and is not related to the usual structure of classes at all. It is a separate entity with its own syntax:

protocol Person { 
  var name: String { get }
}

Also, in Swift, there is an incredible composition of protocols, which, understandably, was not imported into Dart. But let's not dwell on sad things.

In Kotlin, the keyword interface is used for contracts:

interface MyInterface {
    fun bar()
    fun foo() {
      // optional body
    }
}

In addition to interfaces, Kotlin also has abstract classes that work similarly to abstract classes in Dart.

So, in Dart 3, the keyword interface also appears, but it is not independent. This keyword is used as a modifier to the class. Of course, looking at Kotlin and Swift, one might assume that an interface class is now a full-fledged contract. But that's not the case:

interface class Contract {
 /// error: 'name' must have a method body because 'Contract' isn't abstract.
 String get name;
}

Trying to use an interface class as an abstract one results in an analyzer error: the Contract class did not become abstract after adding the interface. The point is that interface is a modifier to a class, which only indicates that outside of the current file it can only be implemented (implemented) and cannot be inherited (extended).

Moreover, the dissonance may arise from the fact that for an interface class, you can create a constructor and initialize it:

interface class Contract {
 final String name;
 Contract(this.name);
}

void test() {
 final contract = Contract('John Pork');
 print(contract.name); // print: John Pork
}

Real contracts have indeed appeared in Dart 3. But unlike Kotlin and Swift, it is not enough to just write "interface". The thing is that class modifiers can be mixed together. By mixing the abstract modifier, which prohibits class instantiation, and the interface modifier, which prohibits inheritance, we get a full-fledged real contract. Therefore, contracts in Dart code will look like this:

abstract interface class Contract {
 abstract final String name;
 void foo();
}

You may ask a valid question: "How does the non-inheritance of final and the impossibility of instantiation of abstract combine?" The fact is that modifiers in general only make sense outside the current scope of visibility. In other words, outside of the file.

Thus, you will be able to compile such code:

abstract final interface class Contract {
 abstract final String name;
 void foo();
}

abstract final class ContractChild extends Contract {}

The final modifier

Now the declaration of classes resembles the "concise" public static void main in Java. The interface from the previous example can now also be made non-inheritable with the final modifier:

abstract final interface class Contract {
 abstract final String name;
}

But if you try to do it in another file, you get two errors at once:

The class 'Contract' can't be extended outside of its library because it's an interface 
The class 'Contract' can't be extended outside of its library because it's a final class.

Adding the final modifier solves the "fragile base class" problem in Dart. This gives architecture designers more tools to safeguard their implementations from unintentional breakage due to improper inheritance.

In languages that already have similar restrictions, it is customary to make all classes final by default and only open the ones that are truly necessary. In Kotlin, for example, all classes are closed for inheritance by default, and they can be opened by adding the open keyword.

It will be extremely difficult to enforce this in Dart since it is unlikely that a linter rule can be created to regularly remind developers to set the final modifier for all classes.

This is the base: interface inversion For an interface, there is a "reverse operation" in the form of the base keyword. If an interface prohibits inheritance and allows only implementation, then base is the opposite.

Firstly, it will not be possible to create a class without the base modifier if it inherits from a base class:

// file lib_based.dart

base class Based {}

// file lib_cringe.dart

/// error: The type 'Cringe' must be 'base', 'final' or 'sealed' because the supertype 'Based' is 'base'.
class Cringe extends Based {}

Second, you can't implement a base class outside of the file:

// error: The class 'Based' can't be implemented outside of its library because it's a base class.
final class Cringe implements Based {}

It can be officially declared that Dart is no longer a language in which any class can be represented as abstract.

Mixins: Shake, but don't mix Changes have also affected mixins. If you have been writing Dart code for a while, you know that there is a linter rule from the team that prohibits using regular classes as mixins. And here we encounter the first breaking change in Dart 3. Regular classes can no longer be mixed in!

Interestingly, in Flutter there is a class called WidgetsBindingObserver, which is officially recommended in the documentation to be used as a mixin. In Flutter 3.10, it was transformed into an abstract mixin class: "

abstract mixin class WidgetsBindingObserver {

In fact, this is the main difference between a regular mixin and a class mixin. Unlike a regular mixin, a class can be inherited and a constructor can be specified inside. Thus, the mixin class receives the grand prize as the most flexible class in the new Dart 3 system: you can do whatever you want with it.

Modifier combinations: Learning a new multiplication table

How many possible combinations of modifiers are there? Judging by the table provided by the authors of the feature, there are 15.

Declaration Construct? Extend? Implement? Mix in? Exhaustive?
class Yes Yes Yes No No
base class Yes Yes No No No
interface class Yes No Yes No No
final class Yes No No No No
sealed class No No No No Yes
abstract class No Yes Yes No No
abstract base class No Yes No No No
abstract interface class No No Yes No No
abstract final class No No No No No
mixin class Yes Yes Yes Yes No
base mixin class Yes Yes No Yes No
abstract mixin class No Yes Yes Yes No
abstract base mixin class No Yes No Yes No
mixin No No Yes Yes No
base mixin No No No Yes No
This is not all possible ways to write a class, but all the variants that make sense. Perhaps with new versions we will see linter rules that prohibit the use of incompatible modifiers, but for now it is allowed.

Note that the usual "old" class has become effectively dynamic, if we make an analogy with declaring variables in Dart. This means that the new modifier system will be fully compatible with old code, except for mixins, which we discussed earlier. For example, new abstract interface classes can be inherited from ordinary classes.

Long-awaited sealed

This feature has been in the works for years in Dart. I first tried Dart in 2019 and immediately thought of two things. First, where is Null Safety (it arrived in 2021)? Second, where are sealed classes and why is enum so useless?

There is also a significant demand for sealed classes in the community. The freezed library, which is a very neat implementation of sealed classes, called unions there, has gathered 2700 likes on pub.dev. For comparison, the most popular implementation of bloc in the community has 2200 likes.

The simplest explanation of how sealed classes work is that they are a mix of enum and regular class. The subclasses of a sealed class, like enums, have a limited number of values. At the same time, the values are descendants of the class. In Dart 3, the keyword sealed class appeared.

The simplest example is getting a result. Imagine you need to get a result from a function: success, loading or error. Well, for this you can use a regular enum:

enum Result { success, loading, error }

With such an enum, you can return information about the result, but you cannot append additional data to it. For example, return a message to success or add a specific error code to error. Then you can use regular classes instead of enum:

abstract class Result {}

class Success implements Result {
 final String message;
 Success(this.message);
}

class Loading implements Result {}

class Error implements Result {
 final Exception exception;
 Error(this.exception);
}

This is a good way, but it has several problems:

At the time we receive the Result, as a user, we have no guarantee that Result only has three subclasses.

The analyzer also doesn't know how many subclasses Result can have.

We can't protect ourselves from the appearance of a new Result subclass at compile time. We can only check at runtime by throwing an exception.

Unlike enums, we don't have a convenient way to iterate through all the values. The only option is to use if statements.

These problems are solved by the appearance of a sealed class:

sealed class Result {}

class Success implements Result {
 final String message;
 Success(this.message);
}

class Loading implements Result {}

class Error implements Result {
 final Exception exception;
 Error(this.exception);
}

Now it will be impossible to add new descendants outside the file where the sealed class was declared. This gives a clear guarantee that Result will have a limited number of values. However, the Result itself cannot act as a value, because sealed is implicitly abstract.

The new switch and "completeness"

The main difference between switch and the usual if-else is the ability to pass through all possible values of an object. For example, all values of an enum:

switch (result) {
     case Result.success:
       print('success');
       break;
     case Result.loading:
       print('loading');
       break;
     case Result.error:
       print('error');
       break;
   }

This may come as a revelation to some, but the same thing works with boolean.

This property of bool-values and enum is called completeness, or exhaustiveness. What is new in Dart 3 is that in addition to the types described above, the int or sealed classes, for example, also have exhaustiveness.

Let's take an example with the sealed class Result, which I described above:

sealed class Result {}

void test(Result result) {
 switch (result) {
   case Success():
     print('Success');
     break;
   case Loading():
     print('Loading');
     break;
   case Error():
     print('Error');
     break;
 }
}

First, we do not need to specify a default-value for the parser to understand that we have covered all values. Second, you can see that now you can work with types in switch, which previously could only be done with an if-else construct. Among other things, you can now "pull" class values in each case:

void test(Result result) {
 switch (result) {
   case Success(message: var message):
     print(message);
     break;
   case Loading():
     print('Loading');
     break;
   case Error(exception: var exception):
     print(exception);
     break;
 }
}

Suppose we don't want to print the value, but rather return it from a function. How would we have done it before:

String getStringResult(Result result) {
 switch (result) {
   case Success(message: var message):
     return message;
   case Loading():
     return 'Loading';
   case Error(exception: var exception):
     return exception.toString();
 }
}

It already looks like a pretty concise entry, but you could make it even better:

String getResultInString(Result result) => switch (result) {
     Success success => success.message,
     Loading _ => 'Loading',
     Error error => error.exception.toString(),
   };

The point is that the switch construct used to be a statement, like if-else or while. It could not be returned from a function. Now the switch construct can be used as an expression, and it can be written in its entirety after return.

Let's go back to the "completeness" of sealed classes and the main difference between using them and using abstract. If we replace a sealed class with an abstract one, we get an error:

abstract class Result {}

// error: The type 'Result' is not exhaustively matched by the switch cases since it doesn't match 'Result()'.
String getResultInString(Result result) => switch (result) {
     Success success => success.message,
     Loading _ => 'Loading',
     Error error => error.exception.toString(),
   };

The error indicates that the analyzer understands that the abstract class Result can have an unlimited number of representations. So you need to add the default value here, which in the new writing will look like this

_ => 'Default',

Patterns

No, this isn't about design patterns; it's another advanced syntax that not only affects switch, but many other aspects of the language as well. Patterns are probably the most difficult to learn innovation of Dart 3.

Objects

In the example above, we already dealt with patterns when we "pulled" a value from an object into a switch:

case Success(message: var message):

This entry is called object pattern. In addition to parsing values from the object in the switch, it has another interesting application:

void handlePerson(Person person) {
 var Person(name: name, age: age) = person;
 print(name);
 print(age);
}

Before that, we already talked about destructuring when using records. In this case, we do not destructure the record, but the whole object, dividing it into two different variables.

There is such a thing in Kotlin, for example, but it looks more like destructuring than in Dart 3 to work with records:

val person = Person("Jon Snow", 20)
val (name, age) = person

Logical operators

Suppose that it is not important for us to know the exact value of the error, but we need to get information about whether there was an error or not. Then the Result entry above can be written in the following form:

bool isError(Result result) => switch (result) {
     Success() || Loading() => false,
     Error _ => true,
   };

Inside case we can also use logical operators. Due to completeness, the parser will also understand that we have covered all the values of the sealed class in the case.

Records

The new records can also be used as patterns. In the chapter on records, I have already shown how to initialize variables with them. The records can also be used inside a switch:

String describeBools(bool b1, bool b2) => switch ((b1, b2)) {
     (true, true) => 'both true',
     (false, false) => 'both false',
     (true, false) || (false, true) => 'one of each',
   };

As you can see, completeness also works in the case of records. Dart understands that all possible values are covered here.

This is how you can select the desired color for an element in the UI:

final color = switch ((isSelected, isActive)) {
      (true, true) => Colors.white,
      (false, true) => Colors.red,
      _ => Colors.black
  };

Null Safety

Of course, there is also a place for patterns with null safety. ? after a destructured variable ensures that a non-zero value gets into the case.

class Person {
 final String? name;
 final int? age;
 Person(this.name, this.age);
}

(String, int) getPersonInfo(Person person) => switch (person) {
     Person(name: var name?, age: var age?) => (name, age),
     Person(name: var name, age: var age) => (name ?? 'Unkown', age ?? 0),
   };

Of course, you can also use the ! pattern to force a non-null value:

(String, int) getPersonInfo(Person person) => switch (person) {
     Person(name: var name!, age: var age!) => (name, age),
     // warning: Dead code
     // warning: This case is covered by the previous cases.
     Person(name: var name, age: var age) => (name ?? 'Unknown', age ?? 0),
   };

In this case, Dart will tell you that the bottom case is no longer meaningful and can be removed.

Guard clause

The guard clause appeared in Dart, but so far only for switch. It is implemented using the when keyword inside the case:

final isOldEnough = switch (person) {
   Person(age: var age?) when age > 60 => true,
   _ => false,
 };

In this part of the code, we make sure that the age in the Person model is not null and is greater than 60. Otherwise we return false.

Collections

Collections can be destructured using patterns - just like everything else. For example, pull the first three values out of an array:

var list = [1, 2, 3, 4];
var [a, b, c] = list;

Of course, this also applies to Map:

final map = {'a': 1, 'b': 2};
 var {'a': int first, 'b': int second} = map;

Dart 3 isn't a breaking update, but it will seriously change the way we use it every day. When Dart 2 came out, we had to move projects to Null Safety - it took many months and took a huge amount of man hours. Dart 2 was an extremely important and breaking update, but it didn't change that much about how we use Dart. With Dart 3 it works the other way around: it won't force us to rewrite the entire project, but it will change the way we work with it on a daily basis significantly.

And, of course, every year Dart becomes more and more of a "quick-start" language. Four years ago, Dart was the language for DartPad: the changes are very revealing. More and more serious and big projects are being written in Flutter every year, and the changes to the language towards constraints and complications in syntax will definitely benefit new cool projects.

In case you have found a mistake in the text, please send a message to the author by selecting the mistake and pressing Ctrl-Enter.
Jacob Enderson 4.1K
I'm a tech enthusiast and writer, passionate about everything from cutting-edge hardware to innovative software and the latest gadgets. Join me as I explore the...
Comments (0)

    No comments yet

You must be logged in to comment.

Sign In / Sign Up