Generics (reified types, bounds, variance)

By the end of this lesson you will be able to write your own generic classes and functions, explain why List<dynamic> is dangerous while List<int> is safe, add bounds like T extends Comparable<T> so generic code can actually call methods on T, prove that Dart keeps type arguments alive at runtime (unlike Java), and trace, step by step, the exact TypeError that Dart's covariant generics can throw.

1. The problem generics solve

Imagine two storage boxes. One box has no label at all — you can drop in a sock, a sandwich, or a snake, and the box will happily hold any of them. That's List<dynamic>: a box that accepts anything. The other box is labelled "INTEGERS ONLY" — like a vending machine slot that physically only accepts one specific coin shape. Try to post a button instead of a coin and the slot rejects it before it goes anywhere near the machine's insides. That's List<int>: the label isn't just a suggestion, Dart's analyzer actually enforces it.

Generics let a class or function be written once, but work with a type that is filled in later — T in List<T> is a placeholder, exactly like a parameter is a placeholder for a value. Writing List<int> "fills in" T = int, and from that point on, Dart's analyzer (the tool that checks your code before it ever runs) treats that list as an int-only slot.

Notice what did not happen above: nothing was executed to discover the mistake. The moment you write nums.add('oops') against a List<int>, the analyzer already knows — from the declared type alone — that a String cannot go into an int-shaped slot. This is a compile-time error: the whole file fails to compile, and main() never starts running at all.

The same thing as runnable Dart — the same List<dynamic> versus List<int> experiment, with the rejected line kept as a comment together with the exact error dart analyze prints (the // => lines are the exact output, checked by verify/d10.dart):

Input size → what's feasible: the type check is done once, before the program runs, so it costs nothing at runtime: summing a List<int> of n = 106 elements is about 106 steps either way — the difference is that only the typed list can be proven correct before any of those steps run.

"It compiled, so it must be correct" is backwards reasoning with List<dynamic>. dynamic tells Dart "don't check this at all" — so List<dynamic> will accept literally anything, and any mistake (adding the wrong kind of value, calling a method that doesn't exist) is deferred all the way to runtime, when it's far more expensive to track down. Generics move that same mistake to compile time, where your editor can point at the exact line before you even run the program.
A generic type parameter like T in List<T> is filled in when you use the type. Filling it with a concrete type (List<int>) lets the analyzer catch type mistakes before the program runs at all — that's the entire point of generics.

2. Generic classes, generic functions & inference

A generic class is a labelled storage box template, not one box: Box<T> is the blueprint for "a box that holds exactly one thing of some type T, whatever that ends up being." Every time you write Box<int> or Box<String>, you're stamping out a new, independent box from that same blueprint, each locked to its own kind of coin.

A generic function works the same way, but the type parameter lives on the function itself: T first<T>(List<T> xs) means "give me a list of some type T, and I'll hand back one value of that exact same T." You can pass a List<int> and get an int back, or a List<String> and get a String back — one function definition, reused safely for every type.

Notice the last two steps returned the exact same value, 10, whether or not you wrote <int> explicitly. That's type inference: Dart looks at the argument you actually passed ([10, 20, 30], a List<int>) and works out that T must be int, without you spelling it out. Most of the time in real Dart code, you never write the type argument explicitly at all — it's there, just inferred.

The same thing as runnable Dart — Box<T>, Pair<A, B> and first<T> in use, with the type each call really has (the full Box and Pair source is in questions q3 and q7) (the // => lines are the exact output, checked by verify/d10.dart):

Input size → what's feasible: creating a Box<T> or calling first<T> is O(1) however big T is; inference is done by the analyzer, so it has no runtime cost at n = 10 or n = 106.

Type inference has a few rules worth seeing with your own eyes. The function below returns the T the compiler chose, so you can print it:

The rules the output shows: the arguments decide T (their closest common supertype, called the least upper bound); a declared type on the left (List<num> wide = ...) is used first (downward inference); and with nothing to go on, Dart falls back to dynamic ([] is a List<dynamic>).

A class can also have more than one type parameter, like Pair<A, B>. Each parameter is independent: Pair<String, int>('age', 30) fixes A = String and B = int at the same time, and the analyzer will always know that .first is a String and .second is an int for that particular pair — no casting needed anywhere.

A generic class is a blueprint with a placeholder type; each concrete instantiation (Box<int>, Box<String>) is its own distinct type. A generic function's type parameter is usually inferred from the arguments you pass, not written out by hand.

3. Bounds: T extends Comparable<T>, T extends num

Plain T means "absolutely anything can go here" — like a vending slot with no shape restriction at all, so the machine has no idea what to do with whatever gets dropped in. A bound, written T extends Comparable<T>, narrows the slot: "I'll accept any coin, as long as it's a coin that knows how to compare itself to another coin of the same kind." Now the machine (your generic function) can safely rely on that one guaranteed ability.

Without a bound, T defaults to Object? — "any object, or possibly null." Object? doesn't define a compareTo method (and might be null, which has no methods at all), so a generic function that tries to call a.compareTo(b) on an unbounded T simply will not compile.

The same thing as runnable Dart — both bounded functions, a bound on your own class, and the fact that explains the int gotcha. The two compile errors quoted in the comments were confirmed with dart analyze (the // => lines are the exact output, checked by verify/d10.dart):

A class can have a bound just like a function. Range<T> below only makes sense for types that can be ordered, so it demands Comparable<T>:

Input size → what's feasible: each call to maxOf does one compareTo, so folding n = 106 values costs 106 comparisons — comfortably under the ~108 simple steps per second a loop can do.

A subtlety that surprises people who assume "numbers are numbers": int implements Comparable<num>, not Comparable<int> — because int needs to be comparable to any num, including doubles. That means calling maxOf<int>(3, 7) against a T extends Comparable<T> bound is a compile-time error ("'int' doesn't conform to the bound 'Comparable<int>'"), even though maxOf<num>(3, 7) and numMax<int>(3, 7) (bounded by num instead) both work perfectly. When in doubt for numeric code, bound by num; reserve Comparable<T> for types like String and your own classes that directly implement Comparable of themselves.
A bound (T extends X) restricts which types are legal for T, and in exchange lets the function body call whatever methods X guarantees. Unbounded T defaults to Object?, which guarantees almost nothing. int/double satisfy Comparable<num>, not Comparable<int>/Comparable<double> — a real gotcha worth remembering.

4. Reified generics: type arguments exist at runtime

Some languages print a label on the storage box during construction, then peel it off and throw it away the moment the box ships — at runtime, nobody can tell what the label used to say (this is called type erasure, and it's how Java's generics historically worked). Dart instead staples the label on permanently: the box still carries its real type tag no matter how long it's been sitting around, and you can always ask it "what does your label actually say?" This is called reification — the type argument is a real, inspectable fact about the object, not just a compile-time note that vanishes.

You can prove this directly with the is operator, which checks a type at runtime:

The same thing as runnable Dart — every kind of runtime type test, including the subtype rules and a generic function that uses T as a real value (the // => lines are the exact output, checked by verify/d10.dart):

Input size → what's feasible: x is List<int> compares type arguments, not elements, so it is O(1) whether the list has 3 or 106 items; building a real List<int> with List<int>.from(x) is O(n).

In a language with erased generics, asking "is this really a List<Integer>?" at runtime is literally impossible to answer — the information is gone. In Dart it's a normal, cheap, always-correct runtime check, because the VM keeps the type argument attached to the object for its entire lifetime. This is also why the variance rule in the next section needs a runtime check at all: the runtime genuinely knows the object's real element type, so it can enforce it.
Dart's generics are reified: the type argument is a real, checkable fact at runtime, not erased after compilation. xs is List<int> gives a real, correct answer — this is a meaningful difference from erasure-based generics.

5. Variance: covariant generics, the runtime check, contravariance

If every apple is a fruit, is a box of apples also, safely, a box of fruit? It feels obviously "yes" — you can look inside and every item really is a fruit. Dart's generics agree: List<int> is treated as a subtype of List<num>, because every int really is a num. This is called covariance. But there's a catch: if someone is handed that box labelled "box of fruit" and drops in a banana, they've just put a non-apple into what was really an apples-only box underneath. Dart lets the label lie at compile time — and catches the banana at the exact moment it's dropped in, at runtime.

Watch this happen step by step — a List<int>, viewed through a List<num> variable, has a double added to it:

This is not a compile-time error — the analyzer genuinely allows numsView.add(2.5), because 2.5 really is a valid num and numsView's declared type really is List<num>. The mistake only surfaces the moment that exact line executes, as a TypeError thrown by the list itself. This is exactly why Dart's generics are described as "covariant, but sound only via a runtime check": the type system's static promise is technically breakable, and the runtime is what actually keeps the list's real contents honest.

The same thing as runnable Dart — the same trace as runnable code, including the exact TypeError message and the safe fix (copy into a real List<num>) (the // => lines are the exact output, checked by verify/d10.dart):

Dart gives you an explicit way to accept this same trade-off on purpose, using the covariant keyword — and the reverse direction (functions) works oppositely, called contravariance:

The same thing as runnable Dart — the covariant override, and what happens when an Animal reference is used to call it with the wrong kind of food. Removing covariant gives the invalid_override error quoted in the comment (confirmed with dart analyze) (the // => lines are the exact output, checked by verify/d10.dart):

And the mirror image, function parameters (this panel reuses Animal and GrassEater from the one above):

Input size → what's feasible: a covariant write costs one O(1) type check; a defensive copy List<num>.of(ints) is O(n) — fine once for n = 106 (106 steps) but 1012 steps if you did it inside a loop of 106 iterations.

Why is it "contravariance" for functions but "covariance" for containers? A container is covariant because you only ever read apples out of a box of apples — reading is safe when going from a specific type to a more general one. A function parameter is the mirror image: you only ever hand values in. A function that can handle any Object can obviously also handle the narrower case of an Animal — so a Function(Object) safely substitutes for a Function(Animal), which is the opposite direction from container covariance. Both rules exist for the same reason: they only allow substitutions that can't actually go wrong.
Dart's generic classes are covariant (List<int> is-a List<num>), enforced soundly only via a runtime TypeError check on writes. covariant lets a subclass legally narrow an overridden parameter's type, at the cost of the same kind of runtime check. Function types are contravariant in their parameters: a function accepting a wider type fits wherever one accepting a narrower type is expected.

6. Generic typedefs, Object? vs dynamic, raw types

dynamic is like telling airport security "don't scan this bag at all" — anything at all can be inside, and you only find out what's wrong with it when it's already through, out in the world, causing a problem. Object? is more like "scan it, and you're allowed to carry anything — but you can only use what a generic passenger is guaranteed to have (a boarding pass, an ID)." Both accept "anything", but only one of them keeps checking.

A generic typedef gives a reusable, generic function shape a short name, without changing behavior at all: typedef Reducer<T> = T Function(T acc, T next); means "a function that folds two Ts into one T" — useful for something like a generic reduceAll<T>(List<T> xs, T seed, Reducer<T> f). It's pure sugar for readability, exactly like the non-generic typedefs from D07.

The same thing as runnable Dart — reduceAll with its generic typedef, used with three different Ts (the // => lines are the exact output, checked by verify/d10.dart):

The same thing as runnable Dart — dynamic versus Object? side by side. The rejected line is kept as a comment with its exact error (the // => lines are the exact output, checked by verify/d10.dart):

The same thing as runnable Dart — a raw List and what it silently means (the // => lines are the exact output, checked by verify/d10.dart):

Input size → what's feasible: typedefs and type arguments are compile-time names with zero runtime cost; reduceAll over n = 106 items makes 106 calls to f (about 106 steps).

A common misconception: writing plain List (no <...> at all) is not some special "generics disabled" mode. Dart quietly fills in the missing type argument with dynamic for you — a raw type List literally is List<dynamic>, provably (raw.runtimeType prints List<dynamic>). Because this can hide bugs the same way dynamic can, many teams switch on the analyzer's opt-in strict-raw-types setting (under analyzer: language: in analysis_options.yaml), which flags every raw generic type in the codebase and forces you to write the type argument out, even if it's <dynamic> on purpose.
dynamic switches off static checking for a value entirely — mistakes surface only at runtime. Object? is a real, checked type that only exposes what every object (and possibly null) is guaranteed to have. A bare List (or any raw generic type) silently means List<dynamic> — strict-raw-types is the opt-in analyzer setting that catches this by name.

7. Generic collections recap and how the analyzer uses them

Every collection type you already use daily — List<T>, Map<K, V>, Set<T> — is just an ordinary generic class, built by the Dart team using exactly the same tools you now have: type parameters, optionally bounded, reified at runtime. There is no special magic reserved for the built-in collections that Box<T> or Pair<A, B> couldn't also use.

Map<K, V> has two independent type parameters, exactly like your own Pair<A, B> from section 2 — K for every key, V for every value. Set<T> has one, exactly like Box<T>.

The same thing as runnable Dart — Map<String, int> and Set<String> used the way the analyzer sees them, with the two rejected lines kept as comments (the // => lines are the exact output, checked by verify/d10.dart):

Input size → what's feasible: map and set lookups are O(1) on average, so building or querying a Map<String, int> with n = 106 entries is about 106 steps.

Generics aren't a special-case feature bolted onto collections — collections are simply the most common example of generics in action. Once you can read Map<String, int> and know exactly what the analyzer will and won't allow, you already understand how every other generic type in Dart (including your own) behaves.

Quiz

Interview questions

Cheat sheet

ConceptSyntax / rule
Generic classclass Box<T> { T value; Box(this.value); } — each instantiation (Box<int>) is a distinct type
Multiple type paramsclass Pair<A, B> { final A first; final B second; }
Generic functionT first<T>(List<T> xs) => xs[0]; — T is usually inferred from the argument
BoundT extends Comparable<T> / T extends num — restricts T, unlocks methods on it; unbounded T defaults to Object?
int/num bound gotchaint implements Comparable<num>, not Comparable<int> — use a num bound for numeric code
Reified genericsType arguments exist at runtime: xs is List<int> gives a real answer (unlike Java-style erasure)
Covariant genericsList<int> is-a List<num>; adding a value of the wrong REAL element type throws a runtime TypeError
covariant keywordLegally narrows an overridden parameter's type, trading a compile-time guarantee for a runtime check
Function contravariancevoid Function(Object) is assignable where void Function(Animal) is expected — parameters go the OPPOSITE direction from container covariance
Generic typedeftypedef Reducer<T> = T Function(T acc, T next); — names a generic function shape, changes no behavior
dynamic vs Object?dynamic disables static checks entirely (errors surface at runtime); Object? is checked and only exposes universal members
Raw typeBare List (no <...>) silently means List<dynamic> — strict-raw-types analyzer setting flags this
Built-in genericsList<T>, Map<K, V>, Set<T> are ordinary generic classes — no special-case rules beyond what you've learned here