Dart Advanced Lesson 115 of 115 8 min read
Dart Best Practices: Write Clean, Safe and Fast Dart Code
Dart best practices with before-and-after examples: naming, final and const, null safety, collections, functions, classes, errors, async and tooling.
On this page
Code is read far more often than it is written. These rules make Dart code easier to read, harder to break and faster to run. Each one shows the code to avoid and the code to prefer, and every full example runs in the browser.
You need the basics first: variables, functions, classes and null safety.
1. Name things for what they are
| Kind | Style | Example |
|---|---|---|
| Classes, enums, typedefs, extensions | UpperCamelCase | ShoppingCart, OrderStatus |
| Variables, functions, parameters, constants | lowerCamelCase | totalPrice, maxRetries |
| Files, folders, packages | snake_case | shopping_cart.dart |
| Private members | leading _ | _cache |
Good names describe the meaning, not the type. A bool reads like a question.
// Avoid
var d = 7;
var list1 = ['pen', 'book'];
bool flag = true;
// Prefer
const deliveryDays = 7;
final cartItems = ['pen', 'book'];
bool isLoggedIn = true;
2. Use final by default, const when you can
final stops a variable from being reassigned by mistake. const goes further: the value is fixed when the program is compiled, and identical const objects are shared.
void main() {
const taxRate = 0.13; // known before the program runs
final orderedAt = DateTime.now(); // known only at run time
var itemsInCart = 0; // really changes
itemsInCart += 2;
print('Tax ${taxRate * 100}% on $itemsInCart items, ordered at ${orderedAt.hour}h');
}
Write the type when the right-hand side does not make it clear, and always on public APIs: function parameters, return types and fields.
3. Keep null safety strict
The ! operator says “trust me, this is not null”. If you are wrong, the program crashes. Handle the null case instead.
String greet(String? name) {
// Avoid: return 'Hello, ${name!}'; crashes when name is null
if (name == null || name.isEmpty) return 'Hello, guest';
return 'Hello, $name';
}
void main() {
print(greet('Asha'));
print(greet(null));
final Map<String, int> stock = {'pen': 12};
final books = stock['book'] ?? 0; // a default instead of !
print('Books in stock: $books');
String? nickname;
print(nickname?.toUpperCase() ?? 'No nickname'); // ?. skips the call when null
}
Use late only when you are certain the value is set before it is read, for example in Flutter’s initState. See null-aware operators and late variables.
4. Write collections the Dart way
void main() {
final isMember = true;
final extras = ['gift wrap'];
// Avoid: building a list step by step with add() and if statements.
// Prefer: a literal with collection if, collection for and spread.
final lines = [
'Notebook',
if (isMember) 'Member discount',
...extras,
for (var i = 1; i <= 2; i++) 'Sticker $i',
];
print(lines);
// Prefer isEmpty / isNotEmpty over length checks.
if (lines.isNotEmpty) print('First: ${lines.first}');
// Transform with where and map instead of manual loops.
final prices = [120, 45, 300, 80];
final expensive = prices.where((p) => p > 100).map((p) => 'Rs. $p').toList();
print(expensive);
}
Give empty collections a type: <String>[], <String, int>{}. Without it Dart infers List<dynamic> and loses type checks.
5. Keep functions small and clear
- One function, one job. If you need the word “and” to describe it, split it.
- Use named parameters when there are more than two arguments or when the meaning is not obvious at the call site. Mark the ones that must be passed as
required. - Use
=>for functions that are a single expression.
// Avoid: createUser('Asha', 'Pokhara', true, false) — what are true and false?
String createUser({required String name, required String city, bool isAdmin = false}) =>
'$name from $city${isAdmin ? ' (admin)' : ''}';
void main() {
print(createUser(name: 'Asha', city: 'Pokhara'));
print(createUser(name: 'Bikram', city: 'Kathmandu', isAdmin: true));
}
6. Make data classes immutable
A class whose fields never change is safe to share and easy to reason about. Give it final fields, a const constructor and a copyWith method for changes.
class Product {
const Product({required this.name, required this.price, this.inStock = true});
final String name;
final double price;
final bool inStock;
Product copyWith({String? name, double? price, bool? inStock}) => Product(
name: name ?? this.name,
price: price ?? this.price,
inStock: inStock ?? this.inStock,
);
@override
bool operator ==(Object other) =>
other is Product && other.name == name && other.price == price && other.inStock == inStock;
@override
int get hashCode => Object.hash(name, price, inStock);
@override
String toString() => 'Product($name, Rs. $price, inStock: $inStock)';
}
void main() {
const pen = Product(name: 'Pen', price: 25);
final onSale = pen.copyWith(price: 20);
print(pen);
print(onSale);
print(pen == pen.copyWith()); // true: same values
}
For small groups of values that need no methods, a record such as (String name, int age) is even shorter, and records already compare by value.
7. Model states with sealed classes and exhaustive switch
When a value can be one of a fixed set of shapes, a sealed class lets the compiler check that every case is handled.
sealed class PaymentResult {}
class Paid extends PaymentResult {
Paid(this.receiptId);
final String receiptId;
}
class Declined extends PaymentResult {
Declined(this.reason);
final String reason;
}
class Pending extends PaymentResult {}
String describe(PaymentResult r) => switch (r) {
Paid(:final receiptId) => 'Paid, receipt $receiptId',
Declined(:final reason) => 'Declined: $reason',
Pending() => 'Waiting for the bank',
};
void main() {
for (final r in [Paid('R-1042'), Declined('card expired'), Pending()]) {
print(describe(r));
}
}
Add a new subclass later and every switch that forgets it becomes a compile error, not a bug in production. See sealed classes and switch expressions.
8. Handle errors on purpose
- Throw specific exceptions with a clear message, not plain strings.
- Catch only what you can handle, and catch the specific type with
on. - Never leave an empty
catch. Log it, or let it reach a place that can report it. - Use
finallyto release resources.
class InsufficientFunds implements Exception {
InsufficientFunds(this.needed);
final double needed;
@override
String toString() => 'InsufficientFunds: need Rs. $needed more';
}
double withdraw(double balance, double amount) {
if (amount <= 0) throw ArgumentError.value(amount, 'amount', 'must be positive');
if (amount > balance) throw InsufficientFunds(amount - balance);
return balance - amount;
}
void main() {
try {
print(withdraw(500, 200));
print(withdraw(500, 800));
} on InsufficientFunds catch (e) {
print('Cannot withdraw: $e');
} finally {
print('Transaction finished');
}
}
See exceptions and custom exceptions.
9. Write async code that does not get lost
- Always
awaitaFuture, return it, or mark it on purpose withunawaited()fromdart:async. A forgotten future hides its errors. - Do not mark a function
asyncif it never awaits anything. - Run independent work at the same time with
Future.wait, not one after another. - Put
try/catcharoundawaitcalls that can fail.
Future<int> fetchStock(String item) async {
await Future.delayed(const Duration(milliseconds: 300)); // pretend network call
return item.length * 3;
}
Future<void> main() async {
final sw = Stopwatch()..start();
// Avoid: three awaits in a row take about 900 ms.
// Prefer: run them together, about 300 ms.
final results = await Future.wait([fetchStock('pen'), fetchStock('book'), fetchStock('bag')]);
print('Stock: $results in ${sw.elapsedMilliseconds} ms');
try {
await Future<void>.error(StateError('server offline'));
} catch (e) {
print('Handled: $e');
}
}
See async and await, running futures in parallel and async error handling.
10. Let the tools check your code
Run these in every project, and before every commit:
dart format . # one consistent layout for everyone
dart analyze # type errors, lints and hints
dart fix --apply # applies many suggested fixes for you
dart test # your unit tests
Turn on the recommended lint rules in analysis_options.yaml:
include: package:lints/recommended.yaml
linter:
rules:
- prefer_final_locals
- avoid_print # use a logger in real apps
- unawaited_futures
Add the package once with dart pub add --dev lints. Flutter projects use package:flutter_lints/flutter.yaml instead. See formatting and linting and testing.
11. Small habits that keep code fast
- Build long strings with
StringBufferinside loops, not+. - Use a
Setfor “does it contain?” checks on large collections: it is much faster than aList. - Prefer
constwidgets and objects: they are created once. - Move heavy CPU work off the main isolate with
Isolate.runso apps stay responsive.
void main() {
final buffer = StringBuffer();
for (var i = 1; i <= 5; i++) {
buffer.write('Line $i; ');
}
print(buffer.toString());
final blocked = {'spam@example.com', 'bot@example.com'};
print(blocked.contains('bot@example.com')); // fast lookup
}
More in const and performance, benchmarking and isolates.
A checklist for code review
- Names say what things are; booleans read like questions.
-
finaleverywhere it can be;constfor fixed values. - No
!without a clear reason; null cases handled. - Functions do one job; more than two arguments use named parameters.
- Data classes are immutable with
copyWith,==andhashCode. - Every future is awaited, returned or
unawaited()on purpose. - No empty
catch; specific exceptions with messages. -
dart format,dart analyzeanddart testall pass.
Try it yourself
- Take a program you wrote earlier and run
dart analyzeon it. Fix every warning. - Rewrite one class from your code as an immutable class with
copyWith. - Find two
awaitcalls in a row that do not depend on each other and run them withFuture.wait. Measure the time before and after withStopwatch.
Next: practise with the Dart interview questions or start building apps with the Flutter roadmap.
What are the most important Dart best practices?
Use final by default, keep null safety strict (avoid the ! operator), give names that say what things are, keep functions small with named parameters, make data classes immutable, always await or handle futures, and run dart format and dart analyze before every commit.
Should I use var, final or a type in Dart?
Use final for local variables that never change, which is most of them. Use var only when the value really changes. Write the type when the right-hand side does not make it obvious, and always on public APIs such as function parameters and return types.
How do I enforce Dart best practices automatically?
Add the lints package and include package:lints/recommended.yaml in analysis_options.yaml. Then dart analyze reports problems, dart fix --apply fixes many of them, and dart format keeps the layout consistent.