Sparround

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.

TypeWhat it acceptsCalling methods on itChecking
`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`EverythingAny method — uncheckedNone; errors surface at runtime
`T` (unbounded)EverythingAt the `Object?` levelStatic, resolved at the call site
`T extends X`Only `X` and its subtypesAll 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 alldynamic 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