Dart Guide¶
Applies to: Dart 3.0+, Flutter, Backend Services, CLI Tools
Core Principles¶
- Type Safety: Sound null safety, strong typing
- Async First: Futures, Streams, async/await
- Flutter Patterns: Widget composition, state management
- Immutability: Prefer immutable data structures
- Code Generation: Use build_runner for JSON, routing
Language-Specific Guardrails¶
Dart Version & Setup¶
- ✓ Use Dart 3.0+ (sound null safety required)
- ✓ Use
pubspec.yamlfor dependencies - ✓ Pin SDK version constraints
- ✓ Use
flutter pub getordart pub get
Code Style (dart format)¶
- ✓ Run
dart formatbefore every commit - ✓ Use
dart analyzefor static analysis - ✓ Follow Effective Dart guidelines
- ✓ Use
lowerCamelCasefor variables, functions - ✓ Use
UpperCamelCasefor classes, enums, types - ✓ Use
lowercase_with_underscoresfor files, libraries - ✓ 2-space indentation
- ✓ Max line length: 80 characters
Null Safety¶
- ✓ Use non-nullable types by default
- ✓ Use
?for nullable types:String? - ✓ Use
!only when certain (prefer null checks) - ✓ Use
??for default values - ✓ Use
?.for null-aware access - ✓ Use
latesparingly (prefer initialization)
Classes & Objects¶
- ✓ Prefer
finalfields (immutability) - ✓ Use named constructors for clarity
- ✓ Use factory constructors appropriately
- ✓ Implement
==andhashCodefor value objects - ✓ Use
@immutableannotation for immutable classes
Error Handling¶
- ✓ Use typed exceptions
- ✓ Catch specific exceptions
- ✓ Don't catch
Error(programming errors) - ✓ Use
rethrowto preserve stack traces - ✓ Document thrown exceptions
Project Structure¶
Flutter App¶
myapp/
├── lib/
│ ├── main.dart
│ ├── app.dart
│ ├── features/
│ │ ├── auth/
│ │ │ ├── data/
│ │ │ │ ├── models/
│ │ │ │ └── repositories/
│ │ │ ├── domain/
│ │ │ └── presentation/
│ │ │ ├── screens/
│ │ │ ├── widgets/
│ │ │ └── providers/
│ │ └── home/
│ ├── core/
│ │ ├── constants/
│ │ ├── extensions/
│ │ ├── utils/
│ │ └── theme/
│ └── shared/
│ └── widgets/
├── test/
│ ├── unit/
│ ├── widget/
│ └── integration/
├── pubspec.yaml
├── analysis_options.yaml
└── README.md
Dart Package¶
mypackage/
├── lib/
│ ├── mypackage.dart # Main export file
│ └── src/ # Implementation
│ ├── models/
│ └── services/
├── test/
├── example/
├── pubspec.yaml
├── analysis_options.yaml
├── CHANGELOG.md
└── README.md
Data Classes & Models¶
With Freezed (Recommended)¶
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:json_annotation/json_annotation.dart';
part 'user.freezed.dart';
part 'user.g.dart';
@freezed
class User with _$User {
const factory User({
required String id,
required String email,
required int age,
@Default('user') String role,
DateTime? createdAt,
}) = _User;
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}
// Usage
final user = User(id: '1', email: 'test@example.com', age: 25);
final updatedUser = user.copyWith(age: 26);
final json = user.toJson();
Manual Implementation¶
import 'package:meta/meta.dart';
@immutable
class User {
final String id;
final String email;
final int age;
final String role;
final DateTime? createdAt;
const User({
required this.id,
required this.email,
required this.age,
this.role = 'user',
this.createdAt,
});
User copyWith({
String? id,
String? email,
int? age,
String? role,
DateTime? createdAt,
}) {
return User(
id: id ?? this.id,
email: email ?? this.email,
age: age ?? this.age,
role: role ?? this.role,
createdAt: createdAt ?? this.createdAt,
);
}
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: json['id'] as String,
email: json['email'] as String,
age: json['age'] as int,
role: json['role'] as String? ?? 'user',
createdAt: json['created_at'] != null
? DateTime.parse(json['created_at'] as String)
: null,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'email': email,
'age': age,
'role': role,
if (createdAt != null) 'created_at': createdAt!.toIso8601String(),
};
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is User &&
other.id == id &&
other.email == email &&
other.age == age &&
other.role == role &&
other.createdAt == createdAt;
}
@override
int get hashCode => Object.hash(id, email, age, role, createdAt);
@override
String toString() => 'User(id: $id, email: $email, age: $age, role: $role)';
}
Async Programming¶
Futures¶
// Async function
Future<User> fetchUser(String id) async {
final response = await http.get(Uri.parse('$baseUrl/users/$id'));
if (response.statusCode == 200) {
return User.fromJson(jsonDecode(response.body));
} else {
throw UserNotFoundException(id);
}
}
// Error handling
Future<void> loadData() async {
try {
final user = await fetchUser('123');
print(user);
} on UserNotFoundException catch (e) {
print('User not found: ${e.id}');
} on SocketException {
print('Network error');
} catch (e, stackTrace) {
print('Unexpected error: $e');
print(stackTrace);
}
}
// Parallel execution
Future<void> loadAll() async {
final results = await Future.wait([
fetchUsers(),
fetchProducts(),
fetchOrders(),
]);
final users = results[0] as List<User>;
final products = results[1] as List<Product>;
final orders = results[2] as List<Order>;
}
Streams¶
// Create stream
Stream<int> countStream(int max) async* {
for (int i = 1; i <= max; i++) {
await Future.delayed(const Duration(seconds: 1));
yield i;
}
}
// Stream transformations
final doubled = countStream(5).map((n) => n * 2);
final evens = countStream(10).where((n) => n.isEven);
// Listen to stream
final subscription = countStream(5).listen(
(value) => print('Value: $value'),
onError: (error) => print('Error: $error'),
onDone: () => print('Done'),
);
// Cancel subscription
await subscription.cancel();
// StreamController
class UserService {
final _userController = StreamController<User>.broadcast();
Stream<User> get userStream => _userController.stream;
void updateUser(User user) {
_userController.add(user);
}
void dispose() {
_userController.close();
}
}
Flutter Widgets¶
Stateless Widget¶
class UserCard extends StatelessWidget {
final User user;
final VoidCallback? onTap;
const UserCard({
super.key,
required this.user,
this.onTap,
});
@override
Widget build(BuildContext context) {
return Card(
child: ListTile(
leading: CircleAvatar(
child: Text(user.email[0].toUpperCase()),
),
title: Text(user.email),
subtitle: Text('Age: ${user.age}'),
trailing: const Icon(Icons.chevron_right),
onTap: onTap,
),
);
}
}
Stateful Widget¶
class CounterWidget extends StatefulWidget {
final int initialValue;
const CounterWidget({
super.key,
this.initialValue = 0,
});
@override
State<CounterWidget> createState() => _CounterWidgetState();
}
class _CounterWidgetState extends State<CounterWidget> {
late int _count;
@override
void initState() {
super.initState();
_count = widget.initialValue;
}
void _increment() {
setState(() {
_count++;
});
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Count: $_count',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _increment,
child: const Text('Increment'),
),
],
);
}
}
State Management with Riverpod¶
import 'package:flutter_riverpod/flutter_riverpod.dart';
// Simple provider
final counterProvider = StateProvider<int>((ref) => 0);
// Async provider
final userProvider = FutureProvider.family<User, String>((ref, id) async {
final repository = ref.watch(userRepositoryProvider);
return repository.getUser(id);
});
// Notifier provider
class UserListNotifier extends AsyncNotifier<List<User>> {
@override
Future<List<User>> build() async {
return ref.watch(userRepositoryProvider).getUsers();
}
Future<void> addUser(User user) async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
await ref.read(userRepositoryProvider).createUser(user);
return ref.read(userRepositoryProvider).getUsers();
});
}
}
final userListProvider =
AsyncNotifierProvider<UserListNotifier, List<User>>(UserListNotifier.new);
// Usage in widget
class UserListScreen extends ConsumerWidget {
const UserListScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final usersAsync = ref.watch(userListProvider);
return usersAsync.when(
data: (users) => ListView.builder(
itemCount: users.length,
itemBuilder: (context, index) => UserCard(user: users[index]),
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Center(child: Text('Error: $error')),
);
}
}
Testing¶
Unit Tests¶
import 'package:test/test.dart';
import 'package:mocktail/mocktail.dart';
class MockUserRepository extends Mock implements UserRepository {}
void main() {
group('UserService', () {
late UserService service;
late MockUserRepository mockRepository;
setUp(() {
mockRepository = MockUserRepository();
service = UserService(repository: mockRepository);
});
test('getUser returns user when found', () async {
// Arrange
final user = User(id: '1', email: 'test@example.com', age: 25);
when(() => mockRepository.getUser('1')).thenAnswer((_) async => user);
// Act
final result = await service.getUser('1');
// Assert
expect(result, equals(user));
verify(() => mockRepository.getUser('1')).called(1);
});
test('getUser throws when not found', () async {
when(() => mockRepository.getUser('999'))
.thenThrow(UserNotFoundException('999'));
expect(
() => service.getUser('999'),
throwsA(isA<UserNotFoundException>()),
);
});
});
}
Widget Tests¶
import 'package:flutter_test/flutter_test.dart';
void main() {
group('UserCard', () {
testWidgets('displays user information', (tester) async {
final user = User(id: '1', email: 'test@example.com', age: 25);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: UserCard(user: user),
),
),
);
expect(find.text('test@example.com'), findsOneWidget);
expect(find.text('Age: 25'), findsOneWidget);
});
testWidgets('calls onTap when tapped', (tester) async {
var tapped = false;
final user = User(id: '1', email: 'test@example.com', age: 25);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: UserCard(
user: user,
onTap: () => tapped = true,
),
),
),
);
await tester.tap(find.byType(UserCard));
expect(tapped, isTrue);
});
});
}
Error Handling¶
Custom Exceptions¶
sealed class AppException implements Exception {
const AppException(this.message);
final String message;
@override
String toString() => message;
}
class NetworkException extends AppException {
const NetworkException([super.message = 'Network error occurred']);
}
class ValidationException extends AppException {
const ValidationException(super.message, {this.field});
final String? field;
}
class UserNotFoundException extends AppException {
const UserNotFoundException(this.userId)
: super('User not found');
final String userId;
}
// Usage
Future<User> getUser(String id) async {
try {
final response = await api.get('/users/$id');
return User.fromJson(response.data);
} on DioException catch (e) {
if (e.response?.statusCode == 404) {
throw UserNotFoundException(id);
}
throw NetworkException(e.message ?? 'Network error');
}
}
Result Type Pattern¶
sealed class Result<T> {
const Result();
}
class Success<T> extends Result<T> {
const Success(this.value);
final T value;
}
class Failure<T> extends Result<T> {
const Failure(this.error, [this.stackTrace]);
final Object error;
final StackTrace? stackTrace;
}
// Usage
Future<Result<User>> getUser(String id) async {
try {
final user = await repository.getUser(id);
return Success(user);
} catch (e, st) {
return Failure(e, st);
}
}
// Handle result
final result = await getUser('123');
switch (result) {
case Success(:final value):
print('User: ${value.email}');
case Failure(:final error):
print('Error: $error');
}
Configuration¶
analysis_options.yaml¶
include: package:flutter_lints/flutter.yaml
# Or for Dart: package:lints/recommended.yaml
analyzer:
language:
strict-casts: true
strict-inference: true
strict-raw-types: true
errors:
missing_required_param: error
missing_return: error
exclude:
- "**/*.g.dart"
- "**/*.freezed.dart"
linter:
rules:
- always_declare_return_types
- avoid_dynamic_calls
- avoid_print
- avoid_type_to_string
- cancel_subscriptions
- close_sinks
- prefer_const_constructors
- prefer_const_declarations
- prefer_final_fields
- prefer_final_locals
- require_trailing_commas
- unawaited_futures
- unnecessary_await_in_return
Common Pitfalls¶
Avoid These¶
// Using dynamic
dynamic user = fetchUser();
// Not awaiting futures
fetchData(); // Fire and forget bug
// Using ! without checking
String name = nullableString!;
// Mutable state in providers
final usersProvider = Provider((ref) => []);
// Building widgets in initState
@override
void initState() {
super.initState();
// Don't use context here!
}
Do This Instead¶
// Use proper types
final User user = await fetchUser();
// Await or handle futures
await fetchData();
// Or intentionally ignore
unawaited(logAnalytics());
// Check null first
if (nullableString != null) {
String name = nullableString;
}
// Use proper state management
final usersProvider = StateProvider<List<User>>((ref) => []);
// Use didChangeDependencies for context
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Safe to use context here
}