Dart Tutorial
🇺🇸

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

KindStyleExample
Classes, enums, typedefs, extensionsUpperCamelCaseShoppingCart, OrderStatus
Variables, functions, parameters, constantslowerCamelCasetotalPrice, maxRetries
Files, folders, packagessnake_caseshopping_cart.dart
Private membersleading __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 finally to 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 await a Future, return it, or mark it on purpose with unawaited() from dart:async. A forgotten future hides its errors.
  • Do not mark a function async if it never awaits anything.
  • Run independent work at the same time with Future.wait, not one after another.
  • Put try / catch around await calls 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 StringBuffer inside loops, not +.
  • Use a Set for “does it contain?” checks on large collections: it is much faster than a List.
  • Prefer const widgets and objects: they are created once.
  • Move heavy CPU work off the main isolate with Isolate.run so 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.
  • final everywhere it can be; const for 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, == and hashCode.
  • Every future is awaited, returned or unawaited() on purpose.
  • No empty catch; specific exceptions with messages.
  • dart format, dart analyze and dart test all pass.

Try it yourself

  1. Take a program you wrote earlier and run dart analyze on it. Fix every warning.
  2. Rewrite one class from your code as an immutable class with copyWith.
  3. Find two await calls in a row that do not depend on each other and run them with Future.wait. Measure the time before and after with Stopwatch.

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.

Practise in the playground Updated

Stuck? Look up your error message, check a term in the glossary, or send us your question.