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
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.
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
dart format own every whitespace decision so nobody has to.2. Documentation guide — /// doc comments
// 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.)
/// 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
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.
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
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).
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.
5. Lints & dart fix
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
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.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
- Immutability by default (D02/D09): reach for
final/constfirst; make a field mutable only when you have a concrete reason. An object that can't change after construction can't be corrupted by code you haven't read yet. - Null safety used deliberately (D05): a nullable type (
String?) means "this can genuinely be absent" — not "I didn't feel like initializing this yet".!(the null-assertion operator) is a promise to the compiler that should be rare and load-bearing, not a routine way to silence errors. - Records & patterns (D11): a record (
(String, int)) is a lightweight, immutable, structurally-typed way to return more than one value without declaring a whole class; pattern matching (switchexpressions,case) destructures data and gets exhaustiveness-checked for free over sealed types. - Extension methods (D09): add a focused, readable helper to a type you don't own (even
int/String) without subclassing or wrapping it. - Async streams (D14): an
async*generator function turns "produce values over time, possibly many of them" into a plain loop withyield— the consumer just doesawait for, no manual callback bookkeeping.
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
7. Code review checklist for Dart
- Naming & formatting — types UpperCamelCase, members lowerCamelCase, file
dart formatted (CI should enforce this, not a human eyeball). - Public API has
///docs whose first sentence stands alone. - No boolean trap — any new function with more than one
boolparameter, or a singleboolwhose meaning isn't obvious at the call site, should use named parameters or an enum. - Immutable by default — every field/local that is never reassigned is
final; every object that could be a compile-time constant isconst. - Null safety used deliberately — no drive-by
!added just to silence the analyzer; nullability reflects a real "can be absent" case. - Illegal states unrepresentable — a set of mutually-exclusive states (loading/success/error, connected/disconnected, ...) is a
sealedhierarchy, not a bag of nullable fields. - Async correctness — every
Futureisawaited or deliberately marked withunawaited(...); no raw.thenchain whereasync/awaitwould read more clearly. - 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. dart analyze --fatal-infosis clean and every claimed behaviour has a test (or, on this course's pages, a check inverify/*.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
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%.
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.Quiz
Interview questions
Cheat sheet
| Guide | Rule |
|---|---|
| Style | UpperCamelCase 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] |
| Usage | collection 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 — late | only for fields that truly can't be set in the constructor; misuse throws LateInitializationError at runtime if read before assigned |
| Design | name by behaviour; return empty collections, not null; named params/enum instead of a positional bool; sealed classes to make illegal states unrepresentable |
| Lints | package: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 gate | dart analyze --fatal-infos turns lint violations into build failures |
| Recap | immutability + null safety + records/patterns + extensions + streams, applied together by default |