Generics and the type system
A generic is a class or function that takes a type as a parameter. The goal: reuse the same logic across different types without losing type safety.
- Generic class:
class Cache<T> { T? get(String key); void put(String key, T value); } - Generic function:
T firstOr<T>(List<T> items, T fallback) => items.isEmpty ? fallback : items.first; - Several parameters:
class Pair<A, B> { ... }
The alternative — making everything dynamic — shortens the code but pushes every mistake to runtime. A generic instead knows the concrete type at the call site: for a Cache<User>, get returns User? and the compiler checks it.
A bounded generic constrains the type parameter: T extends Comparable<T> allows only comparable types. That is what lets you call the type's methods inside the generic body. Without a bound, T behaves as Object? and there is almost nothing you can do with it.
| Type | What it accepts | Calling methods on it | Checking |
|---|---|---|---|
| `Object` | Everything except `null` | Only `Object` members (`toString`, `hashCode`) | Static — an `is` check is required |
| `Object?` | Everything, including `null` | A null check first, then `is` | Static |
| `dynamic` | Everything | Any method — unchecked | None; errors surface at runtime |
| `T` (unbounded) | Everything | At the `Object?` level | Static, resolved at the call site |
| `T extends X` | Only `X` and its subtypes | All members of `X` | Static |
Covariance and its price. Generics in Dart are covariant: List<int> is considered a subtype of List<num>. That makes everyday code convenient (you can pass a List<int> to a function expecting List<num>), but it is not safe for writes.
The scenario: List<int> ints = [1, 2]; List<num> nums = ints; nums.add(3.5); — statically everything is legal, because 3.5 is a num. At runtime the object is still a List<int> and add throws a TypeError.
To stay sound, Dart inserts a runtime downcast check — which is exactly why the failure happens at the add call rather than at compile time. Covariance is a deliberate trade for convenience, and its price is that small runtime check.
The practical consequence: when an API accepts a shared collection at a wider type (List<num>), either take it read-only (Iterable<num>) or make your own copy (List<num>.from(...)).
`is`, `as` and promotion. is is a runtime type test; when it succeeds, flow analysis promotes the variable and no as is needed. as is a cast that throws a TypeError when wrong. is! is the negated form.
The rule: before writing `as`, ask whether an `is` check would do. if (x is User) { x.name } is both safer and more readable than a cast. Dart 3 patterns take it one step further: if (x case User(:final name)).
Legitimate uses of as: dynamic boundaries such as the result of jsonDecode, generics where the type is known at the call site, and platform interop. For every as, ask: if this cast is wrong, where does the error surface — here, or three screens later?
Note: a cast on a dynamic value gives no static checking at all — dynamic d = 'text'; final n = d as int; only throws at runtime while the analyzer stays silent.
Interview tip. "What is the difference between Object, Object? and dynamic?" — telling these three apart is a middle-level marker. A strong answer: "`Object` is everything except `null`, `Object?` is everything including `null`; both keep type checking, and you need an `is` test before calling a method. `dynamic` is not a type but the absence of checking — any method call compiles and may throw `NoSuchMethodError` at runtime." Then add the practical choice: Object? for an "accepts anything" parameter, dynamic only at untyped JSON boundaries — and convert to a real type immediately.
The second strong topic is covariance. Being able to explain why assigning a List<int> to a List<num> and calling add(3.5) throws at runtime is a senior signal.
📚 Sources and documentation
- Genericsofficialdart.dev