Emmanuel Akinfulubi

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

  • String can 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.

The three safe ways to handle a maybe-null value

ran and analysed
// 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.

This does not compile, on purpose

does not compile
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.

Copy the field to a local, then check the local

ran and analysed
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 gives null, which is why a lookup is typed V?.

Both a Set and a Map literal use braces, so a bare {} is an empty Map. Write <String>{} when you meant a Set.

Literals, spread, collection-if and collection-for

ran and analysed
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.

The one that catches everybody, and the one exception

ran and analysed
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.

Two checks, because a shallow one is not enough

ran and analysed
/// 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.

This does not compile, on purpose

does not compile
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.

Order is promised; a mutable key is not

ran and analysed
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.

Initialising formals, a named constructor, and a factory that caches

ran and analysed
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.

Two const objects with the same arguments are the same object

ran and analysed
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.

This does not compile, on purpose

does not compile
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.

Override one and not the other, and a Set quietly keeps duplicates

ran and analysed
/// 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

  • sealed cannot 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.

A sealed load-state, matched with no default case

ran and analysed
/// 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.

This does not compile, on purpose

does not compile
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.

The same state, modelled twice. One of them can hold nonsense.

ran and analysed
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.

Destructuring in a declaration, an if and a for

ran and analysed
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 $1 and $2.
  • Named — (row: 12, letter: 'A'), read with .row and .letter.
  • A record type is written the same way as its value: (int, int) or ({int row, String letter}).

Positional, named, and three ways to destructure

ran and analysed
/// 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.

Free value equality, and the one field type that breaks it

ran and analysed
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}).

This does not compile, on purpose

does not compile
// 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.

The record cannot refuse. The class can.

ran and analysed
/// 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.

A generic class, a bounded one, and a generic method

ran and analysed
/// 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

This does not compile, on purpose

does not compile
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.

The assignment is legal. The write is not, and only run time knows.

ran and analysed
/// 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.

This does not compile, on purpose

does not compile
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, this is 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 getters on int, String and a generic List

ran and analysed
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.

Same object, two answers, decided at compile time

ran and analysed
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.

The real member wins; the override syntax is the only way past it

ran and analysed
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.

This does not compile, on purpose

does not compile
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 zero-cost wrapper, shown rather than run

shown, not run
/// 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.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.