Dart 3 Features #
Dart 3, released alongside Flutter 3.10 at Google I/O 2023, marks one of the biggest evolutionary leaps in the history of Dart language development. This update not only solidified the language’s status as 100% free from null reference errors (100% Sound Null Safety), but also introduced modern programming paradigms that change how you model data, manage state, and structure business logic in Flutter apps. We’ll thoroughly explore the pillar features of Dart 3, from Records, Pattern Matching, Switch Expressions, Sealed Classes, advanced Class Modifiers, to the efficient Extension Types feature with zero runtime overhead.
The Big Evolution: Absolute Sound Null Safety #
Since Dart 3’s release, the Dart compiler has officially removed support for running code without null safety guarantees (un-sound null safety). This means Dart 3 is a programming language that is 100% Sound Null Safety.
Before this era, the compiler still allowed some configuration exceptions to run legacy code packages that hadn’t migrated to null safety. With that tolerance removed, you get several big benefits under the hood:
- AOT/JIT Compilation Optimization: The compiler no longer needs to insert additional null check instructions at the machine code level for non-nullable variables. This produces smaller app binaries and higher execution speed.
- Code Predictability: You’re guaranteed to never encounter Null Pointer Exceptions at runtime for non-nullable typed variables.
This bold decision to mandate absolute soundness is an important stepping stone that allowed the Dart team to design new features like Records and Patterns with maximum performance.
Records — Returning Multiple Values Efficiently #
In programming, we often create functions that need to return more than one value at once. Before Dart 3, the solutions available for this problem each had limitations:
// ANTI-PATTERN: Several old approaches before Dart 3 for returning multiple values
// Option 1: Using a List (Weakness: Not type-safe, prone to wrong indexing)
List<dynamic> getUserOldList() {
return ['Andi', 28, true];
}
// Option 2: Using a Map (Weakness: Not type-safe, prone to typos in key names)
Map<String, dynamic> getUserOldMap() {
return {'name': 'Andi', 'age': 28, 'isAdmin': true};
}
// Option 3: Creating a Custom Class (Weakness: Too verbose for simple structures)
class UserResult {
final String name;
final int age;
final bool isAdmin;
UserResult(this.name, this.age, this.isAdmin);
}
UserResult getUserOldClass() => UserResult('Andi', 28, true);
Dart 3 solves this problem elegantly through Records. Records are anonymous, immutable, type-safe collection data types. Unlike creating new classes, Records are declared concisely using parentheses ().
Records with Positional Fields #
By default, you can define fields inside a record based on their positional order:
// Defining a function that returns a Record of type (String, int, bool)
(String, int, bool) getUserInfo() {
return ('Budi', 25, true);
}
void main() {
final user = getUserInfo();
// Accessing positional values using $1, $2, etc. syntax
print('Name: ${user.$1}'); // Output: Budi
print('Age: ${user.$2}'); // Output: 25
print('Admin: ${user.$3}'); // Output: true
}
Records with Named Fields #
To improve code readability, you can give each field inside the record a name, similar to named parameters in functions:
// Defining a Record with named fields
({String name, int age, bool isStaff}) getStaffInfo() {
return (name: 'Sari', age: 30, isStaff: false);
}
void main() {
final staff = getStaffInfo();
// Accessing values using the field name directly
print('Name: ${staff.name}'); // Output: Sari
print('Age: ${staff.age}'); // Output: 30
print('Staff: ${staff.isStaff}'); // Output: false
}
You can also mix positional and named fields in the same record:
(int, {String label}) myRecord = (404, label: 'Not Found');
Structural Equality #
Unlike List or Map, which compare object equality by memory reference (identity), Records in Dart use structural value equality. Two records are considered equal if the data types and values in every field are identical:
void testEquality() {
final recordA = ('flutter', 42);
final recordB = ('flutter', 42);
// Comparing value equality
print(recordA == recordB); // Output: true (On a regular List, this would return false)
final mapRecordA = (x: 10, y: 20);
final mapRecordB = (y: 20, x: 10);
// The comparison still succeeds even if the named fields are written in different order
print(mapRecordA == mapRecordB); // Output: true
}
This mechanism saves a lot of boilerplate code when you want to apply value comparison to simple coordinates, configurations, or UI states.
Patterns — Data Destructuring and Matching #
Patterns is a very powerful companion feature to Records in Dart 3. This feature has two main functions:
- Destructuring: Breaking complex data structures (like Records, Lists, Maps, or custom Objects) into individual variables directly.
- Matching: Checking whether data has a certain shape, type, or value according to the pattern you define.
Here’s a flow diagram of how the destructuring process safely breaks down Record data:
flowchart LR
RecordData["Record: (status: 200, message: 'Success')"] --> Destructure{"Destructuring Pattern"}
Destructure -->|"Type & Key Name Check"| VarStatus["status variable: 200"]
Destructure -->|"Type & Key Name Check"| VarMsg["message variable: 'Success'"]Variable Destructuring #
You can unpack various data structure types in just one variable declaration line:
void destructureExamples() {
// 1. Destructuring from a Record
final (name, age) = ('Deni', 32);
print('$name is $age years old.'); // Output: Deni is 32 years old.
// 2. Destructuring from a List (Using the '...' rest pattern for remaining elements)
final [first, second, ...rest] = [10, 20, 30, 40, 50];
print('First: $first, Second: $second, Rest: $rest');
// Output: First: 10, Second: 20, Rest: [30, 40, 50]
// 3. Destructuring from a Map
final json = {'id': 'user-99', 'role': 'editor'};
final {'id': userId, 'role': userRole} = json;
print('User $userId is an $userRole');
// Output: User user-99 is an editor
}
Conditional Matching Patterns (if-case Pattern) #
Before Dart 3, validating complex JSON data structures from APIs required many nested if branches that were very prone to runtime crashes. With if-case, you can match data types and values while destructuring at the same time:
// CORRECT: Processing JSON data safely and concisely using the if-case pattern
void handleApiResponse(dynamic jsonResponse) {
if (jsonResponse case {'status': 'success', 'data': {'name': String name, 'age': int age}}) {
// This block only runs if jsonResponse is a Map, status equals 'success',
// and the data object contains a 'name' field (of type String) and 'age' (of type int).
// The 'name' and 'age' variables are directly available for use within this scope.
print('User data successfully verified: $name, $age years old.');
} else {
print('Invalid JSON data structure.');
}
}
Object Property Matching (Object Patterns) #
Patterns also work with your own custom classes. You can match properties of an object instance without writing repetitive manual getter code:
class UserProfile {
final String username;
final int level;
UserProfile(this.username, this.level);
}
void checkUserLevel(Object profile) {
// Matching whether the object is of type UserProfile and extracting its property values
if (profile case UserProfile(username: final name, level: int lvl) when lvl > 50) {
print('Elite user: $name (Level $lvl)');
} else if (profile case UserProfile(username: final name)) {
print('Regular user: $name');
}
}
Switch Expressions — Concise, Declarative, and Expressive #
Dart 3 revolutionizes how switch works by introducing Switch Expressions. If the traditional switch acts as a statement (a control flow statement that produces no value), switch expressions act as an expression (producing a value you can directly store in a variable or return from a function).
Let’s compare the difference directly:
// ANTI-PATTERN: Writing a long, repetitive switch statement to assign a value
String getStatusLabelOld(String status) {
String label;
switch (status) {
case 'pending':
label = 'Awaiting Payment';
break;
case 'processing':
label = 'Processing';
break;
case 'shipped':
label = 'In Transit';
break;
default:
label = 'Unknown Status';
}
return label;
}
// ====================================================================
// CORRECT: Writing a very clean switch expression free of 'break' boilerplate
String getStatusLabelNew(String status) => switch (status) {
'pending' => 'Awaiting Payment',
'processing' => 'Processing',
'shipped' => 'In Transit',
_ => 'Unknown Status', // The '_' character acts as the default case (wildcard)
};
Additional Condition Checks (Guard Clause) #
Switch expressions support adding a guard clause using the when keyword to evaluate additional logic conditions:
String evaluateScore(int score) => switch (score) {
>= 90 => 'Excellent (A)',
>= 75 && < 90 => 'Good (B)',
>= 60 when score.isEven => 'Fair - Even (C)', // Guard clause active
>= 60 => 'Fair (C)',
_ => 'Needs Improvement (D)',
};
Sealed Classes — Exhaustive Type Handling Guarantees #
In Flutter app development, one of the most common scenarios is UI State Management. For example, a page could be in Loading, Success, or Error status.
Before Dart 3, we usually used regular class inheritance or enums to represent these states. The weakness was that the compiler had no way to know whether you’d handled all possible states in the interface code.
Dart 3 introduces the sealed modifier keyword for classes. Classes marked sealed have the following characteristics:
- The class cannot be instantiated directly (implicitly abstract).
- All subclasses of the
sealedclass must be written in the same file (library). - The Dart compiler guarantees exhaustiveness checking. If you miss even one subclass when checking with
switch, your app won’t build (compile-time error).
The exhaustiveness checking flow can be seen in the diagram below:
flowchart TD
Sealed["Sealed Class: UIState"] --> Sub1["Class: Loading"]
Sealed --> Sub2["Class: Success"]
Sealed --> Sub3["Class: Error"]
Sub1 & Sub2 & Sub3 --> Compiler{"Compiler Exhaustiveness Check"}
Compiler -->|"All Subtypes Covered"| Valid["Compilation Succeeds (Safe)"]
Compiler -->|"A Subtype Is Missed"| Invalid["Compile-Time Error (Build Fails)"]Sealed Class Implementation Example #
Let’s apply sealed class to design a safe product state:
// ProductState.dart - All classes are in the same file
sealed class ProductState {}
class ProductLoading extends ProductState {}
class ProductSuccess extends ProductState {
final List<String> items;
ProductSuccess(this.items);
}
class ProductError extends ProductState {
final String errorMessage;
ProductError(this.errorMessage);
}
Now, let’s consume the state above in a Flutter UI using a switch expression:
// The compiler will validate that all sub-states are handled
Widget buildProductList(ProductState state) {
return switch (state) {
ProductLoading() => const CircularProgressIndicator(),
ProductSuccess(:final items) => ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) => Text(items[index]),
),
ProductError(:final errorMessage) => Text('Failed to load: $errorMessage'),
// If you remove any of the state lines above,
// the compiler will show the error: "The type 'ProductState' is not exhaustively matched"
};
}
This mechanism eliminates the need for writing the _ => ... default handling usually used to silence compiler errors. This is very safe because if you add a new state in the future (e.g., ProductEmpty), the compiler will force you to update all UI code consuming that state before the app can run.
Class Modifiers — Full Control Over Class Access Rights #
Dart 3 gives library authors much more granular control over how a class can be consumed outside their library. You can use the new class modifiers to restrict inheritance and instantiation.
Here’s an explanation and rules for each modifier:
1. base #
A class marked base restricts the class to only being inherited (extends), and forbids direct implementation (implements) from outside the file. This guarantees all subclasses fully inherit the internal methods.
// File: animal.dart
base class Animal {
void eat() => print('Eating');
}
// File: main.dart
class Dog extends Animal {} // VALID
// class Robot implements Animal {} // ERROR: base class cannot be implemented
2. interface #
The opposite of base, the interface modifier forbids inheritance (extends) and only allows re-implementing the entire class blueprint (implements) from outside the file. This is useful for separating API contracts from concrete implementations.
// File: auth.dart
interface class AuthRepository {
void login() {}
}
// File: main.dart
class MyAuth implements AuthRepository {
@override
void login() => print('Login...');
} // VALID
// class ExtendedAuth extends AuthRepository {} // ERROR: interface class cannot be extended
3. final #
The final modifier completely closes off the possibility of extending the class from outside the file. The class cannot be inherited (extends) nor re-implemented (implements).
// File: config.dart
final class AppConfig {
final String apiUrl = 'https://api.example.com';
}
// File: main.dart
// class CustomConfig extends AppConfig {} // ERROR: final class cannot be inherited
4. mixin class #
Allows a class to be used as an inheritance blueprint while also being insertable using the with keyword (as a mixin).
mixin class Logger {
void log(String msg) => print('[LOG]: $msg');
}
class AuthService with Logger {} // Used as a mixin (VALID)
class ConsoleLogger extends Logger {} // Used as a regular class (VALID)
Class Modifier Characteristics Summary Table #
| Modifier | Instantiable? | Inheritable (extends)? | Implementable (implements)? | Usable as Mixin (with)? |
|---|---|---|---|---|
class | ✅ | ✅ | ✅ | ❌ |
abstract | ❌ | ✅ | ✅ | ❌ |
base | ✅ | ✅ | ❌ | ❌ |
interface | ✅ | ❌ | ✅ | ❌ |
final | ✅ | ❌ | ❌ | ❌ |
sealed | ❌ | ✅* | ✅* | ❌ |
mixin class | ✅ | ✅ | ✅ | ✅ |
*Special exception: Only allowed within the same file/library.
Extension Types — Zero-Cost Runtime Wrappers #
Officially introduced in Dart version 3.3, Extension Types are an advanced feature designed to wrap existing data types to provide a new interface or static validation, without incurring additional memory allocation costs when the app runs (zero runtime overhead).
Before this feature, if you wanted to create a special data type (e.g., separating user IDs and product IDs to avoid input errors), you had to create a regular wrapper class:
// Classic Approach: Creating a wrapper class (Causes new object memory allocation at runtime)
class LegacyUserId {
final String value;
const LegacyUserId(this.value);
}
Although safe while writing code, the approach above burdens app performance because the compiler must allocate memory space for that new object on the heap every time it’s instantiated.
Extension Types Syntax #
With Extension Types, the wrapper only exists during static analysis (compile-time). When the code is compiled into machine code, the wrapper is melted away (compiled away) back into its underlying data type:
// Defining an extension type
extension type UserId(String value) {
// You can add helper methods
bool get isValid => value.startsWith('usr_');
}
extension type ProductId(String value) {}
void showUserProfile(UserId id) {
print('Showing user profile: ${id.value}');
}
void main() {
final myUserId = UserId('usr_12345');
final myProductId = ProductId('prod_9999');
showUserProfile(myUserId); // VALID
// COMPILE-TIME ERROR: The ProductId data type is not compatible with UserId
// showUserProfile(myProductId);
// At runtime (app running on a phone):
// The myUserId and myProductId variables are just plain String objects ('usr_12345' and 'prod_9999')
// With no UserId or ProductId class allocation at all!
}
This feature is very useful when you want to interact with Javascript APIs (on the Web platform) or when you want to map external JSON data into type-safe Dart objects without spending extra performance on creating new objects.
Summary #
- Absolute Sound Null Safety: Dart 3 removes tolerance for non-null-safe code, producing much more optimized AOT/JIT compilation and snappier performance.
- Records: Lets you return multiple values at once from a function in a structured, type-safe way with built-in structural equality.
- Patterns: Brings data destructuring and pattern matching capabilities that cut the complexity of processing raw data like JSON from APIs.
- Switch Expressions: Transforms how
switchworks into a declarative, immutable, clean value-producing expression without needing to writebreak.- Sealed Classes: Creates closed class hierarchies that guarantee compiler exhaustiveness checking, ideal for modeling safe UI States.
- Class Modifiers: Provides strict access control over class inheritance rights using keywords like
base,interface, andfinal.- Extension Types: Lets you create custom wrapper data types that are statically safe (compile-time) without burdening runtime memory performance (zero-cost abstraction).