This article will cover three practical Dart features that will instantly improve your Flutter code.
With Dart 3, and the combination of sealed classes, switch cases, and a sprinkle of pattern matching, you can construct type-safe logic flows.
Let’s break this down, starting with my favorite language feature, sealed classes.
Sealed Class
Let’s say you have an application that can be in one of multiple states. For example, you can have multiple login states such as:
- Loading
- Logged In
- Error
When using a sealed class, you define a base class and in this case, it would be the login state.
sealed class LoginState {}
You then extend that base class to derive multiple different permutations, with different properties associated with them.
sealed class LoginState {}
class LoginInitial extends LoginState {}
class LoginLoading extends LoginState {}
class LoginSuccess extends LoginState { final String username; LoginSuccess(this.username);}
class LoginError extends LoginState { final String message; LoginError(this.message);}
For example, a successful login state might have the username of the logged-in user, while a loading state would not have any information.
sealed class LoginState {}
class LoginLoading extends LoginState {}
class LoginSuccess extends LoginState { final String username; LoginSuccess(this.username);}
This alone isn’t anything special; the great part comes when you need to handle these states in a compile-safe manner.
This leads us into the second useful feature, which is a simple switch case.
Switch case
Because the compiler understands sealed classes and inheritance, you can switch against these states.
switch (authViewModel.state) { case LoginInitial(): return Text("Enter your credentials"); case LoginLoading(): return CircularProgressIndicator(); case LoginSuccess(): return Text("Welcome, ${authViewModel.state.username}!"); case LoginError(:var message): return Text("Error: $message", style: TextStyle(color: Colors.red));}
The best part is that if we forget to handle a case or if we add more states to switch against, the compiler will let us know that we aren’t handling all cases and need to add them.
switch (authViewModel.state) { case LoginInitial(): return Text("Enter your credentials"); case LoginLoading(): return CircularProgressIndicator(); case LoginSuccess(): return Text("Welcome, ${authViewModel.state.username}!"); // ERROR: not handling LoginError state}
Now, this has already forced us into handling different scenarios that can happen. However, writing out authViewModel.state.username
feels annoying.
switch (authViewModel.state) { case LoginInitial(): return Text("Enter your credentials"); case LoginLoading(): return CircularProgressIndicator(); case LoginSuccess(): // CAN WE GET THE USERNAME HERE DIRECTLY return Text("Welcome, ${authViewModel.state.username}!"); case LoginError(): return Text("Error: ${authViewModel.state.message}", style: TextStyle(color: Colors.red) );}
Pattern Matching and Destructuring
The third feature solves this and is a combination of the previous two features: pattern matching and destructuring.
In the following example, we can see that using destructuring, we can get the username and the message out of the class as separate variables to use. This is helpful when you only care about a small number of properties and don’t want to write out the entire nested property multiple times.
switch (authViewModel.state) { case LoginInitial(): return Text("Enter your credentials"); case LoginLoading(): return CircularProgressIndicator(); case LoginSuccess(:var username): return Text("Welcome, $username!"); case LoginError(:var message): return Text("Error: $message", style: TextStyle(color: Colors.red));}
Let’s look at another example. The state here is a payment state. You can pattern match against the different states and use destructuring to extract the relevant properties, leaving your code looking readable.
switch (viewModel.state) { case PaymentInitial(): return Text("Enter an amount and click 'Pay Now'");
case PaymentProcessing(): return CircularProgressIndicator();
case PaymentSuccess(:var amount, :var transactionId): return Column( children: [ Text("Payment Successful!", style: TextStyle(color: Colors.green, fontSize: 18)), Text("Amount: \$${amount.toStringAsFixed(2)}"), Text("Transaction ID: $transactionId"), ], );
case PaymentFailed(:var errorMessage): return Text("Payment Failed: $errorMessage", style: TextStyle(color: Colors.red));}
These are just a few of my favorite and very useful Dart language features.
If you enjoyed this article, you will enjoy this next article about 3 concepts to build more maintainable Flutter apps