The Dart language all 7 written
Null safety, records, patterns, sealed classes and generics. The parts that change how you design.
1 Null safety, and what the ? and ! really promise
In Dart, a type either can hold null or it cannot,
and the compiler enforces it. That single rule removes most crashes from a Flutter app,
and it is the first thing an interviewer will probe.
The rule
Stringcan never be null. The compiler will not let you.String?might be null. You must handle that before using it.!says "trust me, this is not null". If you are wrong, it crashes.
So ? is a promise you are asking the compiler to check, and !
is a promise you are making instead. Reach for ! rarely.
// A map lookup gives String? — the compiler cannot know whether the key is there.
final seatFor = <String, String>{'Ada': 'window'};
void main() {
String name = 'Ada'; // never null
String? seat = seatFor['Bode']; // might be null, and the compiler cannot tell
print('name is $name');
print('seat is $seat');
// Three safe ways to use a value that might be null.
print(seat?.length); // null-aware: gives null instead of crashing
print(seat ?? 'not booked'); // a fallback value
if (seat != null) {
print(seat.length); // inside this if, Dart KNOWS it is not null
}
test('a non-null value is just there', name, 'Ada');
test('a missing key gives null', seat, null);
test('?. returns null rather than crashing', seat?.length, null);
test('?? supplies a fallback', seat ?? 'not booked', 'not booked');
test('a key that IS present gives the value', seatFor['Ada'], 'window');
}
What it printed, when the build ran it
name is Ada seat is null null not booked ok a non-null value is just there ok a missing key gives null ok ?. returns null rather than crashing ok ?? supplies a fallback ok a key that IS present gives the value
The compiler really does refuse
This is not a warning you can ignore. The panel below does not compile, and the message under it is what the analyser actually said when the build ran it.
void main() {
String? name;
print(name.length); // no ?. and no check: refused
}
What the analyser said
line 3: The property 'length' can't be unconditionally accessed because the receiver can be 'null'. Try making the access conditional (using '?.') or adding a null check to the target ('!').
[unchecked_use_of_nullable_value]
Where the promotion stops
That if (maybe != null) trick is called promotion, and it
has one limit worth knowing before an interview: it does not work on a class field,
because another piece of code could change the field between your check and your use.
class Booking {
String? note; // a field, so promotion will not apply
Booking(this.note);
// This is the pattern: copy to a local, then check the local.
int noteLength() {
final local = note;
if (local == null) return 0;
return local.length; // promotion works on a local
}
}
void main() {
print(Booking('window seat').noteLength());
print(Booking(null).noteLength());
test('a note is measured', Booking('window seat').noteLength(), 11);
test('a missing note is zero, not a crash', Booking(null).noteLength(), 0);
test('an empty note is also zero', Booking('').noteLength(), 0);
}
What it printed, when the build ran it
11 0 ok a note is measured ok a missing note is zero, not a crash ok an empty note is also zero
If you are asked about null safety in an interview, this field-versus-local distinction is the answer that shows you have used it rather than read about it. The reason promotion is refused on a field is that a getter could return something different each time it is called, so the compiler cannot assume the second read matches the first.
On late. late String name; means "not null,
but I will set it before anything reads it". Useful in a State class where
the value arrives in initState. It also moves the check from compile time to
run time: read it early and you get a LateInitializationError instead of a
compiler message. Use it when the ordering is genuinely guaranteed, not to silence an
error you have not thought about.
2 Collections, and the equality trap
Three collections, and one rule that catches everybody: in Dart, two collections with identical contents are not equal. Not Lists, not Sets, not Maps. Knowing that, and knowing the one exception, is the whole lesson.
The three, quickly
List— ordered, indexed, duplicates allowed.Set— no duplicates, no index. Membership is the fast operation.Map— keys to values. A missing key givesnull, which is why a lookup is typedV?.
Both a Set and a Map literal use braces, so a bare {} is an empty
Map. Write <String>{} when you meant a Set.
void main() {
// A List keeps order and allows duplicates.
final classes = <String>['economy', 'economy', 'business'];
// A Set drops duplicates. There is no index because there is nothing to index.
// Note the analyser will not even let you write a literal with a duplicate in it.
final unique = classes.toSet();
// A Map is keys to values.
final seats = <String, int>{'economy': 120, 'business': 20};
// const means built at compile time and shared. That matters below.
const cabins = ['economy', 'premium', 'business'];
final showFirst = true;
final extras = ['first'];
final menu = [
if (showFirst) ...extras, // collection-if, then a spread
...cabins,
];
final rows = [for (var i = 1; i <= 3; i++) 'row $i'];
print('list $classes');
print('set $unique');
print('map $seats');
print('menu $menu');
print('rows $rows');
test('a List keeps duplicates', classes.length, 3);
test('a Set drops them', unique.length, 2);
test('a Map looks up by key', seats['business'], 20);
test('a missing key gives null', seats['first'], null);
test('collection-if and spread flatten into one List', menu,
['first', 'economy', 'premium', 'business']);
test('collection-for builds from a loop', rows, ['row 1', 'row 2', 'row 3']);
}
What it printed, when the build ran it
list [economy, economy, business]
set {economy, business}
map {economy: 120, business: 20}
menu [first, economy, premium, business]
rows [row 1, row 2, row 3]
ok a List keeps duplicates
ok a Set drops them
ok a Map looks up by key
ok a missing key gives null
ok collection-if and spread flatten into one List
ok collection-for builds from a loop
The trap
Here is the part worth remembering. List does not override
==, so it inherits the one from Object, which asks
"are these the same object?" and nothing else. People often assume Sets and Maps are
the exception. They are not. All three behave the same way.
The exception is const. A const collection is built by the
compiler and canonicalised — two const collections with the same contents
are the same object, so they are == and also
identical.
void main() {
final a = [1, 2, 3];
final b = [1, 2, 3];
final listsEqual = a == b;
// Not the exception people expect.
final setsEqual = {1, 2} == {1, 2};
final mapsEqual = {'a': 1} == {'a': 1};
// const is different: the compiler builds one object and hands it out twice.
const c1 = [1, 2, 3];
const c2 = [1, 2, 3];
print('list == list $listsEqual');
print('set == set $setsEqual');
print('map == map $mapsEqual');
print('identical(a, b) ${identical(a, b)}');
print('const == const ${c1 == c2}');
print('identical(c1, c2) ${identical(c1, c2)}');
test('two Lists with equal contents are NOT ==', listsEqual, false);
test('Sets are no different', setsEqual, false);
test('nor are Maps', mapsEqual, false);
test('a List is only == to itself', identical(a, a), true);
test('two const Lists with the same contents ARE ==', c1 == c2, true);
test('because const canonicalises them into one object', identical(c1, c2), true);
test('a const Set is canonicalised too', identical(const {1, 2}, const {1, 2}), true);
}
What it printed, when the build ran it
list == list false set == set false map == map false identical(a, b) false const == const true identical(c1, c2) true ok two Lists with equal contents are NOT == ok Sets are no different ok nor are Maps ok a List is only == to itself ok two const Lists with the same contents ARE == ok because const canonicalises them into one object ok a const Set is canonicalised too
If a state class holds a List and you compare two states with
==, the comparison is an identity check on the list. Rebuild the list
from the same data and the states compare as different, so the UI rebuilds every
time. That is the single most common cause of "my Bloc emits but nothing is
deduplicated".
Comparing contents, properly
There is no operator for it, so you call something. Flutter ships
listEquals, setEquals and mapEquals in
foundation.dart; package:collection has
ListEquality and DeepCollectionEquality. Both are shallow
unless you ask for deep, and the panel below shows why that distinction matters.
/// What listEquals does, written out. Note it compares elements with ==.
bool sameList(List<Object?> a, List<Object?> b) {
if (a.length != b.length) return false;
for (var i = 0; i < a.length; i++) {
if (a[i] != b[i]) return false;
}
return true;
}
/// A List inside a List is still compared by identity by the shallow version,
/// so nesting needs recursion. This is what DeepCollectionEquality gives you.
bool sameDeep(Object? a, Object? b) {
if (a is List && b is List) {
if (a.length != b.length) return false;
for (var i = 0; i < a.length; i++) {
if (!sameDeep(a[i], b[i])) return false;
}
return true;
}
return a == b;
}
void main() {
print('flat, shallow ${sameList([1, 2], [1, 2])}');
print('nested, shallow ${sameList([[1]], [[1]])}');
print('nested, deep ${sameDeep([[1]], [[1]])}');
test('a written-out check compares contents', sameList([1, 2], [1, 2]), true);
test('and still says no when they differ', sameList([1, 2], [1, 3]), false);
test('length is checked first', sameList([1], [1, 2]), false);
test('but a shallow check fails on nesting', sameList([[1]], [[1]]), false);
test('so nesting needs recursion', sameDeep([[1]], [[1]]), true);
test('a Set compares unordered, which is what containsAll is for',
{1, 2}.containsAll({2, 1}), true);
}
What it printed, when the build ran it
flat, shallow true nested, shallow false nested, deep true ok a written-out check compares contents ok and still says no when they differ ok length is checked first ok but a shallow check fails on nesting ok so nesting needs recursion ok a Set compares unordered, which is what containsAll is for
What const actually demands
const means the compiler builds the value, so everything inside it has
to exist at compile time. A value that only exists when the program runs cannot go in
one, and the analyser says so rather than letting it slide.
void main() {
// DateTime.now() only has an answer once the program is running.
const stamps = [DateTime.now()];
print(stamps);
}
What the analyser said
line 3: The values in a const list literal must be constants. Try removing the keyword 'const' from the list literal. [non_constant_list_element]
Iteration order, and mutable keys
A Map literal and {} both give you a
LinkedHashMap, so iteration order is insertion order and that is a
guarantee you can rely on. Overwriting a value keeps the key where it was;
removing and re-adding moves it to the end. A HashMap from
dart:collection promises nothing about order.
Then the sharper trap. A Map finds a key by its hashCode first. If the
field that hashCode is built from changes after the key went in, the entry
is still in the Map and you can no longer reach it.
import 'dart:collection';
/// == and hashCode built from a field that can change. This is the bug, on purpose.
class Tag {
Tag(this.name);
String name;
@override
bool operator ==(Object other) => other is Tag && other.name == name;
@override
int get hashCode => name.hashCode;
@override
String toString() => 'Tag($name)';
}
void main() {
final counts = <String, int>{};
counts['zebra'] = 1;
counts['apple'] = 2;
counts['mango'] = 3;
counts['zebra'] = 9; // overwrite: the key does not move
final reordered = <String, int>{'a': 1, 'b': 2, 'c': 3};
reordered.remove('a');
reordered['a'] = 1; // removed then re-added: it goes to the end
final hashed = HashMap<String, int>.of(counts);
// The key goes in, then the field its hash came from changes underneath it.
final tag = Tag('draft');
final byTag = {tag: 3};
final before = byTag[tag];
tag.name = 'final';
final after = byTag[tag];
print('insertion order ${counts.keys.toList()}');
print('after remove+add ${reordered.keys.toList()}');
print('HashMap holds ${hashed.length} keys, in an order it does not promise');
print('lookup before $before');
print('lookup after $after');
print('entries still ${byTag.length}, holding ${byTag.keys.first}');
test('a Map literal iterates in insertion order', counts.keys.toList(),
['zebra', 'apple', 'mango']);
test('overwriting a value does not move the key', counts.keys.first, 'zebra');
test('removing then re-adding does move it', reordered.keys.toList(), ['b', 'c', 'a']);
test('a Set literal is insertion ordered too', {'z', 'a', 'm'}.toList(), ['z', 'a', 'm']);
test('the key was reachable before the mutation', before, 3);
test('and unreachable after it', after, null);
test('but the entry never left the Map', byTag.length, 1);
}
What it printed, when the build ran it
insertion order [zebra, apple, mango] after remove+add [b, c, a] HashMap holds 3 keys, in an order it does not promise lookup before 3 lookup after null entries still 1, holding Tag(final) ok a Map literal iterates in insertion order ok overwriting a value does not move the key ok removing then re-adding does move it ok a Set literal is insertion ordered too ok the key was reachable before the mutation ok and unreachable after it ok but the entry never left the Map
The rule that follows from that panel. Anything used as a Map key or
put in a Set must be immutable, or at least immutable in the fields
hashCode reads. In practice: make the fields final. A record
or a class with final fields is safe; a class with a settable field that
feeds hashCode is a bug waiting for a large enough Map.
Worth saying out loud in an interview: Dart chose identity equality for collections
deliberately, because content equality on a large list is O(n) and == is
expected to be cheap. The cost is pushed to you, once, at the place you actually need
it — which is usually a state class, and usually solved by
DeepCollectionEquality or by not putting a raw List in the state at all.
3 Classes, constructors and const
Dart has more constructor forms than most languages, and one of
them — the const constructor — changes how Flutter behaves rather than just
how your code reads. That is the part worth understanding properly.
The constructor forms
- Initialising formals —
Seat(this.row)assigns the field directly. No body needed, and it works before the object exists. - Named —
Seat.aisle(4). A second way in, often delegating to the main one with: this(...). - Factory — not required to build anything new. It can return a cached instance, a subtype, or throw. A normal constructor can do none of those.
class Seat {
// Initialising formals. this.row assigns the field, so there is no body.
const Seat(this.row, this.letter);
// A named constructor delegating to the main one.
const Seat.aisle(int row) : this(row, 'C');
// A factory can validate and refuse. A generative constructor cannot.
factory Seat.parse(String code) {
final letter = code.substring(code.length - 1);
final row = int.tryParse(code.substring(0, code.length - 1));
if (row == null) throw FormatException('no row in $code');
return Seat(row, letter);
}
final int row;
final String letter;
String get label => '$row$letter';
}
class Cabin {
Cabin._(this.name); // private, so only the factory can call it
static final _known = <String, Cabin>{};
// The other reason for a factory: hand back the same instance for the same name.
factory Cabin(String name) => _known.putIfAbsent(name, () => Cabin._(name));
final String name;
}
void main() {
String refused = 'nothing was thrown';
try {
Seat.parse('nope');
} on FormatException catch (e) {
refused = e.message;
}
final plain = Seat(12, 'A');
final aisle = Seat.aisle(4);
final cached = identical(Cabin('economy'), Cabin('economy'));
print('label ${plain.label}');
print('named ${aisle.label}');
print('parsed ${Seat.parse('19F').label}');
print('factory cache $cached');
print('refused $refused');
test('initialising formals fill the fields', plain.label, '12A');
test('a named constructor can delegate to the main one', aisle.label, '4C');
test('a factory can parse its way to an instance', Seat.parse('19F').label, '19F');
test('a factory can refuse outright', refused, 'no row in nope');
test('a factory can hand back the same instance twice', cached, true);
test('a plain constructor cannot', identical(Seat(1, 'A'), Seat(1, 'A')), false);
}
What it printed, when the build ran it
label 12A named 4C parsed 19F factory cache true refused no row in nope ok initialising formals fill the fields ok a named constructor can delegate to the main one ok a factory can parse its way to an instance ok a factory can refuse outright ok a factory can hand back the same instance twice ok a plain constructor cannot
const, and why Flutter cares
A const constructor says the object can be built at compile time. The
consequence is canonicalisation: build two const objects with
the same arguments and you do not get two objects, you get one, handed to you twice. They
are identical.
That is why const in a build method is not a style preference. Flutter
decides whether to reuse an Element by comparing the old widget with the new one, and a
canonicalised widget is the same object across rebuilds, so the whole subtree can be
skipped. Lesson 17 measures that.
class Insets {
const Insets(this.all);
final double all;
}
class Padded {
const Padded({required this.insets, required this.child});
final Insets insets;
final String child;
}
void main() {
const a = Insets(8);
const b = Insets(8);
const wider = Insets(16);
final atRuntime = Insets(8); // same arguments, no const
// Canonicalisation is not shallow. The nested const object is shared too.
const one = Padded(insets: Insets(8), child: 'hello');
const two = Padded(insets: Insets(8), child: 'hello');
print('identical(a, b) ${identical(a, b)}');
print('a == b ${a == b}');
print('identical(a, wider) ${identical(a, wider)}');
print('identical(a, runtime) ${identical(a, atRuntime)}');
print('nested const shared ${identical(one, two)}');
test('two const objects with the same arguments are ONE object', identical(a, b), true);
test('so == is true without writing an == override', a == b, true);
test('different arguments, different object', identical(a, wider), false);
test('drop the const and you get a fresh object', identical(a, atRuntime), false);
test('which means == is false against the run-time twin', a == atRuntime, false);
test('canonicalisation reaches the nested const too', identical(one, two), true);
}
What it printed, when the build ran it
identical(a, b) true a == b true identical(a, wider) false identical(a, runtime) false nested const shared true ok two const objects with the same arguments are ONE object ok so == is true without writing an == override ok different arguments, different object ok drop the const and you get a fresh object ok which means == is false against the run-time twin ok canonicalisation reaches the nested const too
Read the fourth and fifth assertions together, because that is the interview answer.
const did not give Insets value equality. It gave you
one object, and == was true only because identity was true. The
moment one side is built at run time, == is false again. If you want value
equality you still have to write it.
What a const constructor gives up
Everything the object holds must also be available at compile time. Every field must be
final, and no field may be initialised with something that only exists once
the program is running.
class Stamped {
const Stamped(this.label);
final String label;
// Every instance would need a value that only exists at run time.
final DateTime createdAt = DateTime.now();
}
void main() {
print(const Stamped('hello').label);
}
What the analyser said
line 2: Can't define the 'const' constructor because the field 'createdAt' is initialized with a non-constant value. Try initializing the field to a constant value, or removing the keyword 'const' from the constructor. [const_constructor_with_field_initialized_by_non_const]
== and hashCode, and the half-done job
Overriding == alone looks like it works, because == is what you
test with. Then you put the object in a Set or use it as a Map
key, and the collection asks for hashCode first. Two objects that are
== with different hash codes break the contract the collections rely on, and
the failure is silent.
The rule: equal objects must have equal hash codes, and both must be
derived from the same final fields.
/// Overrides == and stops there. The classic half-done job.
class Half {
const Half(this.id);
final String id;
@override
bool operator ==(Object other) => other is Half && other.id == id;
}
/// Both, from the same fields. Object.hash combines any number of them.
class Whole {
const Whole(this.id, this.row);
final String id;
final int row;
@override
bool operator ==(Object other) =>
other is Whole && other.id == id && other.row == row;
@override
int get hashCode => Object.hash(id, row);
}
void main() {
final h1 = Half('a');
final h2 = Half('a');
final w1 = Whole('a', 1);
final w2 = Whole('a', 1);
final halves = {h1, h2};
final wholes = {w1, w2};
final byWhole = {w1: 'seat 1A'};
print('Half == says ${h1 == h2}');
print('Half hashes match ${h1.hashCode == h2.hashCode}');
print('Half in a Set ${halves.length}');
print('Whole in a Set ${wholes.length}');
print('Whole as a key ${byWhole[w2]}');
test('== says the two Halves are equal', h1 == h2, true);
test('but their hash codes are not', h1.hashCode == h2.hashCode, false);
test('so a Set keeps both, despite == saying they are the same', halves.length, 2);
test('overriding both collapses them', wholes.length, 1);
test('and a Map finds one entry by the other object', byWhole[w2], 'seat 1A');
test('a different field is still a different object', Whole('a', 2) == w1, false);
}
What it printed, when the build ran it
Half == says true Half hashes match false Half in a Set 2 Whole in a Set 1 Whole as a key seat 1A ok == says the two Halves are equal ok but their hash codes are not ok so a Set keeps both, despite == saying they are the same ok overriding both collapses them ok and a Map finds one entry by the other object ok a different field is still a different object
Do not hand-write this in production. Six fields means six chances to
forget one in == and get a comparison that is wrong only for the case you did
not test. Use package:equatable, or generate it with
freezed, and keep the hand-written version for the interview whiteboard.
Lesson 29 is about what code generation costs you in return.
4 Sealed classes and pattern matching
A sealed class tells the compiler the complete list of
its subtypes. Once it knows the list, it can check that your switch handled
every one — and refuse to compile when it does not. That check is why sealed classes
change how you model state rather than just how you write it.
What sealed means
sealedcannot be extended outside its own file, so the subtype list is closed and knowable.- It is also implicitly
abstract. You never instantiate the sealed class itself, only its cases. - Each case carries its own data. That is the difference from an enum, and the whole reason to bother.
/// The subtypes must live in this file, which is what lets the compiler
/// know the list is complete.
sealed class Load<T> {
const Load();
}
class Loading<T> extends Load<T> {
const Loading();
}
class Ready<T> extends Load<T> {
const Ready(this.value);
final T value; // only exists on this case
}
class Failed<T> extends Load<T> {
const Failed(this.reason, {this.retryable = false});
final String reason; // and only these exist on this one
final bool retryable;
}
/// A switch EXPRESSION with no default. The compiler checks the list for us.
/// Ready(value: final n) matches the type and binds the field in one step.
String describe(Load<int> state) => switch (state) {
Loading() => 'still loading',
Ready(value: final n) when n > 100 => 'ready, and large: $n',
Ready(value: final n) => 'ready: $n',
Failed(reason: final why, retryable: true) => 'temporary: $why',
Failed(reason: final why) => 'permanent: $why',
};
/// An or-pattern groups cases that share an answer.
int valueOr(Load<int> state, int fallback) => switch (state) {
Ready(value: final n) => n,
Loading() || Failed() => fallback,
};
void main() {
print(describe(const Loading()));
print(describe(const Ready(7)));
print(describe(const Ready(900)));
print(describe(const Failed('rate limited', retryable: true)));
print(describe(const Failed('bad token')));
test('a case with no data matches on its type alone',
describe(const Loading()), 'still loading');
test('a guard is tried before the plainer case',
describe(const Ready(900)), 'ready, and large: 900');
test('and the plainer case catches the rest', describe(const Ready(7)), 'ready: 7');
test('a field pattern can match on a value, not just bind it',
describe(const Failed('rate limited', retryable: true)), 'temporary: rate limited');
test('falling through to the general failure case',
describe(const Failed('bad token')), 'permanent: bad token');
test('an or-pattern groups two cases with one answer',
valueOr(const Failed('offline'), -1), -1);
test('and the Ready case still binds its value', valueOr(const Ready(42), -1), 42);
}
What it printed, when the build ran it
still loading ready: 7 ready, and large: 900 temporary: rate limited permanent: bad token ok a case with no data matches on its type alone ok a guard is tried before the plainer case ok and the plainer case catches the rest ok a field pattern can match on a value, not just bind it ok falling through to the general failure case ok an or-pattern groups two cases with one answer ok and the Ready case still binds its value
The guarantee, proved
Nothing above says "default" or "else". That is not a convenience, it is the point: if
a case is missing the code does not compile. Add a fourth payment type a year from now and
every switch over the family becomes a compile error pointing at the place
that needs a decision.
Compare that with an enum plus a default:, where the new case silently
lands in the fallback branch and ships.
sealed class Payment {}
class Card extends Payment {}
class Cash extends Payment {}
class Voucher extends Payment {} // added later; the switch below was never updated
String fee(Payment p) => switch (p) {
Card() => '1.5%',
Cash() => 'none',
};
void main() {
print(fee(Cash()));
}
What the analyser said
line 7: The type 'Payment' isn't exhaustively matched by the switch cases since it doesn't match the pattern 'Voucher()'. Try adding a wildcard pattern or cases that match 'Voucher()'. [non_exhaustive_switch_expression]
Read the analyser's message above carefully: it names the missing case. That is the
feature. A switch statement over a sealed type is checked the same way, and
the moment you add _ => or default: you opt out of the whole
thing and get the enum behaviour back. So the advice is narrow and worth remembering:
never write a wildcard case in a switch over a sealed type.
Why not an enum and a nullable payload
The shape people write instead is one enum for the phase and one nullable field per outcome. It compiles, and it lets you build states that cannot happen — ready with no value, ready and failed at once — because nothing connects the phase to the fields.
Then every read needs a ?? or a !, and the ! is a
crash you have written down and agreed to.
enum Phase { loading, ready, failed }
/// The old shape. Nothing links the phase to the fields that go with it.
class OldState {
OldState(this.phase, {this.value, this.reason});
final Phase phase;
final int? value;
final String? reason;
}
String oldDescribe(OldState s) => switch (s.phase) {
Phase.loading => 'still loading',
Phase.ready => 'ready: ${s.value ?? -1}', // the ?? is the tell
Phase.failed => 'failed: ${s.reason ?? 'unknown'}',
};
int oldValue(OldState s) => s.value!; // the ! you are pushed into
/// The sealed shape. Each case owns its data, and nothing is nullable.
sealed class NewState {}
class ReadyState extends NewState {
ReadyState(this.value);
final int value;
}
class FailedState extends NewState {
FailedState(this.reason);
final String reason;
}
String newDescribe(NewState s) => switch (s) {
ReadyState(value: final v) => 'ready: $v',
FailedState(reason: final r) => 'failed: $r',
};
void main() {
// Ready and carrying a failure reason, with no value. Nonsense, and legal.
final contradiction = OldState(Phase.ready, reason: 'both at once');
String crashed;
try {
oldValue(OldState(Phase.ready));
crashed = 'no crash';
} on TypeError catch (_) {
crashed = 'the ! fired on a null the type could not rule out';
}
print('contradiction ${contradiction.phase}, value ${contradiction.value}, '
'reason ${contradiction.reason}');
print('forced fallback ${oldDescribe(OldState(Phase.ready))}');
print('forced bang $crashed');
print('sealed, ready ${newDescribe(ReadyState(7))}');
print('sealed, failed ${newDescribe(FailedState('offline'))}');
test('the enum shape happily holds a contradiction', contradiction.value, null);
test('and still reports itself as ready', contradiction.phase, Phase.ready);
test('so reading it needs a fallback the domain has no answer for',
oldDescribe(OldState(Phase.ready)), 'ready: -1');
test('or a ! that really does fire', crashed,
'the ! fired on a null the type could not rule out');
test('the sealed version carries the value with the case',
newDescribe(ReadyState(7)), 'ready: 7');
test('and the reason with the other case',
newDescribe(FailedState('offline')), 'failed: offline');
}
What it printed, when the build ran it
contradiction Phase.ready, value null, reason both at once forced fallback ready: -1 forced bang the ! fired on a null the type could not rule out sealed, ready ready: 7 sealed, failed failed: offline ok the enum shape happily holds a contradiction ok and still reports itself as ready ok so reading it needs a fallback the domain has no answer for ok or a ! that really does fire ok the sealed version carries the value with the case ok and the reason with the other case
Patterns are not only for sealed classes
The same pattern syntax destructures in a variable declaration, tests shape in an
if, and unpacks in a for. The map pattern is the useful one when
decoding JSON: it checks the keys and the types together, so a wrong shape falls to the
else instead of throwing somewhere later.
void main() {
// Destructure a list, and collect what is left.
final [first, second, ...rest] = [10, 20, 30, 40];
// Check shape and types in one step. Note this is decoding, not casting.
final body = <String, Object?>{'seat': '12A', 'row': 12};
String label;
if (body case {'seat': final String s, 'row': final int r}) {
label = '$s in row $r';
} else {
label = 'unrecognised';
}
// The same pattern, against a body that does not fit.
final wrong = <String, Object?>{'seat': 12};
String fallback;
if (wrong case {'seat': final String s}) {
fallback = s;
} else {
fallback = 'unrecognised';
}
// An object pattern works on any class, including MapEntry.
final pairs = <String>[];
for (final MapEntry(key: k, value: v) in {'a': 1, 'b': 2}.entries) {
pairs.add('$k=$v');
}
print('destructured $first, $second, rest $rest');
print('decoded $label');
print('wrong shape $fallback');
print('entries $pairs');
test('a list pattern binds by position', [first, second], [10, 20]);
test('the rest pattern takes what is left', rest, [30, 40]);
test('a map pattern checks keys and types together', label, '12A in row 12');
test('a body that does not fit falls to the else', fallback, 'unrecognised');
test('an object pattern destructures a MapEntry', pairs, ['a=1', 'b=2']);
}
What it printed, when the build ran it
destructured 10, 20, rest [30, 40] decoded 12A in row 12 wrong shape unrecognised entries [a=1, b=2] ok a list pattern binds by position ok the rest pattern takes what is left ok a map pattern checks keys and types together ok a body that does not fit falls to the else ok an object pattern destructures a MapEntry
On the other class modifiers. final stops subclassing
entirely, base forces subclasses to extend rather than implement, and
interface forces the opposite. They exist so a library author can say what
is safe to do with a type. sealed is the one that changes your own code,
because it is the only one that buys you exhaustiveness.
5 Records, and when not to use one
A record is several values travelling together without a class to declare. It compares by its contents, which classes do not, and it has no name, no methods and no validation, which classes do. Both halves of that sentence decide where it belongs.
The syntax
- Positional —
(12, 'A'), read with$1and$2. - Named —
(row: 12, letter: 'A'), read with.rowand.letter. - A record type is written the same way as its value:
(int, int)or({int row, String letter}).
/// A record type in a return position. No class, no file, no constructor.
({int row, String letter}) seatFor(String name) =>
name == 'Ada' ? (row: 12, letter: 'A') : (row: 1, letter: 'C');
/// The classic case: two values out of one pass.
(int, int) minMax(List<int> xs) {
var lo = xs.first;
var hi = xs.first;
for (final x in xs) {
if (x < lo) lo = x;
if (x > hi) hi = x;
}
return (lo, hi);
}
void main() {
final seat = seatFor('Ada');
final pair = minMax([4, 9, 2, 7]);
// Destructuring is why this reads well at the call site.
final (lo, hi) = minMax([4, 9, 2, 7]);
final (row: r, letter: l) = seatFor('Ada');
// The :name shorthand binds a variable with the field's own name.
final (:row, :letter) = seatFor('Bode');
print('named ${seat.row}${seat.letter}');
print('positional ${pair.$1} to ${pair.$2}');
print('destructured $lo to $hi');
print('renamed $r$l');
print('shorthand $row$letter');
test('a named field is read by name', seat.row, 12);
test('a positional field is read by number', pair.$1, 2);
test('and so is the second', pair.$2, 9);
test('destructuring binds both at once', [lo, hi], [2, 9]);
test('a named field can be bound to a new name', '$r$l', '12A');
test('or to a variable with the same name', '$row$letter', '1C');
}
What it printed, when the build ran it
named 12A positional 2 to 9 destructured 2 to 9 renamed 12A shorthand 1C ok a named field is read by name ok a positional field is read by number ok and so is the second ok destructuring binds both at once ok a named field can be bound to a new name ok or to a variable with the same name
Structural equality, which classes do not have
This is the property that makes records worth having. Two records with equal fields are
==, without you writing anything, and their hash codes agree — so a record
works as a Map key or in a Set. Compare that with lesson 3, where
the same guarantee cost you two overrides and a chance to get them out of step.
The limit is inherited from lesson 2. A record compares its fields with
==, so a record holding a List is back to identity for that
field.
void main() {
final a = (12, 'A');
final b = (12, 'A');
final n1 = (row: 12, letter: 'A');
final n2 = (letter: 'A', row: 12); // named fields have no order
final withList = (12, ['A']);
final alsoWithList = (12, ['A']);
final byRecord = {a: 'window'}; // a record as a Map key
print('record == ${a == b}');
print('identical ${identical(a, b)}');
print('hash codes ${a.hashCode == b.hashCode}');
print('named, unordered ${n1 == n2}');
print('as a Map key ${byRecord[b]}');
print('with a List ${withList == alsoWithList}');
test('two records with the same fields ARE ==', a == b, true);
test('though they are not the same object', identical(a, b), false);
test('their hash codes agree, which is what collections need',
a.hashCode == b.hashCode, true);
test('named fields have no order', n1 == n2, true);
test('so a record is a usable Map key', byRecord[b], 'window');
test('a record inside a record compares structurally too',
(1, (2, 3)) == (1, (2, 3)), true);
test('but a List inside one falls back to identity',
withList == alsoWithList, false);
}
What it printed, when the build ran it
record == true identical false hash codes true named, unordered true as a Map key window with a List false ok two records with the same fields ARE == ok though they are not the same object ok their hash codes agree, which is what collections need ok named fields have no order ok so a record is a usable Map key ok a record inside a record compares structurally too ok but a List inside one falls back to identity
That last assertion is the practical one. A record of scalars is a genuinely free value type. A record holding a collection is not, and it will behave exactly like the class in lesson 2 that compared its List by identity. Records did not fix collection equality; nothing did.
The name on a positional field is a comment
You can write (int row, String letter) in a type, and it reads as though the
fields are named. They are not. Names on positional fields are documentation for
the reader, and the values are still $1 and $2. If you want
accessors, the braces are not optional: ({int row, String letter}).
// The names here are documentation only. They create no getters.
typedef Seat = (int row, String letter);
String label(Seat s) => '${s.row}${s.letter}';
void main() {
print(label((12, 'A')));
}
What the analyser said
line 4: The getter 'row' isn't defined for the type 'Seat'. Try importing the library that defines 'row', correcting the name to the name of an existing getter, or defining a getter or field named 'row'. [undefined_getter]
When not to use one
A record has no name, so nothing in a stack trace or a signature tells you what it means. It has no methods, so behaviour that belongs to the data ends up somewhere else. And it has no constructor, so there is no place to put a validation rule. A record cannot refuse to exist.
That makes it excellent for a local return value and wrong for a domain type. The test is the boundary: if the value crosses one — into a repository, over a channel, into a widget's constructor — it wants a name and an invariant, so it wants a class.
/// A record shaped like a seat. Nothing can reject a nonsense one.
typedef LooseSeat = ({int row, String letter});
/// The same idea as a class. The constructor is the only way in, so the rule holds.
class Seat {
Seat({required this.row, required this.letter}) {
if (row < 1) throw ArgumentError.value(row, 'row', 'must be 1 or more');
if (letter.length != 1) {
throw ArgumentError.value(letter, 'letter', 'must be a single letter');
}
}
final int row;
final String letter;
String get label => '$row$letter';
bool get isWindow => letter == 'A' || letter == 'F';
}
void main() {
const LooseSeat nonsense = (row: -4, letter: 'nope');
String refused = 'accepted';
try {
Seat(row: -4, letter: 'nope');
} on ArgumentError catch (e) {
refused = '${e.message}';
}
final real = Seat(row: 12, letter: 'A');
print('record accepts $nonsense');
print('class refuses $refused');
print('and has methods ${real.label}, window: ${real.isWindow}');
test('a record accepts a row that cannot exist', nonsense.row, -4);
test('and a letter that is not a letter', nonsense.letter, 'nope');
test('a class refuses it at the boundary', refused, 'must be 1 or more');
test('and carries behaviour that belongs with the data', real.label, '12A');
test('including a derived answer', real.isWindow, true);
}
What it printed, when the build ran it
record accepts (letter: nope, row: -4) class refuses must be 1 or more and has methods 12A, window: true ok a record accepts a row that cannot exist ok and a letter that is not a letter ok a class refuses it at the boundary ok and carries behaviour that belongs with the data ok including a derived answer
Where records earn their place. Returning two things without inventing
a MinMaxResult class. Destructuring a Map entry. Grouping
arguments inside one function. Where they do not: anything you would name in a
conversation about the product. "Seat", "User", "Money" are types with rules; a record
cannot hold a rule, so it will not hold the line for you.
6 Generics, variance and bounded types
Generics in Dart look like generics anywhere until you hit variance.
A List<int> is a List<num> in Dart, which is
convenient, unsound, and checked at run time instead. That one decision explains a class of
crash you will otherwise find confusing.
Type parameters, and bounds
<T>— T could be anything, so inside the class you can move it around and nothing else. That restriction is the point.<T extends num>— a bound. Now+and>are available on T, because every possible T has them.- A bound is a promise to the class body, and a requirement on the caller. Both halves are checked.
/// Unbounded. Nothing is known about K or V, so nothing is assumed.
class Cache<K, V> {
final _entries = <K, V>{};
void put(K key, V value) => _entries[key] = value;
V? get(K key) => _entries[key];
int get size => _entries.length;
}
/// Bounded, so arithmetic and comparison are legal inside the body.
class Stats<T extends num> {
Stats(this.samples);
final List<T> samples;
T get largest => samples.reduce((a, b) => a > b ? a : b);
double get mean => samples.fold<num>(0, (sum, x) => sum + x) / samples.length;
}
/// A generic METHOD, not a generic class. T comes from the argument, so the
/// caller writes no type at all.
T firstOr<T>(Iterable<T> xs, T fallback) => xs.isEmpty ? fallback : xs.first;
void main() {
final cache = Cache<String, int>();
cache.put('a', 1);
cache.put('b', 2);
final ints = Stats<int>([3, 9, 4]);
final doubles = Stats<double>([1.5, 2.5]);
print('cache size ${cache.size}');
print('missing key ${cache.get('zzz')}');
print('largest ${ints.largest}');
print('mean of ints ${ints.mean}');
print('mean of doubles ${doubles.mean}');
print('firstOr, empty ${firstOr(<String>[], 'none')}');
test('a generic class keeps the value type', cache.get('a'), 1);
test('and a miss is null rather than a crash', cache.get('zzz'), null);
test('a bound lets the body compare its own type parameter', ints.largest, 9);
test('and do arithmetic with it', close(ints.mean, 5.333333333, 1e-6), true);
test('the same class serves double', close(doubles.mean, 2.0), true);
test('a generic method infers T from its argument',
firstOr(<String>[], 'none'), 'none');
test('and hands back the element type when there is one', firstOr([10, 20], 0), 10);
}
What it printed, when the build ran it
cache size 2 missing key null largest 9 mean of ints 5.333333333333333 mean of doubles 2.0 firstOr, empty none ok a generic class keeps the value type ok and a miss is null rather than a crash ok a bound lets the body compare its own type parameter ok and do arithmetic with it ok the same class serves double ok a generic method infers T from its argument ok and hands back the element type when there is one
Reach for a generic method when the relationship is between the arguments and
the return value of one call, as in firstOr. Reach for a generic
class when the type has to be remembered between calls, as in
Cache. Making the class generic when only one method needed it forces every
caller to name a type they do not care about.
The bound is checked on the caller too
class Stats<T extends num> {
Stats(this.samples);
final List<T> samples;
T get largest => samples.reduce((a, b) => a > b ? a : b);
}
void main() {
// String is not a num, so the bound is not satisfied.
print(Stats<String>(['a', 'b']).largest);
}
What the analyser said
line 9: 'String' doesn't conform to the bound 'num' of the type parameter 'T'. Try using a type that is or is a subclass of 'num'. [type_argument_not_matching_bounds]
Covariance, and the crash it buys
Here is the decision. In Dart, if int is a subtype of num then
List<int> is a subtype of List<num>. That is called
covariance, and it is what lets you pass a list of ints to a function that
takes a list of nums without ceremony.
It is also unsound, and provably so: a List<num> is supposed to accept
a double, but the object behind it is still a List<int> and
will not have one. Dart's answer is to allow the assignment and check every write at run
time. Reading is always safe; it is writing that cannot be.
/// Asks about the RUNTIME type, in a way the analyser cannot fold away.
String shapeOf(Object? x) => switch (x) {
List<int>() => 'a List<int>',
List<num>() => 'a List<num>',
_ => 'something else',
};
void main() {
final List<int> ints = [1, 2];
// Allowed, with no cast. This is covariance.
final List<num> asNums = ints;
// Reading through the wider type is always fine.
final total = asNums.fold<num>(0, (sum, x) => sum + x);
String outcome;
try {
asNums.add(3.5); // the compiler agrees; the object does not
outcome = 'accepted';
} on TypeError catch (_) {
outcome = 'TypeError at run time';
}
// The fix is a genuinely new list, not another view of the same one.
final copied = List<num>.of(ints)..add(3.5);
print('same object ${identical(ints, asNums)}');
print('runtime shape ${shapeOf(asNums)}');
print('sum through num $total');
print('add 3.5 $outcome');
print('ints unchanged $ints');
print('copied $copied');
test('a List<int> can be used as a List<num>', total, 3);
test('because it is the same object, not a converted one',
identical(ints, asNums), true);
test('so its real type is still List<int>', shapeOf(asNums), 'a List<int>');
test('which is why adding a double throws when it runs', outcome,
'TypeError at run time');
test('and the original list was never touched', ints, [1, 2]);
test('copying into a real List<num> is the fix', copied, [1, 2, 3.5]);
}
What it printed, when the build ran it
same object true runtime shape a List<int> sum through num 3 add 3.5 TypeError at run time ints unchanged [1, 2] copied [1, 2, 3.5] ok a List<int> can be used as a List<num> ok because it is the same object, not a converted one ok so its real type is still List<int> ok which is why adding a double throws when it runs ok and the original list was never touched ok copying into a real List<num> is the fix
The other direction is refused outright
Worth seeing next to the panel above, because it shows the asymmetry. Widening is
allowed and checked later; narrowing is a compile error, because a
List<num> genuinely might hold a double.
void main() {
final List<num> mixed = [1, 2.5];
// Refused: a List<num> really can hold something that is not an int.
final List<int> ints = mixed;
print(ints);
}
What the analyser said
line 5: A value of type 'List<num>' can't be assigned to a variable of type 'List<int>'. Try changing the type of the variable, or casting the right-hand type to 'List<int>'. [invalid_assignment]
What to do about it. Prefer Iterable<T> in a
parameter when you only intend to read, because it has no add and the
question never arises. When you need a wider list you can write to, copy it with
List<num>.of(...) rather than assigning. And if you see a
TypeError mentioning two type arguments you never wrote together, this is
what happened: something widened a list upstream and something else wrote to it.
The interview version, in two sentences. Dart generics are covariant and therefore unsound, so the language inserts a run-time check on writes through a widened generic. Java made the opposite call with wildcards and made every signature noisier; Dart traded a compile-time guarantee for readability, and the crash in the panel above is the price of that trade.
7 Extensions and extension types
An extension adds members to a type you do not own. The important part is not the syntax, it is that the call is resolved at compile time from the declared type of the receiver. Get that, and every surprising thing extensions do stops being surprising.
Adding members to somebody else's type
- Inside an extension,
thisis the receiver, and its members are in scope unqualified. - An extension can add getters, methods and operators. It cannot add a field, because there is nowhere to put one.
- Extensions can be generic, and the type parameter comes from the receiver.
extension Money on int {
/// Pennies to something readable. this is the int.
String get gbp => '£${(this / 100).toStringAsFixed(2)}';
int get wholePounds => this ~/ 100;
}
extension Sentence on String {
// isEmpty and substring are this.isEmpty and this.substring.
String get titled =>
isEmpty ? this : '${this[0].toUpperCase()}${substring(1)}';
}
/// Generic, with T taken from the receiver.
extension Second<T> on List<T> {
T? get secondOrNull => length < 2 ? null : this[1];
}
void main() {
print('gbp ${1999.gbp}');
print('whole pounds ${1999.wholePounds}');
print('titled ${'window seat'.titled}');
print('second of three ${[1, 2, 3].secondOrNull}');
print('second of one ${[1].secondOrNull}');
test('an extension adds a getter to int', 1999.gbp, '£19.99');
test('this is the receiver', 1999.wholePounds, 19);
test('the receiver members are in scope unqualified',
'window seat'.titled, 'Window seat');
test('an extension can be generic', [1, 2, 3].secondOrNull, 2);
test('and keeps the element type, so null is a legal answer here',
[1].secondOrNull, null);
}
What it printed, when the build ran it
gbp £19.99 whole pounds 19 titled Window seat second of three 2 second of one null ok an extension adds a getter to int ok this is the receiver ok the receiver members are in scope unqualified ok an extension can be generic ok and keeps the element type, so null is a legal answer here
Resolved statically, and here is the proof
Two extensions, one on num and one on int, both defining
kind. Give the compiler an int variable and it picks the more
specific one. Give it a num variable holding the same int — same
object, same runtimeType — and it picks the num one. Nothing about
the value changed. Only the declared type did.
The consequence follows immediately. A dynamic variable has no declared type
to resolve against, so the extension is never found and the call goes looking for a real
member that does not exist.
extension OnNum on num {
String get kind => 'num';
}
extension OnInt on int {
String get kind => 'int';
}
extension Money on int {
String get gbp => '£${(this / 100).toStringAsFixed(2)}';
}
void main() {
final int asInt = 3;
final num asNum = 3; // same value, same runtime type, different declared type
dynamic loose = 1999;
String viaDynamic;
try {
viaDynamic = loose.gbp as String;
} on NoSuchMethodError catch (_) {
viaDynamic = 'NoSuchMethodError';
}
print('declared int ${asInt.kind}');
print('declared num ${asNum.kind}');
print('runtime type ${asNum.runtimeType}');
print('through dynamic $viaDynamic');
print('typed receiver ${1999.gbp}');
test('the more specific extension wins for an int', asInt.kind, 'int');
test('the same value typed as num gets the num extension', asNum.kind, 'num');
test('even though its runtime type is int', asNum.runtimeType, int);
test('so the declared type decided it, not the value',
asInt.kind == asNum.kind, false);
test('a dynamic receiver finds no extension at all', viaDynamic,
'NoSuchMethodError');
test('while the identical call on a typed receiver works', 1999.gbp, '£19.99');
}
What it printed, when the build ran it
declared int int declared num num runtime type int through dynamic NoSuchMethodError typed receiver £19.99 ok the more specific extension wins for an int ok the same value typed as num gets the num extension ok even though its runtime type is int ok so the declared type decided it, not the value ok a dynamic receiver finds no extension at all ok while the identical call on a typed receiver works
Where this bites in real code. JSON. A decoded body is
Map<String, dynamic>, so every value you pull out is
dynamic, and your tidy String get titled extension silently
becomes a NoSuchMethodError at run time with no analyser warning first.
Give the value a type before you call anything on it.
An extension never wins against a real member
If the type already has a member with that name, the real one is used and your extension
is simply not called. No error, no warning. The only way to reach it is the
extension override syntax, Shouty(word).length, which names the
extension explicitly.
extension Shouty on String {
/// String already has a length. This one will never be reached by 'x'.length.
String get length => 'nine hundred';
String get shout => '${toUpperCase()}!';
}
void main() {
const word = 'hi';
print('real member ${word.length}');
print('forced extension ${Shouty(word).length}');
print('no clash ${word.shout}');
test('the real instance member wins, silently', word.length, 2);
test('an extension override reaches the shadowed one',
Shouty(word).length, 'nine hundred');
test('a name with no clash resolves normally', word.shout, 'HI!');
test('and the override syntax works for those too', Shouty(word).shout, 'HI!');
}
What it printed, when the build ran it
real member 2 forced extension nine hundred no clash HI! ok the real instance member wins, silently ok an extension override reaches the shadowed one ok a name with no clash resolves normally ok and the override syntax works for those too
Two extensions, one name
When two extensions on the same type define the same member and neither is more specific,
there is nothing to resolve and the analyser says so. This is why extensions are
named: you can import ... hide Louder;, or use the override syntax to
pick one.
extension Loud on String {
String get shout => toUpperCase();
}
extension Louder on String {
String get shout => '$this!'; // same name, same receiver, neither is more specific
}
void main() {
print('hi'.shout);
}
What the analyser said
line 10: A member named 'shout' is defined in 'extension Loud on String' and 'extension Louder on String', and neither is more specific. Try using an extension override to specify the extension you want to be chosen. [ambiguous_extension_member_access]
extension type: a wrapper that costs nothing
Dart 3.3 added extension type, which is a different tool with a similar
name. An extension adds members to an existing type. An extension type creates a
new compile-time type over an existing representation, with no object
allocated for it — at run time it simply is the underlying value.
That solves the problem an int user id has: every id in your codebase is an
int, so nothing stops you passing an order id where a user id was wanted. Wrap
it in a class and you fix the types and pay for an allocation on every one. An extension
type fixes the types and pays nothing.
The catch is in the second declaration below. Without implements, the new
type is not a subtype of its representation, which is exactly the safety you
wanted. Add implements and the underlying members come through — convenient,
and it gives back the interchangeability you were trying to prevent.
/// A distinct type at compile time. At run time, this IS an int.
extension type UserId(int value) {
bool get isValid => value > 0;
}
/// implements exposes int's own members. Convenient, and it means an int
/// can flow in where Pennies was expected. Choose deliberately.
extension type Pennies(int value) implements int {
String get gbp => '£${(value / 100).toStringAsFixed(2)}';
}
void main() {
final id = UserId(7);
test('an extension type has its own members', id.isValid, true);
test('and the representation is reachable', id.value, 7);
test('but at run time it is the plain int', id.runtimeType, int);
test('implements lets the int operators through', Pennies(1999) + 1, 2000);
test('while still adding its own members', Pennies(1999).gbp, '£19.99');
}
The build's scratch project declares sdk: ^3.0.0, which pins the language
version to 3.0, and extension type needs 3.3 — so the analyser rejects the
feature there regardless of the installed SDK. Every assertion above was checked by hand
against a project with a 3.6 constraint on the same Dart 3.13 SDK, but this build could not
run it, so it is not badged as though it had.
The distinction to have ready. An extension adds behaviour to a type that
already exists, and changes nothing about what is assignable to what. An
extension type adds a type, with no run-time cost, and changes exactly that.
The first is for convenience; the second is for making a wrong assignment impossible. Both
are compile-time only, which is why neither survives a trip through
dynamic.