Dart Generics — Type-Safe Parameterized Programming
In this tutorial, you will learn about Dart Generics. We cover key concepts, practical examples, and best practices to help you master this topic.
Dart generics enable you to write type-safe code that works with any type by parameterizing classes, methods, and functions with type parameters that are resolved at compile time.
What You Will Learn
- Defining generic classes and methods
- Using type parameters in collections
- Bounded type parameters with
extends - Generic methods and functions
- Type inference with generics
- Reified generics and type checking at runtime
Why It Matters
Without generics, collections store Object values and require manual casting on retrieval, which can fail at runtime. Generics shift type checking to compile time, eliminating class cast exceptions. Dart's generics are reified (type information is preserved at runtime), which means you can check generic types with is and as. This is different from Java's type erasure and enables runtime type introspection that is critical for serialization, dependency injection, and state management.
Real-World Use
The DodaTech app uses generic Repository<T> classes for all data access. A UserRepository extends Repository<User>, a ProductRepository extends Repository<Product>, and so on. The generic base class provides getById, getAll, save, and delete methods that work with any type, eliminating duplicate data access code.
Learning Path
flowchart LR A[Dart Mixins] --> B[Dart Generics\nYou are here] B --> C[Async Programming] style B fill:#f90,color:#fff
Why Generics Matter
Consider code without generics versus with generics:
void main() {
// Without generics: unsafe
var list = <dynamic>[];
list.add(42);
list.add('hello');
var value = list[0] as String; // Runtime error!
// With generics: compile-time safety
var numbers = <int>[];
numbers.add(42);
// numbers.add('hello'); // COMPILE ERROR
var safeValue = numbers[0]; // Type is int, no cast needed
print('Safe value: $safeValue');
}
Output:
Safe value: 42
Generics catch type errors at compile time, eliminating runtime cast failures. IDEs also provide better auto-completion and documentation when types are known.
Generic Collections
Dart's collection types are generic. The angle bracket syntax specifies the element type:
void main() {
// Generic List
List<String> names = ['Alice', 'Bob', 'Charlie'];
String first = names[0]; // No cast needed
// Generic Set
Set<int> uniqueIds = {1, 2, 3};
bool hasTwo = uniqueIds.contains(2);
// Generic Map
Map<String, int> scores = {
'Alice': 95,
'Bob': 87,
};
int? aliceScore = scores['Alice'];
// Type inference with var
var cities = <String>['New York', 'London']; // Inferred as List<String>
var lookup = <String, String>{}; // Inferred as Map<String, String>
print('$first, hasTwo: $hasTwo, Alice scored: $aliceScore');
}
Output:
Alice, hasTwo: true, Alice scored: 95
Always specify the type parameter when creating collections. Without it, var list = [] creates List<dynamic>, which loses type safety.
Generic Classes
Define your own generic classes using type parameters in angle brackets:
class Box<T> {
T value;
Box(this.value);
T getValue() => value;
void setValue(T newValue) {
value = newValue;
}
}
void main() {
var intBox = Box<int>(42);
print('Int value: ${intBox.getValue()}');
var stringBox = Box<String>('Hello');
print('String value: ${stringBox.getValue()}');
// Type inference from constructor
var inferredBox = Box(3.14); // Box<double>
print('Double value: ${inferredBox.getValue()}');
}
Output:
Int value: 42
String value: Hello
Double value: 3.14
The type parameter T is a placeholder for the actual type. T can be used as a field type, method parameter type, and return type. Dart infers T from the constructor argument when possible.
Multiple Type Parameters
Generic classes can have multiple type parameters:
class Pair<A, B> {
final A first;
final B second;
Pair(this.first, this.second);
@override
String toString() => 'Pair($first, $second)';
}
void main() {
var pair1 = Pair<String, int>('Alice', 30);
var pair2 = Pair<String, String>('key', 'value');
var pair3 = Pair<double, double>(3.14, 2.71);
print(pair1);
print(pair2);
print(pair3);
// Swapping values
var swapped = Pair(pair1.second, pair1.first);
print('Swapped: $swapped');
}
Output:
Pair(Alice, 30)
Pair(key, value)
Pair(3.14, 2.71)
Swapped: Pair(30, Alice)
Multiple type parameters are separated by commas. They are useful for key-value pairs, coordinate systems, and any data structure that holds two related values of different types.
Generic Methods
Methods and functions can also be generic, even when the enclosing class is not:
// Generic top-level function
T first<T>(List<T> items) {
if (items.isEmpty) throw ArgumentError('List is empty');
return items[0];
}
// Generic method on a non-generic class
class Utils {
static T? findFirstWhere<T>(List<T> items, bool Function(T) predicate) {
for (var item in items) {
if (predicate(item)) return item;
}
return null;
}
}
void main() {
var numbers = [1, 2, 3, 4, 5];
print('First: ${first(numbers)}');
var names = ['Alice', 'Bob', 'Charlie'];
print('First name: ${first(names)}');
var found = Utils.findFirstWhere<String>(
names,
(name) => name.startsWith('B'),
);
print('Found: $found');
// Type inference works with generic methods
var result = Utils.findFirstWhere(numbers, (n) => n > 3);
print('Found number: $result');
}
Output:
First: 1
First name: Alice
Found: Bob
Found number: 4
The type parameter <T> on the method comes before the parameter list. It can be inferred from the arguments, so explicit type annotation at the call site is often unnecessary.
Bounded Type Parameters
Restrict type parameters to a specific type hierarchy using extends:
abstract class Shape {
double area();
}
class Circle extends Shape {
final double radius;
Circle(this.radius);
@override
double area() => 3.14159 * radius * radius;
}
class Rectangle extends Shape {
final double width;
final double height;
Rectangle(this.width, this.height);
@override
double area() => width * height;
}
// Only accepts types that extend Shape
class ShapeCalculator<T extends Shape> {
double totalArea(List<T> shapes) {
return shapes.fold(0.0, (sum, shape) => sum + shape.area());
}
}
void main() {
var calculator = ShapeCalculator<Shape>();
var shapes = [Circle(5), Rectangle(4, 6)];
print('Total area: ${calculator.totalArea(shapes)}');
// This would fail:
// var stringCalc = ShapeCalculator<String>(); // COMPILE ERROR
}
Output:
Total area: 102.53975
T extends Shape constrains the type parameter to Shape or any subclass. The method can safely call Shape methods on values of type T. This is called upper bound typing.
Reified Generics
Dart's generics are reified, meaning type information is preserved at runtime:
void main() {
var intList = <int>[];
var stringList = <String>[];
print('intList is List<int>: ${intList is List<int>}');
print('stringList is List<String>: ${stringList is List<String>}');
print('intList is List<String>: ${intList is List<String>}');
// Runtime type checking with generics
checkType<int>(42);
checkType<String>('Hello');
}
void checkType<T>(Object value) {
if (value is T) {
print('$value is of type $T');
} else {
print('$value is NOT of type $T');
}
}
Output:
intList is List<int>: true
stringList is List<String>: true
intList is List<String>: false
42 is of type int
Hello is of type String
Reified generics enable runtime type checks that are impossible in languages with type erasure (like Java). This is critical for serialization libraries, dependency injection frameworks, and state management solutions.
Generic Factory
Generic factory constructors return instances of the type parameter:
class ApiResponse<T> {
final T data;
final int statusCode;
final String? message;
ApiResponse(this.data, this.statusCode, {this.message});
// Generic factory from JSON
factory ApiResponse.fromJson(
Map<String, dynamic> json,
T Function(Map<String, dynamic>) fromJsonT,
) {
return ApiResponse(
fromJsonT(json['data'] as Map<String, dynamic>),
json['statusCode'] as int,
message: json['message'] as String?,
);
}
}
class User {
final String name;
final int age;
User(this.name, this.age);
factory User.fromJson(Map<String, dynamic> json) {
return User(json['name'] as String, json['age'] as int);
}
}
void main() {
var json = {
'data': {'name': 'Alice', 'age': 30},
'statusCode': 200,
};
var response = ApiResponse<User>.fromJson(json, User.fromJson);
print('User: ${response.data.name}, status: ${response.statusCode}');
}
Output:
User: Alice, status: 200
The generic factory fromJson accepts a conversion function that transforms the JSON data into the target type. This pattern is used by serialization libraries.
Generic with Mixins
Generics can be combined with mixins for flexible behavior injection:
mixin Cacheable<T> {
final Map<String, T> _cache = {};
T? getFromCache(String key) => _cache[key];
void addToCache(String key, T value) {
_cache[key] = value;
}
void clearCache() => _cache.clear();
}
class DataService<T> with Cacheable<T> {
Future<T> fetchData(String id) async {
var cached = getFromCache(id);
if (cached != null) {
print('Returning cached data for $id');
return cached;
}
// Simulate API call
print('Fetching data for $id from API');
await Future.delayed(Duration(milliseconds: 100));
var data = '{id: $id, value: "data"}' as T;
addToCache(id, data);
return data;
}
}
void main() async {
var service = DataService<String>();
var result1 = await service.fetchData('user_1');
print('Result: $result1');
var result2 = await service.fetchData('user_1');
print('Result: $result2');
}
Output:
Fetching data for user_1 from API
Result: {id: user_1, value: "data"}
Returning cached data for user_1
Result: {id: user_1, value: "data"}
The mixin Cacheable<T> provides Caching behavior parameterized by the type of data being cached. Any generic class can mix it in and get type-safe caching.
Common Mistakes
Using
List<dynamic>instead of a typed list: When type information is lost, the compiler cannot catch type errors. Always specify element types.Forgetting that
ischecks work with reified generics: Uselist is List<int>to check at runtime. This is not possible in Java but works in Dart.Over-constraining type parameters: Only use
extendswhen you actually need to call methods on the bounded type. Unnecessary constraints reduce reusability.Not using type inference when it makes code clearer:
Box<int>(42)can be simplified toBox(42)when the type is obvious. Explicit types are useful for documentation and when inference would be wrong.Confusing
List<Object>withList<dynamic>:List<Object>accepts any type but guarantees the elements areObjectinstances.List<dynamic>disables type checking entirely and is less safe.
Practice Questions
- How do Dart's reified generics differ from Java's type erasure?
- What is the purpose of bounded type parameters with
extends? - How does type inference work with generic methods?
- Why would you use multiple type parameters in a class?
- Challenge: Implement a generic
Cache<K, V>class that stores key-value pairs with a configurable maximum size. When the cache exceeds the maximum, remove the least recently used entry. Addget,set, andinvalidatemethods.
Mini Project
Build a generic data access layer:
Repository<T>interface withgetById,getAll,save,deleteInMemoryRepository<T>implementing it with aMap<String, T>UserandProductclasses withid,name, and type-specific fields- A
SerializationService<T>that converts betweenTandMap<String, dynamic> - Combine repository and serialization in a
GenericApiClient<T> - Write tests for the generic components
FAQ
What is Next
Now that you understand generics, learn about asynchronous programming. Proceed to Async Programming in Dart for futures, async-await, and handling asynchronous operations. Then explore Dart Streams for continuous data flows.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro