Dart Type System in Depth
Irbisa · cheatsheetSeptember 13, 2026

Dart Type System in Depth

Top and bottom types, reified generics, variance, inference, FutureOr, extension types, Never

Senior Developer16 itemscompressed for a skim
  1. 01

    A reviewer asks why a parameter is Object? and not dynamic when both accept everything — what does Dart's type lattice actually say?

    Medium

    Object?, dynamic and void are 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
    …
  2. 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?

    Medium

    Its return type has to be Never, not void — 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
    …
  3. 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?

    Medium

    Dart reifies generics: every generic instance carries its type arguments at runtime, so json['tags'] really is a List<dynamic> and the cast to List<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
    …
  4. 04

    Assigning a List<Dog> to a List<Animal> compiles cleanly, and then an unrelated add crashes in production — what did that assignment actually do?

    Medium

    Dart's generic classes are covariant in their type arguments — List<Dog> really is a subtype of List<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()];
    …
  5. 05

    The analyzer demands covariant before you can narrow an overridden parameter to a subtype — which check moves to runtime, and why must operator == keep Object?

    Medium

    covariant tells 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 {
    …
  6. 06

    Your max<T extends Comparable> helper happily compares a Duration with a String and blows up at runtime — what does writing Comparable<T> change?

    Medium

    The raw bound T extends Comparable means Comparable<dynamic>, so compareTo accepts anything and static checking stops; T extends Comparable<T> — an F-bound, where the parameter appears inside its own bound — forces T to 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
    …
  7. 07

    Every call site of your JsonMapper<T> has to spell out T, and half of them want two types from one instance — where should that type parameter have gone?

    Hard

    On 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;
    }
    …
  8. 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?

    Hard

    It had nothing to pick from, so it fell back to dynamic: a bare [] with no context type and no elements infers List<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 {
    …
  9. 09

    Awaiting a FutureOr<Object> hands you the String out of a Future you were storing as data — what is wrong with the type rather than with your code?

    Hard

    FutureOr<T> is an untagged union: the value carries no marker saying which arm it came from, so the moment T can 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. 10

    A void Function(Object) is accepted where a void Function(Dog) is expected, while a void Function(Dog) is rejected the other way round — which rule is that?

    Hard

    Function 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. 11

    A release crash report says type 'Null' is not a subtype of type 'int' on a line that contains no as at all — where did that cast come from?

    Hard

    The compiler wrote it for you: assigning a dynamic to a typed variable is an implicit downcast, compiled into exactly the check as int would 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. 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?

    Hard

    Field 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. 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?

    Hard

    At every run-time check, because an extension type is a compile-time view that is erased to its representation type: at run time a UserId simply is a String.

    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. 14

    A Map<Type, Decoder> registry keyed on value.runtimeType misses for a subclass and again for a decoded JSON map — why is runtimeType a bad dispatch key?

    Hard

    Because runtimeType is the exact class of the instance, type arguments included, while every dispatch you actually want is a subtype question — and a Type object supports nothing but equality.

    import 'dart:convert';
    
    class Animal {}
    
    class Dog extends Animal {}
    …
  15. 15

    A module full of Map<String, dynamic> analyses clean and still crashes on a cast — which of the three strict-* flags catches that, and what do the other two add?

    Hard

    strict-casts, and it is the one to enable first: it removes implicit downcasts from dynamic, so String name = json['name']; stops being legal and is reported as invalid_assignment — an error, not a warning.

    // analysis_options.yaml
    //
    //   include: package:flutter_lints/flutter.yaml
    //   analyzer:
    //     language:
    //       strict-casts: true
    …
  16. 16

    You are publishing a package whose main class is generic — what keeps its public API sound, and where exactly does an unbounded T bite the caller?

    Hard

    Bound every type parameter you intend to dereference, and keep dynamic out 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"?
    }
    …