Dart 3 brought a significant shift in how we express domain logic and handle type safety in our Flutter apps. The language matured beyond syntax sugar, giving us tools that fundamentally change how we architect and reason about our code. In this guide, we'll explore the Dart 3 features worth using: what developers should know — records, patterns, sealed classes and class modifiers — and show you how they solve real problems in production Flutter applications.
Records — Return multiple values without a class
For years, returning multiple values from a function required creating a dedicated class or using a Tuple-like workaround. Records changed that. They're lightweight, immutable, structurally typed bundles that let you group values inline without boilerplate.
A record is a fixed-size, heterogeneous collection of values. You define it with parentheses and types:
// Named fields
(String name, int age) getUserData() {
return ('Alice', 28);
}
// Positional fields
(int, String, bool) parseResponse(String json) {
// Parse and return three unrelated values
return (200, 'Success', true);
}
// Mixed named and positional
(int id, {String? email, String role}) fetchUser(int userId) {
return (42, email: 'user@example.com', role: 'admin');
}Why records matter in a production codebase: they reduce friction for simple multi-value returns. In a ride-hailing app, you might fetch location data and ETAs together. Instead of creating a LocationData class, a record works immediately:
(double latitude, double longitude, int etaSeconds) getNearestDriverInfo() async {
// Fetch from API
return (37.7749, -122.4194, 180);
}
// Usage
final (lat, lng, eta) = await getNearestDriverInfo();
print('Driver arrives in $eta seconds');Records are also immutable by default and compare by value, not reference. Two records with the same field values are considered equal — no need to override == or hashCode.
Patterns — Destructure records, lists and objects
Patterns give you powerful syntax for extracting and matching values from complex structures. They work with records, lists, maps and custom objects, turning nested data unpacking into readable one-liners.
Destructuring a record:
final user = ('Alice', 28, 'alice@example.com');
// Pattern matching in variable declaration
final (name, age, email) = user;
print('$name is $age years old');
// Ignore fields you don't need
final (name, _, email) = user;
// Named fields
final (name: n, age: a, email: e) = (name: 'Bob', age: 30, email: 'bob@example.com');Patterns shine in switch statements. Instead of nested if-else chains, you match on structure:
final response = (statusCode: 200, body: 'OK', cached: true);
switch (response) {
case (statusCode: 200, body: String b, :bool cached):
print('Success: $b (cached: $cached)');
case (statusCode: 404, :_):
print('Not found');
case (statusCode: int code, :_) when code >= 500:
print('Server error: $code');
default:
print('Unknown response');
}In a trading platform, you might deserialize API responses with patterns:
final orderResult = getOrderStatus();
switch (orderResult) {
case ('filled', int quantity, double price):
print('Filled $quantity shares at \$$price');
case ('pending', :final createdAt):
print('Order pending since $createdAt');
case ('cancelled', String reason, :_):
print('Cancelled: $reason');
default:
print('Unknown status');
}You can also destructure lists and maps:
// List patterns
final [first, second, ...rest] = [1, 2, 3, 4, 5];
print('$first, $second, and ${rest.length} more');
// Map patterns with records
final {'status': status, 'data': data} = apiResponse;Sealed classes — Exhaustive switches over class hierarchies
Sealed classes enforce that all subclasses are known at compile time. This gives you exhaustive pattern matching: the compiler warns you if you miss a case in a switch statement.
A sealed class is declared with the sealed keyword and can only be extended by classes in the same file:
sealed class PaymentMethod {}
class CreditCard extends PaymentMethod {
final String cardNumber;
CreditCard(this.cardNumber);
}
class PayPal extends PaymentMethod {
final String email;
PayPal(this.email);
}
class ApplePay extends PaymentMethod {}
// Compiler ensures you handle all cases
String getPaymentLabel(PaymentMethod method) {
return switch (method) {
CreditCard(:final cardNumber) => 'Credit card ending in ${cardNumber.substring(cardNumber.length - 4)}',
PayPal(:final email) => 'PayPal account: $email',
ApplePay() => 'Apple Pay',
};
}Without the sealed keyword, the compiler can't know if there are other subclasses elsewhere in your codebase, so it won't enforce exhaustiveness. Sealed classes prevent that.
In a real e-commerce system, you might model order states as sealed classes:
sealed class OrderState {}
class Pending extends OrderState {
final DateTime createdAt;
Pending(this.createdAt);
}
class Processing extends OrderState {
final String fulfillmentCenter;
Processing(this.fulfillmentCenter);
}
class Shipped extends OrderState {
final String trackingNumber;
Shipped(this.trackingNumber);
}
class Cancelled extends OrderState {
final String reason;
Cancelled(this.reason);
}
// Force dev to handle every case
void handleStateChange(OrderState state) {
switch (state) {
case Pending(:final createdAt):
notifyUser('Order received at $createdAt');
case Processing(:final fulfillmentCenter):
notifyUser('Processing at $fulfillmentCenter');
case Shipped(:final trackingNumber):
notifyUser('Shipped. Track: $trackingNumber');
case Cancelled(:final reason):
notifyUser('Order cancelled: $reason');
}
}The compiler guarantees this function handles every OrderState. Add a new state, and you'll get a compilation error until you handle it. This prevents runtime surprises in production.
Class modifiers — final, base and interface control how classes are extended
Dart 3 introduced modifiers that control how a class can be used: as a parent, interface or sealed boundary.
final prevents both inheritance and implementation:
final class User {
final String name;
User(this.name);
}
// Error: cannot extend final class
class AdminUser extends User {}
// Error: cannot implement final class
class FakeUser implements User {}base allows inheritance but not implementation through other libraries:
base class ApiClient {
Future get(String url) async {
// Common logic
}
}
// OK: inherit in your own code
class AuthApiClient extends ApiClient {
@override
Future get(String url) async {
// Add auth headers
}
}
// Error in other libraries: cannot implement
class FakeApiClient implements ApiClient {}interface marks a class as a contract only, allowing implementation but not inheritance:
interface class Logger {
void log(String message);
}
// OK: implement the interface
class ConsoleLogger implements Logger {
@override
void log(String message) => print(message);
}
// Error: cannot extend interface class
class DetailedLogger extends Logger {}In a large SaaS codebase, these modifiers enforce architecture. Mark your domain models final. Mark your service layer base classes base so internal teams extend them but external plugins implement interfaces instead. This prevents accidental coupling and makes your intent clear.
Common mistakes
- Overusing records for everything: Records are great for simple returns, but if you have five fields with semantic meaning, a proper class with named fields is clearer. Records work best with 2-3 values or when the structure is temporary.
- Forgetting exhaustiveness: Sealed classes only work if you actually use switch expressions in a way that requires covering all cases. Don't fall back to if-else chains; the compiler can't help you then.
- Misunderstanding base vs interface:
baseis for parent classes you want people to extend.interfaceis for contracts. Don't usebasewhen you meaninterface. - Patterns without exhaustiveness: If you destructure in a regular variable declaration, you won't get compile-time safety. Use switch statements with sealed classes or enums for that guarantee.
Key takeaways
- Records eliminate lightweight multi-value classes and compare by value automatically.
- Patterns let you destructure and match complex nested structures inline with readable syntax.
- Sealed classes guarantee exhaustive switch coverage at compile time, catching missing cases before production.
- Class modifiers (
final,base,interface) enforce architectural intent and prevent accidental misuse across teams and libraries. - Together, these features reduce boilerplate, improve type safety and make your code's intent explicit to the compiler and your team.






Comments
Loading comments…