Dart Patterns
Description
Expert guidance on Dart language idioms, patterns, and best practices for building robust, idiomatic Dart applications.
Trigger
Use when:
- •Writing or reviewing Dart code
- •Working with Flutter or server-side Dart projects
- •Implementing async patterns, null safety, or advanced language features
- •Architecting Dart packages and libraries
Instructions
Core Language Features
Null Safety
Dart's sound null safety system eliminates null reference errors at compile time.
Nullable vs Non-nullable Types:
// Non-nullable (cannot be null)
String name = 'John';
int age = 25;
// Nullable (can be null)
String? nickname;
int? optionalAge;
// Null assertion operator (!) - use sparingly
String definitelyNotNull = nickname!; // Throws if null
// Null-aware operators
String displayName = nickname ?? 'Anonymous'; // Default value
int? length = nickname?.length; // Safe navigation
// Late variables (initialized later, but non-nullable)
late String initLater;
void initialize() {
initLater = 'Now initialized';
}
// Late lazy initialization
late final String expensive = computeExpensive();
Best Practices:
- •Prefer non-nullable types by default
- •Use
?only when null is a valid value - •Avoid
!operator; prefer null checks or?? - •Use
latefor two-phase initialization - •Use
late finalfor lazy initialization
Async Programming
Futures:
// Basic Future usage
Future<String> fetchData() async {
await Future.delayed(Duration(seconds: 1));
return 'Data loaded';
}
// Error handling
Future<String> fetchWithError() async {
try {
final result = await riskyOperation();
return result;
} on NetworkException catch (e) {
print('Network error: ${e.message}');
rethrow;
} catch (e, stackTrace) {
print('Error: $e\n$stackTrace');
return 'default';
} finally {
cleanup();
}
}
// Multiple futures
Future<void> loadMultiple() async {
// Sequential
final user = await fetchUser();
final profile = await fetchProfile(user.id);
// Parallel
final results = await Future.wait([
fetchUser(),
fetchSettings(),
fetchNotifications(),
]);
// With error handling per future
final resultsWithErrors = await Future.wait(
[fetchUser(), fetchSettings()],
eagerError: false, // Continue even if one fails
);
}
// Timeout handling
Future<String> fetchWithTimeout() async {
try {
return await fetchData().timeout(
Duration(seconds: 5),
onTimeout: () => 'Timed out',
);
} on TimeoutException {
return 'Request timed out';
}
}
Streams:
// Creating streams
Stream<int> countStream() async* {
for (int i = 1; i <= 5; i++) {
await Future.delayed(Duration(seconds: 1));
yield i;
}
}
// Stream from iterable
Stream<int> fromList() => Stream.fromIterable([1, 2, 3, 4, 5]);
// Listening to streams
void listenToStream() {
final subscription = countStream().listen(
(data) => print('Received: $data'),
onError: (error) => print('Error: $error'),
onDone: () => print('Stream closed'),
cancelOnError: false,
);
// Cancel subscription
subscription.cancel();
}
// Async iteration
Future<void> processStream() async {
await for (final value in countStream()) {
print(value);
if (value == 3) break; // Exit early
}
}
// Stream transformations
Stream<String> transformStream(Stream<int> input) {
return input
.where((n) => n.isEven)
.map((n) => 'Number: $n')
.take(10)
.distinct()
.handleError((error) => print('Error: $error'));
}
// Broadcast streams (multiple listeners)
Stream<int> createBroadcast() {
final controller = StreamController<int>.broadcast();
// Add data
controller.add(1);
controller.add(2);
// Close when done
controller.close();
return controller.stream;
}
// Stream controller with error handling
class DataStream {
final _controller = StreamController<String>();
Stream<String> get stream => _controller.stream;
void addData(String data) {
if (!_controller.isClosed) {
_controller.add(data);
}
}
void addError(Object error) {
if (!_controller.isClosed) {
_controller.addError(error);
}
}
void dispose() {
_controller.close();
}
}
Collections
Idiomatic Collection Usage:
// List operations
final numbers = [1, 2, 3, 4, 5];
final doubled = numbers.map((n) => n * 2).toList();
final evens = numbers.where((n) => n.isEven).toList();
final sum = numbers.reduce((a, b) => a + b);
final total = numbers.fold(0, (sum, n) => sum + n);
// List patterns
final [first, second, ...rest] = numbers;
final [head, ...middle, last] = numbers;
// Spread operator
final combined = [...numbers, 6, 7, ...doubled];
final nullSafe = [...?nullableList];
// Set operations
final uniqueNumbers = {1, 2, 3, 2, 1}; // {1, 2, 3}
final union = set1.union(set2);
final intersection = set1.intersection(set2);
final difference = set1.difference(set2);
// Map operations
final userMap = {
'name': 'John',
'age': 30,
'email': 'john@example.com',
};
// Map transformation
final uppercased = userMap.map(
(key, value) => MapEntry(key.toUpperCase(), value),
);
// Null-aware map access
final email = userMap['email'] ?? 'no-email';
// Collection if/for
final list = [
1,
2,
if (includeThree) 3,
for (var i in [4, 5, 6]) i * 2,
];
Object-Oriented Patterns
Classes and Constructors:
// Standard class
class User {
final String id;
final String name;
final int age;
// Named constructor
User({
required this.id,
required this.name,
required this.age,
});
// Factory constructor
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: json['id'] as String,
name: json['name'] as String,
age: json['age'] as int,
);
}
// Named constructor with defaults
User.guest()
: id = 'guest',
name = 'Guest User',
age = 0;
// Copy with method
User copyWith({
String? id,
String? name,
int? age,
}) {
return User(
id: id ?? this.id,
name: name ?? this.name,
age: age ?? this.age,
);
}
@override
String toString() => 'User(id: $id, name: $name, age: $age)';
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is User &&
runtimeType == other.runtimeType &&
id == other.id;
@override
int get hashCode => id.hashCode;
}
// Immutable data class pattern
class ImmutableUser {
const ImmutableUser({
required this.id,
required this.name,
});
final String id;
final String name;
}
Mixins:
// Mixin definition
mixin Logging {
void log(String message) {
print('[${DateTime.now()}] $message');
}
}
mixin Validation {
bool validate(String input) {
return input.isNotEmpty && input.length >= 3;
}
}
// Restricted mixin (can only be applied to specific types)
mixin Serializable on Object {
Map<String, dynamic> toJson();
}
// Using mixins
class UserService with Logging, Validation {
void createUser(String name) {
if (validate(name)) {
log('Creating user: $name');
// Create user logic
}
}
}
Abstract Classes and Interfaces:
// Abstract class
abstract class Animal {
String get name;
void makeSound();
// Concrete method
void move() {
print('$name is moving');
}
}
// Interface pattern (every class is an interface)
class Flyable {
void fly() {}
}
class Swimmable {
void swim() {}
}
// Multiple interface implementation
class Duck extends Animal implements Flyable, Swimmable {
@override
String get name => 'Duck';
@override
void makeSound() => print('Quack');
@override
void fly() => print('Duck is flying');
@override
void swim() => print('Duck is swimming');
}
Extension Methods:
// String extensions
extension StringExtensions on String {
bool get isValidEmail {
return RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(this);
}
String capitalize() {
if (isEmpty) return this;
return '${this[0].toUpperCase()}${substring(1)}';
}
String truncate(int maxLength) {
return length <= maxLength ? this : '${substring(0, maxLength)}...';
}
}
// List extensions
extension ListExtensions<T> on List<T> {
T? get firstOrNull => isEmpty ? null : first;
T? get lastOrNull => isEmpty ? null : last;
List<T> distinctBy<K>(K Function(T) selector) {
final seen = <K>{};
return where((item) => seen.add(selector(item))).toList();
}
}
// Nullable extensions
extension NullableStringExtensions on String? {
bool get isNullOrEmpty => this == null || this!.isEmpty;
}
// Usage
void main() {
final email = 'test@example.com';
print(email.isValidEmail); // true
final name = 'john';
print(name.capitalize()); // John
final users = [
User(id: '1', name: 'Alice', age: 25),
User(id: '1', name: 'Alice', age: 25),
User(id: '2', name: 'Bob', age: 30),
];
final distinct = users.distinctBy((u) => u.id);
}
Generics
Generic Classes and Methods:
// Generic class
class Box<T> {
final T value;
const Box(this.value);
R transform<R>(R Function(T) transformer) {
return transformer(value);
}
}
// Generic with constraints
class Repository<T extends Identifiable> {
final Map<String, T> _cache = {};
void save(T item) {
_cache[item.id] = item;
}
T? findById(String id) {
return _cache[id];
}
List<T> findAll() {
return _cache.values.toList();
}
}
abstract class Identifiable {
String get id;
}
// Generic methods
T firstWhere<T>(List<T> list, bool Function(T) predicate, {required T orElse}) {
for (final item in list) {
if (predicate(item)) return item;
}
return orElse;
}
// Multiple type parameters
class Pair<K, V> {
final K key;
final V value;
const Pair(this.key, this.value);
@override
String toString() => '($key, $value)';
}
// Covariant generics
class Animal {}
class Dog extends Animal {}
// This works because List is covariant
List<Animal> animals = <Dog>[];
Error Handling
Exception Patterns:
// Custom exceptions
class NetworkException implements Exception {
final String message;
final int? statusCode;
NetworkException(this.message, [this.statusCode]);
@override
String toString() => 'NetworkException: $message (status: $statusCode)';
}
class ValidationException implements Exception {
final Map<String, String> errors;
ValidationException(this.errors);
@override
String toString() => 'ValidationException: $errors';
}
// Result pattern
class Result<T, E> {
final T? value;
final E? error;
final bool isSuccess;
const Result.success(this.value)
: error = null,
isSuccess = true;
const Result.failure(this.error)
: value = null,
isSuccess = false;
R when<R>({
required R Function(T) success,
required R Function(E) failure,
}) {
return isSuccess ? success(value as T) : failure(error as E);
}
}
// Usage
Future<Result<User, String>> fetchUser(String id) async {
try {
final user = await api.getUser(id);
return Result.success(user);
} catch (e) {
return Result.failure('Failed to fetch user: $e');
}
}
// Option/Maybe pattern
sealed class Option<T> {
const Option();
}
class Some<T> extends Option<T> {
final T value;
const Some(this.value);
}
class None<T> extends Option<T> {
const None();
}
// Pattern matching with sealed classes
String greet(Option<String> name) {
return switch (name) {
Some(value: final n) => 'Hello, $n!',
None() => 'Hello, stranger!',
};
}
Package Structure
Standard Project Layout:
my_package/ ├── lib/ │ ├── src/ # Private implementation │ │ ├── models/ │ │ ├── services/ │ │ └── utils/ │ └── my_package.dart # Public API ├── test/ │ ├── src/ │ │ └── models_test.dart │ └── my_package_test.dart ├── example/ │ └── main.dart ├── pubspec.yaml ├── README.md ├── CHANGELOG.md └── LICENSE
Public API Pattern (lib/my_package.dart):
library my_package; // Export public API export 'src/models/user.dart'; export 'src/services/user_service.dart'; // Hide internal implementation export 'src/utils/helpers.dart' hide internalHelper; // Show only specific members export 'src/utils/validators.dart' show validateEmail, validatePhone;
pubspec.yaml Best Practices:
name: my_package description: A clear, concise description version: 1.0.0 homepage: https://github.com/username/my_package environment: sdk: '>=3.0.0 <4.0.0' dependencies: # Use version constraints properly http: ^1.0.0 # Compatible with 1.x.x path: '>=1.8.0 <2.0.0' # Explicit range dev_dependencies: test: ^1.24.0 lints: ^3.0.0 # Optional: Declare supported platforms platforms: android: ios: linux: macos: web: windows:
Performance Patterns
Optimization Techniques:
// Const constructors for immutable objects
class Config {
const Config({
required this.apiUrl,
required this.timeout,
});
final String apiUrl;
final Duration timeout;
}
// Use const where possible
const config = Config(
apiUrl: 'https://api.example.com',
timeout: Duration(seconds: 30),
);
// Lazy initialization
class ExpensiveResource {
static ExpensiveResource? _instance;
factory ExpensiveResource() {
return _instance ??= ExpensiveResource._internal();
}
ExpensiveResource._internal() {
// Expensive initialization
}
}
// Efficient string building
String buildLargeString(List<String> parts) {
final buffer = StringBuffer();
for (final part in parts) {
buffer.write(part);
}
return buffer.toString();
}
// Avoid unnecessary rebuilds/recomputations
class DataProcessor {
List<int>? _cachedResult;
List<int> _lastInput = [];
List<int> process(List<int> input) {
// Return cached if input hasn't changed
if (_cachedResult != null && _listEquals(input, _lastInput)) {
return _cachedResult!;
}
_lastInput = List.from(input);
_cachedResult = _expensiveComputation(input);
return _cachedResult!;
}
bool _listEquals(List<int> a, List<int> b) {
if (a.length != b.length) return false;
for (int i = 0; i < a.length; i++) {
if (a[i] != b[i]) return false;
}
return true;
}
List<int> _expensiveComputation(List<int> input) {
// Heavy computation
return input.map((x) => x * x).toList();
}
}
// Isolates for CPU-intensive work
import 'dart:isolate';
Future<int> computeInBackground(int n) async {
final receivePort = ReceivePort();
await Isolate.spawn(_heavyComputation, receivePort.sendPort);
final sendPort = await receivePort.first as SendPort;
final responsePort = ReceivePort();
sendPort.send([n, responsePort.sendPort]);
return await responsePort.first as int;
}
void _heavyComputation(SendPort sendPort) async {
final receivePort = ReceivePort();
sendPort.send(receivePort.sendPort);
await for (final message in receivePort) {
final data = message as List;
final n = data[0] as int;
final replyPort = data[1] as SendPort;
// Heavy work
int result = 0;
for (int i = 0; i < n; i++) {
result += i;
}
replyPort.send(result);
break;
}
}
Effective Dart Guidelines
Style:
- •Use
lowerCamelCasefor variables, methods, and parameters - •Use
UpperCamelCasefor types (classes, enums, typedefs) - •Use
lowercase_with_underscoresfor libraries and file names - •Prefer
finalovervarwhen variable won't be reassigned - •Use
constfor compile-time constants - •Avoid explicit type annotations for local variables when obvious
- •Use trailing commas for better formatting
Documentation:
/// Fetches user data from the API.
///
/// Returns a [User] object if successful, or throws a [NetworkException]
/// if the request fails.
///
/// Example:
/// ```dart
/// final user = await fetchUser('user-123');
/// print(user.name);
/// ```
Future<User> fetchUser(String id) async {
// Implementation
}
/// Configuration for API client.
///
/// {@template api_config}
/// This class holds all configuration needed to initialize
/// the API client, including base URL and authentication.
/// {@endtemplate}
class ApiConfig {
/// Creates an API configuration.
///
/// {@macro api_config}
const ApiConfig({
required this.baseUrl,
this.timeout = const Duration(seconds: 30),
});
/// The base URL for all API requests.
final String baseUrl;
/// Request timeout duration.
final Duration timeout;
}
Usage:
- •Prefer
=>for simple single-expression functions - •Use
ifinstead of conditional expressions for void returns - •Prefer
async/awaitover raw futures - •Don't explicitly initialize variables to
null - •Use interpolation to compose strings:
'Hello, $name!' - •Avoid using
.lengthto check if collection is empty; use.isEmpty - •Use
whereType<T>()to filter collections by type - •Prefer function declarations over assigning lambdas to variables
Design:
- •Make classes immutable when possible
- •Provide factory constructors for complex object creation
- •Use named constructors for clarity
- •Prefer composition over inheritance
- •Keep classes focused (Single Responsibility)
- •Use enums for fixed sets of values
- •Leverage sealed classes for exhaustive pattern matching
Modern Dart Patterns (3.0+)
Records:
// Record syntax
(int, String) getPair() => (42, 'answer');
// Named fields
({int age, String name}) getUser() => (age: 30, name: 'John');
// Pattern matching with records
final (x, y) = getPoint();
final {:name, :age} = getUser();
// Function returning multiple values
(int min, int max) findRange(List<int> numbers) {
return (numbers.reduce(min), numbers.reduce(max));
}
Pattern Matching:
// Switch expressions
String describe(Object obj) => switch (obj) {
int() => 'An integer',
String() => 'A string',
List(isEmpty: true) => 'Empty list',
List(length: 1) => 'List with one element',
{'key': var value} => 'Map with key: $value',
_ => 'Something else',
};
// Destructuring
final [first, ...middle, last] = [1, 2, 3, 4, 5];
// If-case
if (value case int x when x > 0) {
print('Positive integer: $x');
}
Sealed Classes:
sealed class ApiResponse<T> {}
class Success<T> extends ApiResponse<T> {
final T data;
Success(this.data);
}
class Error<T> extends ApiResponse<T> {
final String message;
Error(this.message);
}
class Loading<T> extends ApiResponse<T> {}
// Exhaustive pattern matching
String handle(ApiResponse<String> response) {
return switch (response) {
Success(data: final d) => 'Got data: $d',
Error(message: final m) => 'Error: $m',
Loading() => 'Loading...',
// Compiler ensures all cases are covered
};
}
Anti-Patterns
Avoid these common mistakes:
- •
Overusing null assertion operator (!)
dart// Bad String name = user!.profile!.name!; // Good String name = user?.profile?.name ?? 'Unknown';
- •
Not using named parameters for clarity
dart// Bad createUser('John', 30, true, 'john@example.com'); // Good createUser( name: 'John', age: 30, isActive: true, email: 'john@example.com', ); - •
Ignoring async errors
dart// Bad fetchData(); // Fire and forget // Good unawaited(fetchData().catchError((e) => log('Error: $e'))); - •
Using dynamic unnecessarily
dart// Bad dynamic processData(dynamic data) => data; // Good T processData<T>(T data) => data;
- •
Not disposing resources
dart// Bad class DataService { final StreamController _controller = StreamController(); } // Good class DataService { final StreamController _controller = StreamController(); void dispose() { _controller.close(); } }
References
- •Effective Dart
- •Dart Language Tour
- •Dart API Reference
- •pub.dev - Official Dart package repository
- •Dart Lint Rules