Effective Dart & Idioms

This is the capstone of the Dart track. By the end you will be able to read the official Effective Dart guides (Style, Documentation, Usage, Design) and explain WHY each rule exists, recognise un-idiomatic code on sight and rewrite it, wire up lints that catch real bugs before they ship, and refactor a messy real-world program into clean Dart while proving — not assuming — that its behaviour didn't change.

1. Style guide — naming & formatting

Think of a shared codebase like a street where every house is numbered and labelled by the SAME convention — even-numbered houses on one side, street names in one font size. You could find any house even in a city you've never visited before, because the convention is universal, not personal. Effective Dart's Style guide is that convention for identifiers: once you know the four rules, you can predict the correct name for anything without ever having seen this codebase before.

The rules are mechanical, not aesthetic opinions: UpperCamelCase for classes, enums, typedefs, extensions and type parameters (UserAccount, T); lowerCamelCase for variables, parameters, methods, and (yes, even) constants (maxRetries, not MAX_RETRIES); lowercase_with_underscores for file and package names (user_account.dart). Formatting — indentation, line length, where to break a long line, whether a trailing comma forces one argument per line — is not something you decide by hand at all: you run dart format . (or enable format-on-save) and it rewrites every file into the one canonical layout. This ends style arguments in code review entirely, because there is no second valid layout to argue for.

Rule: UpperCamelCase types, lowerCamelCase everything else

Lines ending in // <- lint: name are exactly the lines dart analyze reports for that lint; verify/d21.dart re-runs the real analyzer on this panel and fails if the markers and the analyzer ever disagree. (A lint is an automatic warning about code that compiles but is unidiomatic or usually a mistake; the analyzer is the checker built into the Dart tools and your editor that produces those warnings. Section 5 explains both properly.)

Rule: lowercase_with_underscores file names

The file_names lint flags UserAccount.dart and user-account.dart and accepts user_account.dart; verify/d21.dart confirms that with dart analyze.

Rule: let dart format own the layout

This panel really runs dart format on a messy file: the first run changes it, the second run changes nothing.

Writing new Widget() still compiles — but since Dart 2, new is entirely optional, and idiomatic modern Dart drops it (the unnecessary_new lint flags it where enabled). It adds four characters and zero information, since a call that isn't a function/method call and points at a type name is unambiguously a constructor call anyway.

The new rule as a do/don't pair:

Rule: drop new

UpperCamelCase for types, lowerCamelCase for everything else (including constants), lowercase_with_underscores for filenames — and let dart format own every whitespace decision so nobody has to.

2. Documentation guide — /// doc comments

A // comment is a sticky note left for whoever opens THIS file next — a private note between maintainers. A /// doc comment is more like the label printed on a medicine box: it ships WITH the public product, shows up in the IDE's hover tooltip and in generated documentation websites, and is the only thing most callers of your code will ever read before using it.

Every public member worth using from outside its own file should get a /// comment whose first sentence is a short, complete summary ending in a period — tools like dartdoc (the program that turns your /// comments into a documentation website) show ONLY that first sentence in member lists, so it must make sense standing alone. Document parameters that aren't obvious from their name/type, document what a function throws, and reference other identifiers in square brackets ([celsius]) so generated docs can link to them. Don't bother documenting something whose name already says everything (a getter called isEmpty needs no essay) — documentation earns its keep by adding information a reader couldn't already guess.

Rule: /// for public members, a one-paragraph summary first

Lines ending in // <- lint: name are exactly the lines dart analyze reports for that lint; verify/d21.dart re-runs the real analyzer on this panel and fails if the markers and the analyzer ever disagree. (public_member_api_docs needs package resolution, so verify runs dart pub get --offline for this one panel.)

A doc comment that just repeats the function's name in sentence form ("Gets the name.") adds nothing. A useful one explains behaviour the signature can't express: what happens on empty input, whether it mutates its argument, what exception it can throw and when.
/// for anything public — its first sentence is a short summary usable on its own; // stays private, for implementation notes only.

3. Usage guide — collections, null-awareness, cascades, control flow, async

The Usage guide is a big collection of "there are two ways to write this, and Dart has strong opinions about which one is better" rules. Each one, alone, looks tiny — but a codebase that consistently follows all of them reads noticeably cleaner than one that doesn't, the same way a paragraph with no typos reads faster than one with two typos per sentence, even though any single typo is a small thing.

Collection literals, spread (...) and collection-if

Building a list by declaring an empty one and calling .add() in a loop works, but it hides the SHAPE of the final list behind imperative steps. Effective Dart prefers building the whole collection as one literal, using if and for directly inside the brackets, and ... (spread) to inline another collection's elements:

Rule: literals, spread and collection-if

Lines ending in // <- lint: name are exactly the lines dart analyze reports for that lint; verify/d21.dart re-runs the real analyzer on this panel and fails if the markers and the analyzer ever disagree.

Rule: isEmpty / isNotEmpty over length checks

Lines ending in // <- lint: name are exactly the lines dart analyze reports for that lint; verify/d21.dart re-runs the real analyzer on this panel and fails if the markers and the analyzer ever disagree. On a lazy filtered Iterable the difference is real: length has to test every element, isEmpty stops after the first.

Input size → what is feasible: n ≤ 106 elements → every rewrite here is one O(n) pass; the idiomatic form changes readability, not the complexity.

The null-aware family follows the same spirit — say what you mean in one expression instead of an if/else: name ?? 'stranger' ("use name, or this default if it's null"), user?.email ("read .email only if user isn't null, otherwise the whole expression is null"), and cache ??= computeExpensiveDefault() ("assign only if currently null").

// ❌ before
String greet(String? name) {
  if (name == null) {
    return 'Hello, stranger';
  } else {
    return 'Hello, ' + name;
  }
}

// ✅ after
String greet(String? name) => 'Hello, ${name ?? 'stranger'}';

Verified equal for both a null and a non-null name in verify/d21.dart.

Rule: null-aware operators over null if/else

Lines ending in // <- lint: name are exactly the lines dart analyze reports for that lint; verify/d21.dart re-runs the real analyzer on this panel and fails if the markers and the analyzer ever disagree.

Cascades, final locals, arrow bodies

A cascade (..) lets you fire several calls at the SAME receiver without repeating its name — the expression still evaluates to the receiver itself, not to any call's return value. Effective Dart's Usage guide accepts two consistent local-variable conventions: var for every local, or final for every local that's never reassigned (and var for the rest, which is what this course uses) — the prefer_final_locals lint enforces the second style where a team wants it mandatory. Either way, final documents intent and lets the analyzer catch an accidental second assignment. A function whose entire body is one expression reads better as => expr; than as a { return expr; } block.

Rule: cascades over repeated receivers

Lines ending in // <- lint: name are exactly the lines dart analyze reports for that lint; verify/d21.dart re-runs the real analyzer on this panel and fails if the markers and the analyzer ever disagree.

Rule: final locals and => bodies

Lines ending in // <- lint: name are exactly the lines dart analyze reports for that lint; verify/d21.dart re-runs the real analyzer on this panel and fails if the markers and the analyzer ever disagree.

late misuse and is! / patterns

late means "trust me, this will be assigned before anyone reads it" — a promise the compiler CANNOT check. Reaching for late just to avoid writing a constructor parameter turns a compile-time-safe program into one that can crash at runtime. And when you need "is NOT this type", Dart has a dedicated is! operator — !(x is Foo) works, but x is! Foo is the idiomatic form (the prefer_is_not_operator lint flags the negated version where enabled). Patterns (D11) go one step further, testing a type and pulling out its fields in a single if (x case Foo(field: final f)).

Rule: avoid late as a shortcut

There is no official lint for late misuse, so the proof here is the run-time failure itself.

Rule: is! over !(x is T), and patterns

Lines ending in // <- lint: name are exactly the lines dart analyze reports for that lint; verify/d21.dart re-runs the real analyzer on this panel and fails if the markers and the analyzer ever disagree.

async/await over raw .then chains

Chaining .then((v) { ... }) calls nests a new anonymous function for every asynchronous step — two steps need two levels of indentation, three need three ("callback hell", first introduced in D14). Marking a function async lets each step be written as a plain statement with await in front, at the SAME indentation, reading top to bottom exactly like synchronous code while behaving identically underneath.

Rule: async/await over .then chains

No lint is needed for this one: the two versions are provably the same and the second is flat.

Strings, types, errors, printing

Four more Usage rules that the earlier lessons already made visible: build strings with interpolation ('Hello, $name') instead of + chains; leave out type annotations the compiler already knows (omit_local_variable_types); catch specific exceptions and never swallow one silently; and keep print out of library code (inject a logger).

Rule: interpolation over + chains

Lines ending in // <- lint: name are exactly the lines dart analyze reports for that lint; verify/d21.dart re-runs the real analyzer on this panel and fails if the markers and the analyzer ever disagree.

Rule: no redundant local type annotations

Lines ending in // <- lint: name are exactly the lines dart analyze reports for that lint; verify/d21.dart re-runs the real analyzer on this panel and fails if the markers and the analyzer ever disagree.

Rule: catch specific exceptions, never swallow

Lines ending in // <- lint: name are exactly the lines dart analyze reports for that lint; verify/d21.dart re-runs the real analyzer on this panel and fails if the markers and the analyzer ever disagree.

Rule: no print in library code

Lines ending in // <- lint: name are exactly the lines dart analyze reports for that lint; verify/d21.dart re-runs the real analyzer on this panel and fails if the markers and the analyzer ever disagree. The single // ignore: avoid_print in the panel is only there so this panel can show its own output.

All of these are the SAME transformation in disguise: replace an imperative "how to build/check/sequence this" recipe with a declarative "what this IS" expression. That's the throughline of the entire Usage guide.
Prefer expressions over statements wherever one exists: collection literals with if/for/... over loops, ??/?./??= over null-checking if/else, cascades over repeated receivers, => over one-statement blocks, is! over !(x is T), and async/await over .then chains. Reserve late for fields that genuinely can't be set in the constructor.

4. Design guide — APIs, booleans, illegal states

The Design guide is the only one of the four that isn't about how a single line looks — it's about the SHAPE of the contract you hand to every future caller of your code. A badly designed API is like a light switch with no markings: it works, but every single person who uses it has to go find the wiring diagram (the source code) to know which way is "on".

Name things for what they DO, not how they're implemented (fetchUser, not getUserFromDbOrCache). Prefer returning a real, empty collection over null for "nothing found" — callers can then iterate without a null-check at all. And avoid a positional bool parameter at a call site: schedule(task, true) tells the reader nothing without opening the function signature — this is Effective Dart's "boolean trap". Two clean fixes: a named parameter (schedule(task, urgent: true)) when it's one independent on/off switch, or an enum when there are two-or-more mutually exclusive named states:

Rule: no positional boolean parameters

Lines ending in // <- lint: name are exactly the lines dart analyze reports for that lint; verify/d21.dart re-runs the real analyzer on this panel and fails if the markers and the analyzer ever disagree.

Rule: return an empty collection, not null

No lint exists for this; the cost is the extra ?? 0 or ! every caller must remember.

One of the most powerful ideas in modern, idiomatic Dart — not a single numbered Design guide rule, but a natural extension of the class-modifier tools (sealed, final, base) the Design guide does cover — is making illegal states unrepresentable: instead of a class with several independent nullable/boolean fields (where the TYPE allows nonsensical combinations, like "loading" and "failed" being true at once), model each real state as its own class inside a sealed hierarchy (D11). Then a combination that shouldn't exist simply has no constructor that builds it — the compiler, not a runtime check, is what rules it out.

Rule: make illegal states unrepresentable

verify/d21.dart also confirms with dart analyze that a switch missing a case is a compile error (non_exhaustive_switch_expression).

A common half-measure is a class with a status enum field PLUS separate nullable data/error fields "just in case" — this still lets you build status: success, error: 'oops'. Sealed classes remove the possibility entirely, rather than merely discouraging it by convention.

Three API-design rules that have real lints: define == and hashCode together; mark overrides with @override; give public APIs real types and return types.

Rule: == and hashCode come as a pair

Lines ending in // <- lint: name are exactly the lines dart analyze reports for that lint; verify/d21.dart re-runs the real analyzer on this panel and fails if the markers and the analyzer ever disagree.

Rule: annotate overrides

Lines ending in // <- lint: name are exactly the lines dart analyze reports for that lint; verify/d21.dart re-runs the real analyzer on this panel and fails if the markers and the analyzer ever disagree.

Rule: type your public APIs

Lines ending in // <- lint: name are exactly the lines dart analyze reports for that lint; verify/d21.dart re-runs the real analyzer on this panel and fails if the markers and the analyzer ever disagree.

Design for the reader who will never see your source: self-documenting names, empty collections instead of null, named params/enums instead of boolean traps, and sealed hierarchies so the compiler — not a comment — rules out impossible states.

5. Lints & dart fix

A type checker catches code that is definitely BROKEN — a String where an int is required. A lint catches code that is perfectly valid Dart but is USUALLY a mistake, the way a spell-checker flags "definately" — it isn't a grammar error, but it's almost never what you meant to type.

A new Dart/Flutter project's analysis_options.yaml normally starts from a published rule set rather than listing rules one by one:

include: package:lints/recommended.yaml
# Flutter projects instead include package:flutter_lints/flutter.yaml,
# which builds on package:lints and adds Flutter-specific rules.

linter:
  rules:
    - unawaited_futures
    - avoid_dynamic_calls
    - prefer_const_constructors

package:lints is the official baseline maintained by the Dart team (a core set plus a slightly stricter recommended set, both included by dart create); package:flutter_lints extends recommended with a handful of Flutter-specific rules and is what flutter create wires up by default.

Three lints, three real bugs that still compile

Lines ending in // <- lint: name are exactly the lines dart analyze reports for that lint; verify/d21.dart re-runs the real analyzer on this panel and fails if the markers and the analyzer ever disagree.

Three worth knowing by name: unawaited_futures flags a Future-returning call whose result is neither awaited nor explicitly wrapped in unawaited(...) — the classic way an async side effect silently runs out of order. avoid_dynamic_calls flags any member access through a dynamic-typed expression, because it defers the whole safety net to runtime. prefer_const_constructors flags a constructor call that COULD be const (every argument is already a compile-time constant) but isn't — missing a free canonicalization and, in Flutter, a free widget-rebuild skip.

Running dart fix --dry-run previews every automatically-fixable lint violation in a project; dart fix --apply rewrites the files. Not every lint has a mechanical fix (design-level ones like "make illegal states unrepresentable" never will), but many purely-mechanical ones (missing const, unnecessary new, a redundant type annotation) do.

Enabling lints and applying their fixes, run for real. The first panel writes an analysis_options.yaml and runs the analyzer; the second runs dart fix --dry-run and then --apply on a tiny package:

analysis_options.yaml in action

Everything is a lint at "info" level here, and the program still runs.

dart fix --dry-run, then --apply

A lint violation is not a compile error — dart run still executes code the linter is unhappy with. That's exactly why CI pipelines run dart analyze --fatal-infos (as this course's own qa.py does) to turn lint violations into build failures on purpose.
Start from package:lints/package:flutter_lints rather than authoring rules from scratch; unawaited_futures, avoid_dynamic_calls and prefer_const_constructors each catch a specific, common class of bug that still compiles fine. dart fix --apply auto-applies whatever has a mechanical fix.

6. Idiomatic patterns recap — the whole track, tied together

Every earlier lesson taught one tool. This is the moment you see them as one toolbox: a real function or class rarely uses just ONE of D05/D08/D09/D10/D11/D12/D14's ideas — idiomatic Dart is what it looks like when several of them are applied together, by habit, without having to think about each one separately.
class ImmutableUser {
  final String name;
  final int? age;                 // nullable = genuinely unknown, no magic sentinel
  const ImmutableUser(this.name, {this.age});
  String describeAge() => age == null ? 'age unknown' : 'age $age';
}

extension IntClampPositive on int {
  int get positiveOrZero => this < 0 ? 0 : this;
}

Stream<int> countUpTo(int n) async* {
  for (var i = 1; i <= n; i++) {
    yield i;
  }
}

Every value shown here (describeAge() for a known and an unknown age, the extension for a positive and a negative input, and the full sequence yielded by countUpTo) is checked in verify/d21.dart.

The recap code, run with its exact output

Idiomatic Dart isn't one trick — it's D02's immutability, D05's null safety, D11's records/patterns, D09's extensions and D14's streams, all reached for together, by default, without re-deriving each choice from scratch every time.

7. Code review checklist for Dart

A pilot's pre-flight checklist doesn't exist because pilots are careless — it exists because under time pressure, humans skip steps in a predictable order. A code review checklist does the same job for a reviewer skimming a diff at the end of a long day.
  1. Naming & formatting — types UpperCamelCase, members lowerCamelCase, file dart formatted (CI should enforce this, not a human eyeball).
  2. Public API has /// docs whose first sentence stands alone.
  3. No boolean trap — any new function with more than one bool parameter, or a single bool whose meaning isn't obvious at the call site, should use named parameters or an enum.
  4. Immutable by default — every field/local that is never reassigned is final; every object that could be a compile-time constant is const.
  5. Null safety used deliberately — no drive-by ! added just to silence the analyzer; nullability reflects a real "can be absent" case.
  6. Illegal states unrepresentable — a set of mutually-exclusive states (loading/success/error, connected/disconnected, ...) is a sealed hierarchy, not a bag of nullable fields.
  7. Async correctness — every Future is awaited or deliberately marked with unawaited(...); no raw .then chain where async/await would read more clearly.
  8. Errors aren't swallowed — a bare catch (_) {} that discards the error is a red flag unless there's a comment explaining why it's genuinely safe to ignore.
  9. dart analyze --fatal-infos is clean and every claimed behaviour has a test (or, on this course's pages, a check in verify/*.dart).

Part of this checklist can be mechanical. This tiny reviewer works on source TEXT (a real tool uses the syntax tree, which is why the analyzer lints above are the better choice), but it shows the idea and its exact output:

The checklist as a script

8. Capstone: refactor a messy program into idiomatic Dart

This is the whole lesson applied to one program at once: not "here is rule #7 in isolation" but "here is a real, working, slightly ugly 40-line program, and here is the exact sequence of Effective Dart rules that turns it into something a senior reviewer would approve without comment — while it computes the EXACT same answer, verified, not assumed."

The starting point is a small order-processing class: unidiomatic naming, a boolean flag instead of a named parameter, an ad-hoc Map standing in for a real type, manual index loops, and string concatenation with +. Nothing in it is actually broken — it runs and gives the right totals — which is exactly the point: idiomatic Dart is a readability and safety upgrade, not a correctness fix.

The capstone, before and after, compared by running both

Both versions are run on the same inputs; the discounted total 22.5 is 25.0 − 10%.

Every step below changes HOW the code reads, never WHAT it computes. verify/d21.dart runs both the original order_processor and the refactored OrderProcessor on the same two items with a discount, and again on one item with no discount, and asserts the two produce byte-identical report() strings both times — including the exact discounted total, $22.5.
A refactor toward idiomatic Dart is a series of small, individually-justifiable rule applications (naming, real types instead of maps, named params instead of booleans, expressions instead of loops, cascades instead of repeated receivers) — and the only way to be sure none of them changed behaviour is to actually run both versions and compare, not to eyeball the diff.

Quiz

Interview questions

Cheat sheet

GuideRule
StyleUpperCamelCase types, lowerCamelCase everything else (incl. constants), lowercase_with_underscores files; run dart format, drop new
Documentation/// for public members; first sentence is a standalone summary; reference identifiers with [name]
Usagecollection if/for/... over build-then-mutate loops; ??/?./??= over null if/else; cascades (..) over repeated receivers; final locals; => for one-line bodies; is! over !(x is T); async/await over .then
Usage — lateonly for fields that truly can't be set in the constructor; misuse throws LateInitializationError at runtime if read before assigned
Designname by behaviour; return empty collections, not null; named params/enum instead of a positional bool; sealed classes to make illegal states unrepresentable
Lintspackage:lints/package:flutter_lints baseline; unawaited_futures, avoid_dynamic_calls, prefer_const_constructors catch real bugs that still compile
dart fix--dry-run previews, --apply rewrites every mechanically-fixable lint violation
CI gatedart analyze --fatal-infos turns lint violations into build failures
Recapimmutability + null safety + records/patterns + extensions + streams, applied together by default