Skip to content

Dart Language Tour

Dart is statically typed with strong inference. Use var and let Dart infer, or annotate when it aids clarity. final means assign-once; const means a compile-time constant.

var name = 'Ada'; // inferred String
final age = 36; // assign-once int
const pi = 3.14159; // compile-time constant
int? maybe; // nullable (null safety — next lesson)
// `final` vs `const`: final is runtime-fixed, const is compile-time-fixed.
final now = DateTime.now(); // OK — computed at runtime
// const now = DateTime.now(); // ERROR — not a compile-time constant

const matters enormously in Flutter: a const value is canonicalized — identical const objects are the same instance — which is what lets Flutter skip rebuilding const widgets.

Dart functions support positional and named parameters. Flutter’s entire API is built on named parameters — every widget constructor is a wall of them.

// Named parameters in {}, `required` marks the mandatory ones, others have defaults.
Widget greeting({required String name, String prefix = 'Hello'}) {
return Text('$prefix, $name');
}
greeting(name: 'Ada'); // Hello, Ada
greeting(name: 'Ada', prefix: 'Hi'); // Hi, Ada
// Arrow syntax for single-expression functions
int square(int x) => x * x;

This is why reading Flutter code means reading Widget(child: ..., padding: ..., color: ...) — named parameters make big constructors readable and order-independent.

Lists, sets, and maps, plus collection-if and collection-for — used constantly to build widget lists conditionally.

final nums = [1, 2, 3];
final set = {1, 2, 3};
final map = {'a': 1, 'b': 2};
// Spread, collection-if, collection-for — you'll use these inside widget children:
final children = [
const Header(),
...items, // spread
if (isLoggedIn) const Profile(), // conditional element
for (final n in nums) Text('$n'), // generated elements
];

That if/for-inside-a-list pattern is idiomatic Flutter — it replaces the awkward “build a list then push to it” you might reach for in other languages.

The cascade .. runs several operations on the same object without repeating it — common in configuration-style code.

final paint = Paint()
..color = const Color(0xFF027DFD)
..strokeWidth = 4
..style = PaintingStyle.stroke;
// Each `..` returns the same Paint, so this configures one object.

Records: multiple return values without a wrapper class

Section titled “Records: multiple return values without a wrapper class”

Records (Dart 3.0 — “Records require a language version of at least 3.0”) are anonymous, immutable, structurally-typed aggregates. They fix a real gap: returning two or three related values used to mean either an ad hoc List/Map (untyped) or a one-off wrapper class (boilerplate). A record is neither — it’s a genuine compile-time-checked type with no declaration required.

Try it live — the block below runs the exact functions and values proven by the test above, through DartPad’s real Dart runtime, not a canned transcript:

Records: positional & named, destructuring, equality

Loading DartPad…

lib/records_basics.dart
// Positional + named records, destructuring, equality, multiple-return use.
typedef Point = (double x, double y); // positional record type alias
(double, double) minMax(List<double> values) {
var lo = values.first, hi = values.first;
for (final v in values) {
if (v < lo) lo = v;
if (v > hi) hi = v;
}
return (lo, hi); // multiple return — no wrapper class needed
}
({String name, int age}) parsePerson(Map<String, Object?> json) {
return (name: json['name']! as String, age: json['age']! as int); // named record
}
test/records_basics_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:probe/lessons/dart-foundations__dart-language-tour/records_basics.dart';
void main() {
test('positional record: destructuring a multiple return', () {
final (low, high) = minMax([3, 1, 4, 1, 5, 9]);
expect(low, 1);
expect(high, 9);
});
test('named record: field-shorthand destructuring', () {
final person = parsePerson({'name': 'Ada', 'age': 36});
final (:name, :age) = person;
expect(name, 'Ada');
expect(age, 36);
});
test('records always compare structurally with ==, and const records are canonicalized', () {
const a = (1, 2);
const b = (1, 2);
expect(a == b, isTrue); // == compares field-by-field, like a data class
expect(identical(a, b), isTrue); // const records ARE canonicalized, like const objects
// A non-const record built at runtime is a fresh instance...
int notConst() => 1;
final c = (notConst(), 2);
final d = (notConst(), 2);
expect(identical(c, d), isFalse); // ...so identity differs...
expect(c == d, isTrue); // ...but == still compares structurally, not by identity.
});
test('a typedef names a record shape, unwrapped with positional getters', () {
const Point p = (1.0, 2.0);
expect(p.$1, 1.0);
expect(p.$2, 2.0);
});
}
✓ positional record: destructuring a multiple return
✓ named record: field-shorthand destructuring
✓ records always compare structurally with ==, and const records are canonicalized
✓ a typedef names a record shape, unwrapped with positional getters
00:00 +4: All tests passed!

The canonicalization result is the one worth remembering: const (1, 2) and another const (1, 2) are identical — the same object — exactly like const widgets. A record built at runtime ((notConst(), 2)) is a fresh instance every time, but == still compares field-by-field either way, so a record makes a genuinely useful, allocation-cheap key or return value.

Pattern matching: destructure, match, and let the analyzer prove you covered every case

Section titled “Pattern matching: destructure, match, and let the analyzer prove you covered every case”

Patterns (Dart 3.0 — same language-version requirement as records) generalize destructuring far beyond final (a, b) = pair. A pattern can match on a value’s shape — its runtime type, its fields, its length — not just bind a name to it.

Try it live — same Shape/Status types and cases the test above checks, driven through DartPad’s real analyzer and runtime:

Patterns: switch expressions, object/list/map patterns, guards, if-case

Loading DartPad…

lib/patterns_basics.dart
// Switch expression/statement, object/list/map patterns, guards, if-case.
sealed class Shape {}
final class Circle extends Shape {
Circle(this.radius);
final double radius;
}
final class Square extends Shape {
Square(this.side);
final double side;
}
double area(Shape shape) => switch (shape) {
Circle(:final radius) => 3.141592653589793 * radius * radius, // object pattern
Square(:final side) => side * side,
};
String describe(Shape shape) => switch (shape) {
Circle(radius: > 10) => 'big circle', // object pattern + relational guard
Circle() => 'small circle',
Square(side: var s) when s > 10 => 'big square ($s)', // `when` guard
Square() => 'small square',
};
String firstTwo(List<int> xs) => switch (xs) {
[] => 'empty',
[final a] => 'one: $a',
[final a, final b, ...] => 'starts $a, $b', // list pattern + rest
// No trailing wildcard needed: these three list patterns already cover
// every possible List<int> length (0, 1, 2+) — the analyzer proves it by
// flagging a `_` case added here as unreachable_switch_case.
};
String describeUser(Map<String, Object?> json) {
if (json case {'name': String name, 'age': int age}) {
// map pattern via if-case
return '$name ($age)';
}
return 'unknown';
}
enum Status { active, archived, deleted }
String statusLabel(Status s) {
switch (s) {
case Status.active:
return 'Active';
case Status.archived:
return 'Archived';
case Status.deleted:
return 'Deleted';
}
}
test/patterns_basics_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:probe/lessons/dart-foundations__dart-language-tour/patterns_basics.dart';
void main() {
test('switch expression with object patterns computes area per subtype', () {
expect(area(Circle(2)), closeTo(12.566, 0.001));
expect(area(Square(3)), 9);
});
test('object pattern with a relational guard vs a `when` guard', () {
expect(describe(Circle(20)), 'big circle'); // radius: > 10
expect(describe(Circle(1)), 'small circle');
expect(describe(Square(20)), 'big square (20.0)'); // when s > 10
expect(describe(Square(1)), 'small square');
});
test('list patterns destructure by shape, with a rest element', () {
expect(firstTwo([]), 'empty');
expect(firstTwo([7]), 'one: 7');
expect(firstTwo([1, 2, 3, 4]), 'starts 1, 2');
});
test('if-case with a map pattern extracts fields only on a structural match', () {
expect(describeUser({'name': 'Ada', 'age': 36}), 'Ada (36)');
expect(describeUser({'name': 'Ada'}), 'unknown'); // missing `age` — no match
});
test('switch statement over every enum value, no default needed', () {
expect(statusLabel(Status.active), 'Active');
expect(statusLabel(Status.archived), 'Archived');
expect(statusLabel(Status.deleted), 'Deleted');
});
}
✓ switch expression with object patterns computes area per subtype
✓ object pattern with a relational guard vs a `when` guard
✓ list patterns destructure by shape, with a rest element
✓ if-case with a map pattern extracts fields only on a structural match
✓ switch statement over every enum value, no default needed
00:00 +5: All tests passed!

Exhaustiveness: the analyzer proves you handled every case

Section titled “Exhaustiveness: the analyzer proves you handled every case”

The payoff for pattern matching over sealed class/enum is exhaustiveness checking — a real compile-time guarantee, not a convention. bloc-and-patterns already proved this for a switch expression over a sealed hierarchy with a real non_exhaustive_switch_expression diagnostic; here are the two cases it didn’t cover — the statement form over a sealed class, and an enum.

Delete the Square2 case from a switch statement over a sealed hierarchy:

// @expect-error real non_exhaustive_switch_statement diagnostic, captured below
sealed class Shape2 {}
final class Circle2 extends Shape2 { Circle2(this.radius); final double radius; }
final class Square2 extends Shape2 { Square2(this.side); final double side; }
String label(Shape2 shape) {
switch (shape) {
case Circle2():
return 'circle';
// missing Square2 case
}
}

The real, unedited flutter analyze output:

error • The type 'Shape2' isn't exhaustively matched by the switch cases since it doesn't match the
pattern 'Square2()'. Try adding a default case or cases that match 'Square2()' •
non_exhaustive_switch_statement

Delete the last case from a switch expression over an enum — enums get exactly the same treatment, because every enum in Dart 3 is implicitly a closed, exhaustively-checkable type:

// @expect-error real non_exhaustive_switch_expression diagnostic, captured below
enum Status2 { active, archived, deleted }
String label(Status2 s) => switch (s) {
Status2.active => 'Active',
Status2.archived => 'Archived',
// missing Status2.deleted
};
error • The type 'Status2' isn't exhaustively matched by the switch cases since it doesn't match the
pattern 'Status2.deleted'. Try adding a wildcard pattern or cases that match 'Status2.deleted' •
non_exhaustive_switch_expression

The fix for both is exactly what the message says: add the missing case (or a default:/_ => wildcard, which forfeits the exhaustiveness check going forward). This is why AI-generated state unions increasingly favor sealed class + switch over the old abstract class + a chain of is checks: only the sealed+switch combination gets this analyzer-enforced completeness, over both class hierarchies and enums.

Class modifiers: who is allowed to extend or implement your class

Section titled “Class modifiers: who is allowed to extend or implement your class”

Dart 3.0 added five modifiers — sealed, final, base, interface, and mixin class — that restrict how a class can be used outside its own library (each with a real, distinct flutter analyze error when violated). This lesson only needs the overview to read the exhaustiveness examples above (sealed is what makes Shape/Shape2 exhaustively checkable); the next lesson, Classes & Mixins, proves each modifier’s exact cross-library violation and fix.

Extension types: a zero-cost wrapper, checked at compile time

Section titled “Extension types: a zero-cost wrapper, checked at compile time”

Extension types (Dart 3.3, February 2024 — confirmed by dart.dev’s language evolution page: “Extension types are a new feature in Dart that allow zero-cost wrapping of an existing type”) wrap an existing type with a different, restricted, static-only interface — unlike a real wrapper class, there is no runtime object at all: dart.dev states it directly: “Because extension types are static-only and compiled away at run time, they are essentially zero cost.”

lib/extension_types.dart
// Extension types (Dart 3.3) — a compile-time-only wrapper, zero runtime cost.
extension type UserId(String value) {
bool get isValid => value.length == 8;
}
// `implements` a supertype of the representation type (double implements
// Comparable<num>) to inherit its members transparently, at zero cost.
extension type Meters(double value) implements Comparable<num> {
Meters operator +(Meters other) => Meters(value + other.value);
}
test/extension_types_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:probe/lessons/dart-foundations__dart-language-tour/extension_types.dart';
void main() {
test('an extension type has zero runtime footprint: it IS the representation type', () {
final id = UserId('a1b2c3d4');
// Erased at compile time — at runtime this is a plain String, not a
// wrapper object. `is`/runtimeType see straight through it.
expect(id is String, isTrue);
expect((id as Object).runtimeType, String);
expect(id.isValid, isTrue);
});
test('members not declared on the extension type are a real compile-time error, not a runtime one', () {
// See the lesson prose for the captured analyzer error for
// `UserId(...).toUpperCase()` — undefined_method, because UserId does
// not implement String, so its full member set isn't visible.
final id = UserId('short');
expect(id.isValid, isFalse);
});
test('implements a supertype of the representation type to inherit members for free', () {
final total = Meters(1.5) + Meters(2.5);
expect(total.value, 4.0);
expect(total.compareTo(4), 0); // inherited from Comparable<num> via `implements`
});
}
✓ an extension type has zero runtime footprint: it IS the representation type
✓ members not declared on the extension type are a real compile-time error, not a runtime one
✓ implements a supertype of the representation type to inherit members for free
00:00 +3: All tests passed!

UserId wraps a String but does not implement String — so it only exposes what it explicitly declares (isValid), nothing more. Calling a real String member on it is a genuine, real compile-time error:

// @expect-error real undefined_method diagnostic, captured below
extension type UserId(String value) {
bool get isValid => value.length == 8;
}
void demo() {
final id = UserId('a1b2c3d4');
print(id.toUpperCase()); // String's toUpperCase — NOT a member of UserId
}
error • The method 'toUpperCase' isn't defined for the type 'UserId'. Try correcting the name to
the name of an existing method, or defining a method named 'toUpperCase' • undefined_method

This is the entire point of an extension type versus a plain typedef or extension on String: it restricts the interface instead of widening it, while costing nothing at runtime — id is String is still true, because there is no wrapper object to be anything other than a String.

late promises “this will be assigned before anyone reads it” — the next lesson, Null Safety, covers late final and definite-assignment analysis in depth, and setState & the Lifecycle already proved the real runtime cost of breaking that promise (a LateInitializationError) for a controller initialized in initState.

AI-generated Dart tends to default to the oldest idiom it knows for a job — a class with fields instead of a record for a multiple-return, a chain of is/as checks instead of a switch pattern, Map<String, dynamic> instead of a typed record or a real model. None of this is wrong, exactly — it’s usually just years out of date relative to what the type system can now check for you.

// AI-generated: "return the min and max of a list"
class MinMaxResult {
MinMaxResult(this.min, this.max);
final double min;
final double max;
}
MinMaxResult minMax(List<double> values) {
var lo = values.first, hi = values.first;
for (final v in values) {
if (v < lo) lo = v;
if (v > hi) hi = v;
}
return MinMaxResult(lo, hi);
}

Before trusting generated code like this, check for:

  • A throwaway multiple-return wrapper class (no other purpose than bundling 2–3 values) that a record — (double, double) or ({double min, double max}) — would replace with zero declarations, proven above with minMax.
  • A switch (or if/else if chain) over a type hierarchy that isn’t sealed — meaning the analyzer can never tell you a case was missed. If the hierarchy is meant to be closed (a fixed, known set of variants), it should be sealed, and the switch should be a real switch expression/statement (not is checks) to get the exhaustiveness proof shown above.
  • A wrapper class (extra runtime allocation) used purely for type-safety on a primitive (an ID, a currency amount) where an extension type would give the same safety at zero cost — proven above that UserId('...') really is a plain String at runtime.

🐛 Spot the bug

sealed class PaymentResult {}
final class Success extends PaymentResult {
Success(this.transactionId);
final String transactionId;
}
final class Declined extends PaymentResult {
Declined(this.reason);
final String reason;
}
final class NetworkError extends PaymentResult {
NetworkError(this.message);
final String message;
}
String summarize(PaymentResult result) => switch (result) {
Success(:final transactionId) => 'Paid: $transactionId',
Declined(:final reason) => 'Declined: $reason',
};
Show the answer

This does not compile: PaymentResult is sealed with three subtypes (Success, Declined, NetworkError), but the switch expression in summarize() only covers two — the real analyzer error is non_exhaustive_switch_expression, naming exactly what’s missing: "doesn't match the pattern 'NetworkError()'", the same diagnostic proven earlier in this lesson for Shape2/Status2. The fix is adding the missing case (NetworkError(:final message) => 'Error: \$message') — and because the hierarchy is sealed, the compiler will force this same fix everywhere else PaymentResult is switched over, not just here.

Dart 3’s headline features (records, patterns, class modifiers, extension types) share one goal: move a check that used to live in a convention or a code review into the type checker, at zero or near-zero runtime cost. A record is “a tuple, but the compiler checks the shape.” A sealed+switch is “an enum-like union, but the compiler checks every branch.” An extension type is “a wrapper class, but the compiler erases it.” None of these are new capabilities Dart lacked before 3.0 — you could always write a wrapper class or an is-check chain — they’re the same capability with the manual, easy-to-forget verification step now done for you, automatically, on every build.

FeatureBenefitCost
Record over a wrapper classZero declaration, structural equality, canonicalized when constNo named type to attach further behavior to — reach for a class once a record needs methods
sealed + switch over is-chainReal exhaustiveness checking, proven aboveEvery new subtype forces you to touch every switch over it — a feature when you want that, friction when you don’t
Extension type over wrapper classZero runtime allocation, proven above (id is String is true)Not a real subtype relationship unless implements names a real supertype of the representation type — easy to assume more polymorphism than you actually get
when guard / relational patternExpresses “this case, but only if…” inline, no nested ifOverusing guards can hide cases the exhaustiveness checker would otherwise have forced you to name explicitly
  • “A switch without a default always needs one, or the compiler will complain.” Proven wrong above: a switch over an exhaustively-covered sealed class or enum needs no default/wildcard at all — adding one to firstTwo’s already-exhaustive list patterns is flagged unreachable_switch_case, a real diagnostic in the opposite direction.
  • “Records are just tuples; a class can always do the same thing.” True in principle, but a record needs zero declaration and gets structural equality and const canonicalization for free — proven above with identical() — where a hand-written class needs an explicit ==/hashCode override (covered next lesson) to get the same equality behavior.
  • “An extension type is basically a typedef.” Disproven above: a typedef exposes every member of the underlying type; UserId(...).toUpperCase() is a real undefined_method error on an extension type that doesn’t implements String, precisely because it restricts the interface rather than aliasing it.
  • “Exhaustiveness checking works on any abstract class hierarchy.” It requires sealed specifically — proven in bloc-and-patterns and here: only a closed (sealed) hierarchy or an enum lets the analyzer enumerate every possible subtype/value to check against.
What is the difference between final and const in Dart?
Why are named parameters so central to Flutter?
What does collection-if (if (cond) widget) inside a list do?
What does the cascade operator .. do?
A switch expression over a sealed Shape2 (Circle2/Square2) is missing the Square2 case. What is the real, proven outcome?
Why does UserId(...).toUpperCase() fail to compile, proven above, when UserId wraps a String?
Why is a record often preferable to a one-off wrapper class for returning two or three values, per this lesson's "Reading AI-generated code" section?