Course outline

Dart / Contracts: patterns, generic APIs, and asynchronous results / Lesson 9

Use generic types without unsafe write assumptions

Preserve element types, choose meaningful bounds, and understand Dart covariance and runtime checks at mutable collection boundaries.

About 5 min readContent reviewed
On this page

Lesson goals

  • Explain why a broader reference does not make a narrower mutable object accept every value.
  • Distinguish reified generic type checks, lazy cast views, and validated copies.

A type parameter preserves an input-output relationship

A generic function can keep the caller’s element type instead of returning dynamic. A type parameter is useful when it expresses a relationship, such as returning one of the supplied T values. Add a bound only when the implementation needs operations guaranteed by that bound. Object excludes null, while Object? includes it; a bound can therefore change optionality as well as available members. Avoid declaring an unconstrained T and then casting it to a favorite concrete type inside the body. Either state the required capability or accept a behavior parameter that supplies the operation.

Return the caller’s type through a small generic helper

The helper returns the first element or null for an empty input. The T? result makes the absence visible even when T itself is non-nullable. It does not prove that an arbitrary list is immutable or that iteration cannot fail. The example uses fixed int and String lists so the inferred result types are easy to follow. A public API should still explain whether it returns an existing object reference or a copy, and whether the input is traversed once. Generic syntax preserves a type relationship but does not create ownership or lifetime guarantees.

dart A type-preserving nullable result
T? firstOrNull<T>(List<T> values) => values.isEmpty ? null : values.first;
void main() {
  final number = firstOrNull<int>(<int>[7, 8]);
  final name = firstOrNull<String>(<String>[]);
  print(number);
  print(name ?? 'absent');
}
Expected output
7
absent

Expected output is 7 then absent. The function returns the existing first value rather than cloning it. The source was not compiled or analyzed with Dart tooling.

Dart permits covariance and checks unsafe writes

Dart generic classes are covariant in their type parameters by default. A List<int> can therefore be referenced as List<num>, but that does not convert its actual element storage into a list accepting double. A later incompatible write through the broader reference is checked at runtime and can fail. This differs from languages that reject the widening assignment itself. Prefer a separate intentionally wider collection when callers need to add wider values. Do not use a cast merely to persuade the analyzer that mutation is safe; inspect the runtime object’s contract and its aliases.

Build a wider container when wider writes are required

The sample explicitly constructs a List<num> from integer values, then adds a double to that new list. The original list retains its integer element promise. This is a shallow container copy, so the same technique would still share mutable element objects in a reference-valued model. The commented unsafe operation is described in prose rather than executed or given a fabricated exception trace. Runtime checks are an important part of Dart’s soundness story, but avoiding a preventable failing write is clearer than relying on a catch around every collection mutation.

dart A broader view versus an intentionally broader copy
void main() {
  final integers = <int>[1, 2];
  final List<num> view = integers;
  final wider = <num>[...integers];
  wider.add(2.5);
  print(view.length);
  print(wider.join('|'));
  print(integers.join('|'));
}
Expected output
2
1|2|2.5
1|2

Expected output is 2, 1|2|2.5, then 1|2. Adding 2.5 to view instead would violate the underlying List<int> write contract and can fail at runtime; that operation is not performed here. No Dart execution occurred.

A cast view is not an eager data-cleaning pass

Dart retains generic type information at runtime for relevant type tests, unlike a blanket assumption that all type arguments disappear. Still, an externally decoded List<dynamic> is not automatically a validated List<String>. APIs such as cast can expose a view that checks elements when accessed, so invalid content may fail later. Choose an eager validated conversion when the boundary needs one clear rejection point. Check every required element and domain rule, preserve a safe error reason, and avoid losing invalid items silently unless filtering is the declared policy. Reified types support checks; they do not replace meaningful validation.

Common errors and fixes

A double write fails through a List<num> reference.

Why it happens
The underlying object is still a List<int> with a narrower write contract.
How to fix it
Create a genuinely wider container or keep the operation within the original element type.
Prevent it next time
Review covariance together with all mutation paths.

A cast fails long after external data was accepted.

Why it happens
A lazy cast view deferred element checking.
How to fix it
Validate and copy at the boundary when immediate rejection is required.
Prevent it next time
Document whether a conversion is eager validation or a checked view.

A generic function invokes operations not guaranteed for T.

Why it happens
The required capability was not represented by a bound or parameter.
How to fix it
State the narrow bound or pass the required behavior explicitly.
Prevent it next time
Design the operation before choosing broad generic syntax.

Check your understanding

1. Why does assigning List<int> to List<num> not make it a new List<num> object?

Show worked solution

The assignment changes the static reference type, not the identity or implementation of the existing list. Reads can safely expose ints as nums, but writes must respect the original runtime element type. Construct a separate wider list if double insertion is needed, and remember that copying a container does not deep-copy referenced elements.

2. Should an API silently remove every non-string item while converting an external list of labels?

Show worked solution

Only if partial filtering is the declared product policy. Otherwise it can turn malformed input into an apparently complete success. Validate all required elements and return a structured rejection or a report of accepted and rejected items. Runtime type information helps identify types, but the API must decide what incomplete data means.

Key takeaways

  • Covariance permits useful reads but can move incompatible writes to runtime checks.
  • A validated copy and a checked view make different timing and ownership promises.

Official references