Dart Type System in Depth
Top and bottom types, reified generics, variance, inference, FutureOr, extension types, Never
- 01
A reviewer asks why a parameter is
Object?and notdynamicwhen both accept everything — what does Dart's type lattice actually say?MediumObject?,dynamicandvoidare all top types — they hold exactly the same set of values — and they differ only in what the compiler lets you do with one.// The three top types: same values, different permissions Object? a = 42; dynamic b = 42; void c = 42; // assigning to void is fine // a.isEven; // ❌ Object has no isEven — narrow first … - 02
A helper that only ever throws is declared
void, and the analyzer still refuses to promote the variable you null-checked right before it — what is missing?MediumIts return type has to be
Never, notvoid— that is the only thing that tells flow analysis the call does not come back.// ❌ void — the call is just another statement, flow continues past it void failVoid(String m) => throw StateError(m); int lengthBad(String? s) { if (s == null) failVoid('null input'); return s.length; // ❌ receiver can be 'null' — no promotion … - 03
Every element in the decoded list is a String, but
json['tags'] as List<String>throws — how does Dart even know the difference at runtime?MediumDart reifies generics: every generic instance carries its type arguments at runtime, so
json['tags']really is aList<dynamic>and the cast toList<String>is a genuine type mismatch, not a formality an erased language would have thrown away.import 'dart:convert'; final json = jsonDecode('{"tags":["a","b"]}') as Map<String, dynamic>; print(json['tags'].runtimeType); // List<dynamic> — decided at creation … - 04
Assigning a
List<Dog>to aList<Animal>compiles cleanly, and then an unrelatedaddcrashes in production — what did that assignment actually do?MediumDart's generic classes are covariant in their type arguments —
List<Dog>really is a subtype ofList<Animal>— and that rule is deliberately unsound, so the runtime pays for it with a check on every write.class Animal {} class Dog extends Animal {} class Cat extends Animal {} void main() { final List<Dog> dogs = [Dog()]; … - 05
The analyzer demands
covariantbefore you can narrow an overridden parameter to a subtype — which check moves to runtime, and why mustoperator ==keepObject?Mediumcovarianttells the compiler to stop enforcing the contravariant-parameter rule statically and to insert a runtime type check at the top of the override instead.class Animal {} class Dog extends Animal { void bark() {} } class Cat extends Animal {} // covariant on the supertype: any override may narrow abstract class Handler { … - 06
Your
max<T extends Comparable>helper happily compares aDurationwith aStringand blows up at runtime — what does writingComparable<T>change?MediumThe raw bound
T extends ComparablemeansComparable<dynamic>, socompareToaccepts anything and static checking stops;T extends Comparable<T>— an F-bound, where the parameter appears inside its own bound — forcesTto be a type that can compare to its own kind.// ❌ raw bound == Comparable<dynamic>: compareTo takes anything at all T maxRaw<T extends Comparable>(List<T> xs) => xs.reduce((a, b) => a.compareTo(b) >= 0 ? a : b); // maxRaw<Object>([Duration.zero, 'later']); // compiles; TypeError out of Duration.compareTo at runtime … - 07
Every call site of your
JsonMapper<T>has to spell outT, and half of them want two types from one instance — where should that type parameter have gone?HardOn the method, unless the type is part of the object's state.
// T belongs to the CLASS: it is state, shared by fields and members class Repository<T> { final List<T> _cache = []; // the field pins T for the instance void put(T item) => _cache.add(item); T? at(int i) => i < _cache.length ? _cache[i] : null; } … - 08
A field declared
final _items = [];swallows anything anyone puts in it, and a cast three screens later throws — how did Dart pick that type argument?HardIt had nothing to pick from, so it fell back to
dynamic: a bare[]with no context type and no elements infersList<dynamic>, and that type argument is reified into the object for good.class Animal {} class Dog extends Animal {} class Cat extends Animal {} class Box<T> {} class Cart { … - 09
Awaiting a
FutureOr<Object>hands you theStringout of aFutureyou were storing as data — what is wrong with the type rather than with your code?HardFutureOr<T>is an untagged union: the value carries no marker saying which arm it came from, so the momentTcan itself be a future the two arms overlap and nothing at run time can tell them apart.import 'dart:async'; // ❌ the arms overlap: Object includes Future<String> FutureOr<Object> payload() => Future<String>.value('data'); Future<void> ambiguous() async { … - 10
A
void Function(Object)is accepted where avoid Function(Dog)is expected, while avoid Function(Dog)is rejected the other way round — which rule is that?HardFunction types are contravariant in their parameters and covariant in their return type: a substitute may accept more than the slot promises and return less.
class Animal { String get name => 'animal'; } class Dog extends Animal { void fetch() {} … - 11
A release crash report says
type 'Null' is not a subtype of type 'int'on a line that contains noasat all — where did that cast come from?HardThe compiler wrote it for you: assigning a
dynamicto a typed variable is an implicit downcast, compiled into exactly the checkas intwould have produced.final Map<String, dynamic> json = {'id': null, 'name': 'a'}; void implicit() { // ❌ no `as` in sight — the compiler inserts the same checked cast final int id = json['id']; // TypeError: type 'Null' is not a subtype of type 'int' … - 12
A null check on a private final field compiled last week, someone added an unrelated class to the same file, and now it does not — what turned promotion off?
HardField promotion is a library-wide, name-based property: a private final field is promotable only while no other class in that library declares a non-final field or a getter with the same name.
// ✅ private, final, and nothing else in this library declares `_token` class Session { final String? _token; Session(this._token); void send() { … - 13
You wrap ids in
extension type UserId(String value)so a raw String can never be passed by mistake — where does that guarantee vanish at run time?HardAt every run-time check, because an extension type is a compile-time view that is erased to its representation type: at run time a
UserIdsimply is aString.extension type UserId(String value) { bool get isValid => value.isNotEmpty; // String toString() => ...; // ❌ compile error: conflicts with Object } // `implements` exposes the representation's members and widens assignability … - 14
A
Map<Type, Decoder>registry keyed onvalue.runtimeTypemisses for a subclass and again for a decoded JSON map — why isruntimeTypea bad dispatch key?HardBecause
runtimeTypeis the exact class of the instance, type arguments included, while every dispatch you actually want is a subtype question — and aTypeobject supports nothing but equality.import 'dart:convert'; class Animal {} class Dog extends Animal {} … - 15
A module full of
Map<String, dynamic>analyses clean and still crashes on a cast — which of the threestrict-*flags catches that, and what do the other two add?Hardstrict-casts, and it is the one to enable first: it removes implicit downcasts fromdynamic, soString name = json['name'];stops being legal and is reported asinvalid_assignment— an error, not a warning.// analysis_options.yaml // // include: package:flutter_lints/flutter.yaml // analyzer: // language: // strict-casts: true … - 16
You are publishing a package whose main class is generic — what keeps its public API sound, and where exactly does an unbounded
Tbite the caller?HardBound every type parameter you intend to dereference, and keep
dynamicout of every signature; the rest is choosing read-only types on the way in and honest ones on the way out.// ❌ unbounded: Cache<String, int?> makes `V?` ambiguous and `is V` unpromotable class LooseCache<K, V> { final _m = <K, V>{}; V? get(K key) => _m[key]; // null means "absent" — or "stored null"? } …