All three hold every value, null included; they differ only in what the compiler lets you do with it. Object? keeps static checking, dynamic switches it off, and void forbids using the value at all.
**Object?**: only toString, hashCode, ==, runtimeType and noSuchMethod are callable until an is check promotes the value, so a typo is a compile error
**dynamic**: every member call compiles and is looked up at run time, so x.lenght ships and becomes a NoSuchMethodError. Whatever you read off a dynamic is dynamic again, so it spreads
**void**: you may hold the value but not read it: print(f()) for a void f() fails with "This expression has a type of 'void' so its value can't be used". That is how an API says "ignore what I return"
**Object is not a top type**: it excludes null, so null is Object is false. Object? is Object plus Null, just as any T? means T or Null
Rule of thumb: Object? for a value you will inspect, dynamic only at a decoding boundary that you narrow right away
FlutterSenior
Dart Type System in Depth
Top and bottom types, reified generics, variance, inference, FutureOr, extension types, Never
Questions closed
0 / 18
- 0Review
- 1Asked often
Questions
Never is the bottom type: a subtype of every type with no values at all, so an expression of type Never never completes; it throws or loops forever. Declare a helper Never when it always throws, and flow analysis knows the code after the call is unreachable.
Promotion survives: after if (s == null) fail('...'); the variable is a String. With a void helper the call is an ordinary statement and s stays String?
No missing return: a default: that calls fail(...) no longer triggers "body might complete normally"
**throw is a Never expression**: that is why v ?? (throw ArgumentError()) has the non-nullable type of v
Async: an async helper should return Future<Never>; it ends the flow only where you await it
As a type argument: Never fits every slot: <Never>[] is a valid empty List<String>, and Result<int, Never> is a result that cannot fail
**Null is not the bottom type**: null is a value; Never? is simply Null
Sound means a static type never lies: an expression of type String always evaluates to a String, or the program throws before the wrong value is used. Dart gets there with compile-time checks plus a short, known list of run-time checks.
Checked at run time
explicit as casts and the ! null assertion
implicit downcasts from dynamic, the only implicit cast left
writes through covariant generics (add on a List<Animal> that is really a List<Dog>) and parameters marked covariant
reads of a late variable that was never initialised (LateInitializationError), not a type check, but also run time
How Dart got here
Dart 2 made the type system sound but still allowed implicit downcasts such as String s = someObject; with a hidden check
Dart 2.12 null safety made null its own type and removed implicit downcasts from everything except dynamic; Dart 3 runs only with sound null safety
What soundness buys
The compiler can trust types: the compiler can trust static types, so AOT removes redundant null and type checks and devirtualises calls
Errors move to the boundary: a String field cannot hold null at run time, so defensive checks disappear and a bad value fails where it enters, at a cast or a dynamic read, not three layers deeper
Flow analysis narrows the static type of a local variable or parameter after a check such as x is String or x != null, so you can use it as the narrower type without a cast. The promotion lasts only while Dart can prove the variable has not changed since the check.
What promotes: locals and parameters, after is, == null / != null or the negated forms, inside && chains and the branches of ?:
Early exit: if (x is! String) return; promotes everything after it, and so do throw and a call that returns Never
What cancels it: assigning a value of the wider type, or creating a closure that writes the variable: from that point the analyzer cannot know when the write runs. Closures that only read are fine
Fields: only private final fields promote (Dart 3.2); for anything else copy into a local first, final t = _token;
Every generic object carries its type arguments at run time, so is List<String> and as Map<String, int> are real checks and a type parameter T is a real type inside the body. What you still cannot do is construct a T or call its static members.
Fixed at creation: the type argument is decided when the object is built, and no cast changes it. jsonDecode builds a List<dynamic>, so as List<String> throws even when every element is a String
Convert instead of casting: .cast<String>() returns a lazy checked view of the same list; List<String>.from(...) copies into a list you own
**x is T works**: inside a generic function you can test and promote against T without passing a type token, unlike Java's Class<T>
Not available: T(), T.fromJson(...) and static members through T: pass a factory such as T Function(Map<String, dynamic>)
Run-time cost: every instance carries its type arguments, and each generic as or covariant write is a real subtype test. Extension types are the exception: they are erased to their representation type
is asks a subtype question and promotes the variable; runtimeType == T compares the exact class, type arguments included. A subclass, a private implementation class or another type argument never matches, so use is, patterns or a sealed switch, and keep runtimeType for logs.
Exact class only: Dog().runtimeType == Animal is false, so a Map<Type, Handler> registry keyed on Animal misses every subclass
Type arguments count: <String>[] is List<Object> is true, but <String>[].runtimeType == List<Object> is false
Private classes: on the VM, jsonDecode('{}').runtimeType is _Map<String, dynamic>, a type you cannot even write as a key
No promotion: after if (a.runtimeType == Dog) the variable is still an Animal, so you need a cast anyway
Names are not stable: runtimeType.toString() is minified on the web and under --obfuscate, so never persist it or use it as a key
Inference fills in the types you did not write, in two directions: downwards from the context type (List<String> a = [];) and upwards from the expressions themselves ([1, 2] is a List<int>). When neither direction gives it anything, it picks dynamic with no warning by default.
Downwards: a variable annotation, a parameter type or a return type: takesInts([]) builds a List<int>, and Set<String> s = {} builds a Set
Upwards: the least upper bound of the elements: [1, 2.0] is List<num>, [1, 'a'] is List<Object>, [Dog(), Cat()] is List<Animal>, often not the type you had in mind
Empty literals: var xs = []; is a List<dynamic> and {} a Map<dynamic, dynamic>. The type argument is reified, so the list accepts any write and the failure appears later at a cast, far from the line that added the bad element
Generic methods: if T appears only in the return type, final u = decode(body); makes T dynamic and every typo on u compiles. Let an argument carry the type, such as a T Function(Map<String, dynamic>) fromJson
Make it loud: strict-inference reports each of these fallbacks
It compiles, prints 1, and then addStray throws type 'Cat' is not a subtype of type 'Dog' of 'value'. Dart generic classes are covariant, so List<Dog> is a subtype of List<Animal>, and the runtime pays for that with a type check on every write.
Why it is allowed: reading through the wider type is always safe, and covariance keeps everyday code simple. Writing is not safe, so Dart checks at run time every argument whose type uses the class's T
The object never changes: animals is still a List<Dog>; the annotation only changed what the compiler lets you call
Why it hurts in production: the throw lands inside addStray, maybe layers below the line that widened the type, and the stack trace does not point at that line
Fixes: take Iterable<Animal> when you only read, so there is no add to call; build a real List<Animal>.from(dogs) when you need to write. dogs.cast<Animal>() is only a view over the same List<Dog> and fails on the same add
No variance modifiers: unlike Kotlin's out / in, stable Dart cannot declare a read-only type parameter; class Box<out T> still needs the experimental variance flag
covariant lets an override narrow a parameter to a subtype, which the normal override rule forbids. The compiler accepts the narrowing and inserts a run-time type check at the start of the method instead.
The rule exists because a caller holding the supertype may pass any Animal, so void handle(Dog a) could receive a Cat. covariant says "I know, check it at run time", and a wrong argument throws a TypeError.
Put it on the supertype: then every override may narrow without repeating the keyword
Flutter relies on it: State.didUpdateWidget(covariant T oldWidget) and CustomPainter.shouldRepaint(covariant CustomPainter oldDelegate); safe because the framework only passes the matching type
**Never on ==**: List.contains, indexOf and remove call == with arbitrary objects, so covariant Money other turns contains('x') into a TypeError; a hash-based Set or Map hits it only on a hash collision, so the bug is intermittent. Keep Object other, test other is Money inside, and override hashCode to match
Generic parameters get it implicitly: List<T>.add(T value) is checked the same way, which is why adding a Cat to a List<Dog> seen as a List<Animal> throws
json['id'] has the static type dynamic, and assigning a dynamic to an int is an implicit downcast: the compiler inserts the same checked cast that as int would. The key is missing, the value is null, and that check throws.
**Only dynamic still does this**: since null safety (Dart 2.12) an implicit downcast from any other type, such as Object to String, is a compile error. Passing json['name'] as a String argument is a second hidden cast on the next line
The message tells you which: an explicit as ends with "in type cast"; the implicit one does not
**is never throws, as does**: and it throws a TypeError, an Error that signals a bug, so wrapping the parser in on TypeError hides defects instead of handling bad input
Patterns fail softly: case {'id': final int id} simply does not match a wrong shape; only the cast pattern (final id as int) and the null-assert pattern (final id!) throw
Make it visible: strict-casts: true turns every implicit downcast from dynamic into an analyzer error
register(HttpAuthRepository()) infers T from the argument, so the service is stored under the key HttpAuthRepository. A Type key matches only that exact type, never a supertype, so the lookup for AuthRepository misses and throws.
Why inference picks the class: the call has no type argument and no context type, so T is inferred upwards from the argument's static type. The interface you had in mind appears nowhere in the call
**Type keys have no subtyping**: HttpAuthRepository == AuthRepository is false. A Map<Type, Object> does an exact lookup, unlike an is check
The fix: write the type argument at registration: register<AuthRepository>(...). get_it works the same way, so registerSingleton<AuthRepository>(...) is the safe habit. Or give the argument an interface-typed variable first: final AuthRepository repo = HttpAuthRepository(); locator.register(repo);, so the static type is then the interface
Why the analyzer is silent: inference succeeded, so even strict-inference has nothing to report
Catch it next time: tests usually register fakes with an explicit type, so only the production wiring is wrong. A smoke test that builds the real container and resolves every interface catches it in CI
Function types are contravariant in their parameters and covariant in their return type: a substitute may accept more than the slot promises and return something narrower.
Read it from the caller's side. Whoever holds a void Function(Dog) calls it only with a Dog, and a function that takes any Object copes with that. A void Function(Animal) slot may be called with a Cat, and a dog-only handler would receive one, so the compiler refuses it.
**void is a top type**: any return type fits a void-returning slot, which is why onPressed: () => controller.forward() compiles
Optional parameters widen the fit: void ping([int code = 0]) is both a void Function() and a void Function(int)
Where it bites: callbacks: (Dog d) {...} for a void Function(Animal) parameter is rejected; widen the parameter and narrow inside with if (a is Dog)
Unlike generic classes: function types are checked soundly at compile time, with no covariant escape hatch and no run-time write check
Mental model: accept wider, return narrower
int is only a Comparable<num>, and Comparable<int> is a subtype of Comparable<num>, never the reverse, so T extends Comparable<T> rejects int. For numbers take a Comparator<T> or write a num-specific version.
What the F-bound buys: the type parameter appears in its own bound, so T must be comparable to its own kind. Money implements Comparable<Money> qualifies, and comparing a Money with a String is a compile error
What a raw bound loses: T extends Comparable means Comparable<dynamic>, so a List<Comparable> holding a Duration and a String compiles and throws inside Duration.compareTo
The quiet trap: without an explicit type argument, maxOf(ints) does compile, because inference picks T = num. A reduce-based body then throws type '(num, num) => num' is not a subtype of type '(int, int) => int': the closure is handed to a List<int>
What to write: keep T extends Comparable<T> for your own value types; give numbers their own version, as package:collection does with separate max extensions for int, double and num; for anything else take a Comparator<T> and let the caller decide
An extension type (Dart 3.3) is a compile-time view of an existing type: extension type UserId(String value) gives a String its own static type and API with no wrapper object. At run time it is erased to its representation type, so a UserId simply is a String.
Versus a wrapper class
No allocation: members are resolved statically and compiled into calls on the String, which matters for ids in large lists and hot paths
Static discipline: passing a raw String where a UserId is expected is a compile error; that is the whole point
Restricted: no instance fields besides the representation, and no ==, hashCode or toString of its own: "Extension types can't declare members with the same name as a member declared by 'Object'"
**implements String** exposes the representation's members and makes the type assignable to String; without it nothing leaks through
Where the guarantee vanishes
**is and as test the representation**: 'anything' is UserId is true, 'raw' as UserId succeeds, and case UserId u matches every String
**runtimeType is String**, and List<UserId> is the same reified type as List<String>
**== compares representations**: UserId('u1') == 'u1' is true
**dynamic finds nothing**: calling isValid through a dynamic is a NoSuchMethodError
When to use a class instead
when the distinction must survive a run-time check, a serialiser or a Map<Type, ...> lookup, or when you need equality of your own
Extension types are erased at run time, so field is Email only checks is String and is true for every String. The phone number takes the Email branch, and split('@').last returns it whole, so the full number reaches the log.
Why: an extension type exists only for the compiler. At run time Phone('+77011234567') is a plain String, so every type test sees only the representation type. Any raw String passed to mask lands in the Email branch too, so the leak is not limited to phones
Why it is a security bug: mask fails open: the wrong branch returns the sensitive value instead of failing, and the log line looks masked to a reviewer
The analyzer stays quiet: the if chain analyses clean; written as a switch, the Phone case at least gets an unreachable_switch_case warning
The fix: when code must tell the types apart at run time (an Object? parameter, a logger, a serialiser, a Map<Type, ...>), make them classes; a sealed class Contact also gives an exhaustive switch. Or keep the extension types and give each its own masked getter, so the choice is made statically on a known type
Catch it next time: treat is, as and type patterns on an extension type as a review smell, and unit-test every branch: mask(Phone('+77011234567')) must return ***4567
It prints true, String, true. FutureOr<T> is an untagged union of T and Future<T>, and with T = Object the two arms overlap: nothing records which arm a value came from, so await unwraps the future even if you meant it as data.
**The only user-written union type (besides T?)**: it is built into the subtype rules: both T and Future<T> are assignable to it, and nothing comes out without a check
Narrowing needs disjoint arms: if (v is Future<int>) ... else ... promotes the else branch to int, because an int can never be a Future. With Object there is no such guarantee
**FutureOr<Object?> is Object?: the two are mutual subtypes, which is the last line: the annotation tells the reader nothing
FutureOr<Object> is Object**: the return type of load() is no narrower than Object, so the annotation hides that a Future may come back
Rule of thumb: use FutureOr<T> as a calling convention with a concrete T, such as a cache that may answer synchronously, narrow it at the boundary, and use a sealed class when you need a real tagged union
strict-casts catches those crashes: it forbids implicit downcasts from dynamic, so String name = json['name']; becomes an analyzer error. The other two report the places where the analyzer quietly fell back to dynamic.
All three are language modes under analyzer: language: in analysis_options.yaml, not lints, so flutter_lints does not turn them on; very_good_analysis does.
**strict-casts**: every implicit dynamic-to-T assignment, argument and return, reported as ordinary errors such as return_of_invalid_type. This is the JSON boundary
**strict-raw-types**: a generic type with no type arguments where none can be inferred: void f(List items), Future f. Warning strict_raw_type
**strict-inference**: var x; with no initialiser, final xs = [];, a decode(body) whose T comes from nowhere. Warnings such as inference_failure_on_collection_literal; the noisiest of the three
Rolling out on legacy code: strict-casts first, since it finds the real crashes; then strict-raw-types, mostly mechanical; strict-inference last. Demote a diagnostic to info under analyzer: errors: while you work through the list, then remove the override
Bound type parameters to Object so null cannot mean two things, keep dynamic and raw types out of every public signature, accept read-only Iterable<T>, and return copies, not your internal collections.
**Bound with extends Object**: an unbounded V admits int?, and then V? get(key) cannot tell "absent" from "stored null". V extends Object makes nullable type arguments unrepresentable
**No dynamic, no raw types**: use Object? and List<T>; a bare List or a dynamic in a signature switches checking off in the caller's code
Read-only in, copies out: accept Iterable<T> when you only read, so covariance cannot bite on a write; return Map.unmodifiable(...) or a copy rather than your internal collection
A new bound is a breaking change: tightening T to T extends Object rejects callers who wrote Cache<String, int?>, so it needs a major version