Dart Generics
last modified May 30, 2026
In this article we show how to use generics in Dart language.
Generics allow you to write code that works with any type while still
preserving full type safety. Instead of writing separate implementations for
int, String, and every other type, you write one
generic implementation parameterized by a type variable such as
T.
Without generics, the alternatives are dynamic (which loses type
information at compile time) or code duplication (one class per type). Generics
eliminate both problems: the code is written once and the compiler verifies
correct usage for each concrete type.
Dart has reified generics. Type parameters are not erased after
compilation; they are preserved at runtime. This means is checks
and runtimeType queries work correctly with parameterized types
such as List<int> and List<String>, which
are treated as distinct types.
Dart generic types
A generic class is declared by placing a type parameter in angle brackets after the class name. The type parameter acts as a placeholder that is replaced with a concrete type when the class is instantiated.
class Box<T> {
T value;
Box(this.value);
@override
String toString() => 'Box($value)';
}
void main() {
final intBox = Box(42);
final strBox = Box('hello');
final dblBox = Box<double>(3.14);
print(intBox); // Box(42)
print(strBox); // Box(hello)
print(dblBox); // Box(3.14)
print(intBox.value + 1); // 43
}
The Box<T> class wraps a single value of type
T. When you write Box(42), Dart infers
T as int. The type can also be stated explicitly as
in Box<double>(3.14).
class Box<T> {
T value;
The letter T is a type parameter. It can be any identifier, but
single uppercase letters such as T, E,
K, and V are the established convention.
$ dart main.dart Box(42) Box(hello) Box(3.14) 43
Multiple type parameters
A class can accept more than one type parameter. The Pair<K, V>
class below holds two values of potentially different types.
class Pair<K, V> {
final K first;
final V second;
const Pair(this.first, this.second);
Pair<V, K> swap() => Pair(second, first);
@override
String toString() => '($first, $second)';
}
void main() {
var p1 = Pair('id', 101);
var p2 = Pair(3.14, true);
var p3 = Pair('name', 'Alice');
print(p1); // (id, 101)
print(p2); // (3.14, true)
print(p3); // (name, Alice)
print(p3.swap()); // (Alice, name)
}
K and V are independent type parameters. The
swap method returns a new Pair with the types
reversed, demonstrating how type parameters can be combined and manipulated.
$ dart main.dart (id, 101) (3.14, true) (name, Alice) (Alice, name)
Bounded type parameters
A bounded type parameter restricts which types can be used as the argument.
The extends keyword establishes an upper bound. Only types that
are subtypes of the bound are allowed.
class Stats<T extends num> {
final List<T> data;
Stats(this.data);
double get average {
num total = 0;
for (var v in data) total += v;
return total / data.length;
}
T get max => data.reduce((a, b) => a > b ? a : b);
T get min => data.reduce((a, b) => a < b ? a : b);
}
void main() {
var ints = Stats([4, 7, 2, 9, 1]);
print(ints.average); // 4.6
print(ints.max); // 9
print(ints.min); // 1
var doubles = Stats([1.5, 3.2, 0.8]);
print(doubles.average); // 1.8333...
print(doubles.max); // 3.2
}
Because T extends num, the compiler knows that values of type
T support arithmetic and comparison. Passing a
String for T would be a compile-time error.
T get max => data.reduce((a, b) => a > b ? a : b);
The > comparison is available because num
implements Comparable and defines the relational operators.
$ dart main.dart 4.6 9 1 1.8333333333333333 3.2
Covariance
Dart List is covariant: a List<Dog> can be
assigned to a variable of type List<Animal> when
Dog extends Animal. This mirrors how object
references work in Dart.
Covariance is a property of type systems where a type can be
substituted with its subtype. In Dart, this means a List<Dog>
can be used wherever a List<Animal> is expected.
class Animal {
final String name;
Animal(this.name);
void speak() => print('...');
}
class Dog extends Animal {
Dog(super.name);
@override
void speak() => print('$name: woof');
}
class Cat extends Animal {
Cat(super.name);
@override
void speak() => print('$name: meow');
}
void makeNoise(List<Animal> animals) {
for (var a in animals) a.speak();
}
void main() {
final dogs = [Dog('Rex'), Dog('Buddy')];
final cats = [Cat('Whiskers'), Cat('Luna')];
makeNoise(dogs); // List<Dog> accepted as List<Animal>
makeNoise(cats); // List<Cat> accepted as List<Animal>
}
The makeNoise function accepts List<Animal>, but
both List<Dog> and List<Cat> are passed
without any cast. This works because Dart lists are covariant in their element
type.
Animal to the list inside makeNoise would succeed
statically but throw a TypeError at runtime if the underlying
list is actually a List<Dog>. The covariant
keyword on method parameters explicitly opts in to the same narrowing for
overridden methods.
Dart generic functions
A function can declare its own type parameter, independently of any class. The parameter is placed between the function name and the argument list.
T first<T>(List<T> items) {
if (items.isEmpty) throw StateError('List is empty');
return items.first;
}
T last<T>(List<T> items) {
if (items.isEmpty) throw StateError('List is empty');
return items.last;
}
void main() {
print(first([10, 20, 30])); // 10
print(first(['a', 'b', 'c'])); // a
print(last([10, 20, 30])); // 30
print(first<double>([1.1, 2.2])); // 1.1
}
The return type T is the same type as the list element type. The
caller gets back a properly typed value without any cast. The type argument can
be inferred from the list, or stated explicitly as in
first<double>.
$ dart main.dart 10 a 30 1.1
Type inference with generics
Dart infers type arguments from context in most cases. Explicit type arguments are only needed when inference cannot determine the type, or when you want to be explicit for clarity.
List<T> repeat<T>(T value, int count) =>
List.generate(count, (_) => value);
Map<K, V> mapOf<K, V>(K key, V value) => {key: value};
void main() {
var ints = repeat(0, 4); // inferred: List<int>
var words = repeat('hi', 3); // inferred: List<String>
var flags = repeat(false, 2); // inferred: List<bool>
print(ints); // [0, 0, 0, 0]
print(words); // [hi, hi, hi]
print(flags); // [false, false]
var entry = mapOf('score', 100); // inferred: Map<String, int>
print(entry); // {score: 100}
}
The type of the value argument drives inference. When you pass
0, Dart infers T as int; when you pass
'hi', it infers String. The result is fully typed
without any explicit annotations.
$ dart main.dart
[0, 0, 0, 0]
[hi, hi, hi]
[false, false]
{score: 100}
Constraints on generic functions
A generic function can constrain its type parameter with extends,
just like a generic class. This allows the function to call methods defined by
the bound type.
T largest<T extends Comparable<T>>(List<T> items) =>
items.reduce((a, b) => a.compareTo(b) >= 0 ? a : b);
T smallest<T extends Comparable<T>>(List<T> items) =>
items.reduce((a, b) => a.compareTo(b) <= 0 ? a : b);
void main() {
print(largest([3, 1, 4, 1, 5, 9, 2, 6])); // 9
print(largest(['banana', 'apple', 'cherry'])); // cherry
print(smallest([3.0, 1.5, 2.7])); // 1.5
}
The bound T extends Comparable<T> gives the function access to
compareTo. Any type that implements Comparable can be
used: int, double, String, and custom
types that implement the interface.
$ dart main.dart 9 cherry 1.5
Dart generic collections
Dart's built-in collection types — List, Set, and
Map — are all generic. Providing type arguments ensures that only
values of the correct type are added and that values retrieved from the
collection already have the right type.
class Product {
final String name;
final double price;
Product(this.name, this.price);
@override
String toString() => '$name: \$$price';
}
void main() {
final products = <Product>[
Product('Apple', 0.99),
Product('Cherry', 2.99),
Product('Banana', 0.49),
];
products.sort((a, b) => a.price.compareTo(b.price));
for (var p in products) print(p);
print('');
final prices = products.map((p) => p.price).toList();
print(prices); // [0.49, 0.99, 2.99]
}
The list is typed as List<Product>. The lambda in
sort and map receives Product values
directly — no casts needed. Attempting to add a non-Product value
would be caught at compile time.
$ dart main.dart Banana: $0.49 Apple: $0.99 Cherry: $2.99 [0.49, 0.99, 2.99]
Generic set and map
Sets and maps follow the same pattern. A Set<String> rejects
duplicates while keeping only string values. A Map<K, V>
associates keys of type K with values of type V.
void main() {
// Generic Set
final visited = <String>{};
final urls = ['home', 'about', 'home', 'contact', 'about'];
for (var url in urls) {
if (visited.add(url)) {
print('Visiting: $url');
} else {
print('Already seen: $url');
}
}
print('');
// Generic Map
final scores = <String, int>{};
scores['Alice'] = 95;
scores['Bob'] = 82;
scores['Carol'] = 91;
final sorted = scores.entries.toList()
..sort((a, b) => b.value.compareTo(a.value));
for (var e in sorted) {
print('${e.key}: ${e.value}');
}
}
Set.add returns false when the element already exists,
providing a clean way to detect duplicates. The map entries are sorted by value
in descending order using a typed comparator.
$ dart main.dart Visiting: home Visiting: about Already seen: home Visiting: contact Already seen: about Alice: 95 Carol: 91 Bob: 82
Generic methods in classes
A method can declare its own type parameter even when its class is not generic. This is useful when only one specific operation needs to be type-parameterized.
class Transformer {
List<R> mapList<T, R>(List<T> items, R Function(T) fn) =>
items.map(fn).toList();
List<T> filter<T>(List<T> items, bool Function(T) test) =>
items.where(test).toList();
T? find<T>(List<T> items, bool Function(T) test) {
for (var item in items) {
if (test(item)) return item;
}
return null;
}
}
void main() {
final t = Transformer();
final nums = [1, 2, 3, 4, 5];
print(t.mapList(nums, (n) => n * n)); // [1, 4, 9, 16, 25]
print(t.mapList(nums, (n) => 'item_$n')); // [item_1, ..., item_5]
print(t.filter(nums, (n) => n.isEven)); // [2, 4]
print(t.find(['a', 'b', 'c'], (s) => s == 'b')); // b
}
The Transformer class is not generic, but each method carries its
own type parameters. mapList<T, R> takes a list of one type and
produces a list of a different type. Both type parameters are inferred from the
arguments.
List<R> mapList<T, R>(List<T> items, R Function(T) fn)
Method-level type parameters are appropriate when the genericity is local to that one operation. Class-level type parameters are appropriate when the entire class works with a single type throughout its lifetime.
$ dart main.dart [1, 4, 9, 16, 25] [item_1, item_2, item_3, item_4, item_5] [2, 4] b
Generic interfaces and abstract classes
Abstract classes and interfaces can be generic. Concrete classes implement them by supplying a specific type argument, which the compiler then enforces throughout the implementation.
abstract class Serializable<T> {
Map<String, dynamic> toJson();
T fromJson(Map<String, dynamic> json);
}
class User implements Serializable<User> {
final int id;
final String name;
User(this.id, this.name);
@override
Map<String, dynamic> toJson() => {'id': id, 'name': name};
@override
User fromJson(Map<String, dynamic> json) =>
User(json['id'] as int, json['name'] as String);
@override
String toString() => 'User($id, $name)';
}
void main() {
final u = User(1, 'Alice');
final json = u.toJson();
final restored = u.fromJson(json);
print(u); // User(1, Alice)
print(json); // {id: 1, name: Alice}
print(restored); // User(1, Alice)
print(u == restored); // false (different instances)
}
Serializable<T> expresses the contract: any class that
implements it must provide toJson and a fromJson that
returns the concrete type T. The compiler ensures the return type
matches.
$ dart main.dart
User(1, Alice)
{id: 1, name: Alice}
User(1, Alice)
false
Extending generic types
A class can extend a generic base class and either fix the type parameter or pass it through. Fixing the parameter specializes the base class; passing it through keeps the subclass generic.
abstract class Collection<T> {
final List<T> _items = [];
void add(T item) => _items.add(item);
int get length => _items.length;
List<T> get all => List.unmodifiable(_items);
}
// Subclass keeps the type parameter open
class SortedList<T extends Comparable<T>> extends Collection<T> {
@override
void add(T item) {
super.add(item);
_items.sort();
}
}
// Subclass fixes the type parameter to String
class NameList extends Collection<String> {
void addNormalized(String name) => add(name.trim().toLowerCase());
}
void main() {
final sorted = SortedList<int>();
sorted.add(3);
sorted.add(1);
sorted.add(2);
print(sorted.all); // [1, 2, 3]
final names = NameList();
names.addNormalized(' Alice ');
names.addNormalized('BOB');
print(names.all); // [alice, bob]
}
$ dart main.dart [1, 2, 3] [alice, bob]
Dart generic mixins
Mixins can also be generic. A generic mixin receives a type parameter that is provided by the class mixing it in.
mixin Logger<T> {
void log(T value) => print('[${T.toString()}] $value');
}
class StringProcessor with Logger<String> {
String process(String input) {
final result = input.trim().toLowerCase();
log(result);
return result;
}
}
class NumberProcessor with Logger<int> {
int process(int input) {
final result = input.abs();
log(result);
return result;
}
}
void main() {
StringProcessor().process(' Hello World ');
NumberProcessor().process(-42);
}
The Logger<T> mixin is mixed into each class with a specific
type argument. T.toString() inside the mixin returns the name of
the actual type, which is available at runtime because Dart reifies generics.
$ dart main.dart [String] hello world [int] 42
Generic mixin with constraint
A generic mixin can constrain its type parameter with extends,
enabling it to use methods defined by the bound type.
mixin Summable<T extends num> {
List<T> get items;
T get sum {
num total = 0;
for (var item in items) total += item;
return total as T;
}
double get average => sum / items.length;
}
class IntList with Summable<int> {
@override
final List<int> items;
IntList(this.items);
}
class DoubleList with Summable<double> {
@override
final List<double> items;
DoubleList(this.items);
}
void main() {
final ints = IntList([1, 2, 3, 4, 5]);
print(ints.sum); // 15
print(ints.average); // 3.0
final doubles = DoubleList([1.5, 2.5, 3.0]);
print(doubles.sum); // 7.0
print(doubles.average); // 2.3333...
}
The mixin declares items as an abstract getter. The mixing-in
class supplies the concrete list. This pattern separates behavior (summing,
averaging) from the data structure that holds the values.
$ dart main.dart 15 3.0 7.0 2.3333333333333335
Generic typedefs
A typedef can be parameterized, giving a reusable name to a
family of function types.
typedef Predicate<T> = bool Function(T);
typedef Mapper<T, R> = R Function(T);
typedef Consumer<T> = void Function(T);
typedef Reducer<T> = T Function(T, T);
List<T> where<T>(List<T> items, Predicate<T> test) =>
items.where(test).toList();
List<R> transform<T, R>(List<T> items, Mapper<T, R> fn) =>
items.map(fn).toList();
T fold<T>(List<T> items, Reducer<T> fn) =>
items.reduce(fn);
void main() {
final nums = [1, 2, 3, 4, 5, 6];
final evens = where(nums, (n) => n.isEven);
print(evens); // [2, 4, 6]
final squares = transform(nums, (n) => n * n);
print(squares); // [1, 4, 9, 16, 25, 36]
final sum = fold(nums, (a, b) => a + b);
print(sum); // 21
// Typedef variables hold typed function references
Predicate<String> isLong = (s) => s.length > 4;
Mapper<String, int> strLen = (s) => s.length;
final words = ['hi', 'hello', 'hey', 'howdy'];
print(where(words, isLong)); // [hello, howdy]
print(transform(words, strLen)); // [2, 5, 3, 5]
}
Named typedefs make signatures more readable and reusable. A parameter of type
Predicate<T> is clearer than an anonymous
bool Function(T), especially in larger codebases.
$ dart main.dart [2, 4, 6] [1, 4, 9, 16, 25, 36] 21 [hello, howdy] [2, 5, 3, 5]
Generic extension methods
Extensions can be generic. A generic extension on a type adds new methods that work with the element type without modifying the original class.
extension ListExt<T> on List<T> {
T? firstOrNull(bool Function(T) test) {
for (var e in this) {
if (test(e)) return e;
}
return null;
}
List<T> sortedBy<K extends Comparable<K>>(K Function(T) key) {
final copy = [...this];
copy.sort((a, b) => key(a).compareTo(key(b)));
return copy;
}
Map<K, List<T>> groupBy<K>(K Function(T) key) {
final result = <K, List<T>>{};
for (var item in this) {
result.putIfAbsent(key(item), () => []).add(item);
}
return result;
}
}
class Person {
final String name;
final int age;
Person(this.name, this.age);
@override
String toString() => '$name($age)';
}
void main() {
final people = [
Person('Alice', 30),
Person('Bob', 25),
Person('Carol', 30),
Person('Dave', 25),
];
print(people.firstOrNull((p) => p.age > 28)); // Alice(30)
print(people.sortedBy((p) => p.name)); // [Alice(30), Bob(25), Carol(30), Dave(25)]
final byAge = people.groupBy((p) => p.age);
byAge.forEach((age, ps) => print('$age: $ps'));
}
$ dart main.dart Alice(30) [Alice(30), Bob(25), Carol(30), Dave(25)] 30: [Alice(30), Carol(30)] 25: [Bob(25), Dave(25)]
Advanced: reified generics
In Dart, generic type arguments are reified — they exist at runtime, not just at compile time. This enables runtime type checks and comparisons involving parameterized types.
void printType<T>() => print(T);
bool isListOf<T>(Object? obj) => obj is List<T>;
String describe<T>(T value) =>
'value: $value type: ${T.toString()} runtimeType: ${value.runtimeType}';
void main() {
printType<int>(); // int
printType<String>(); // String
printType<List<int>>(); // List<int>
print(isListOf<int>([1, 2, 3])); // true
print(isListOf<String>([1, 2, 3])); // false
print(isListOf<int>('hello')); // false
print(describe(42)); // value: 42 type: int runtimeType: int
print(describe('dart')); // value: dart type: String runtimeType: String
}
List<int> and List<String> are distinct types at
runtime. This differs from Java, where generic type information is erased at
compile time and both would appear as plain List at runtime.
$ dart main.dart int String List<int> true false false value: 42 type: int runtimeType: int value: dart type: String runtimeType: String
Using Type objects with generics
The Type class represents a type object at runtime. A generic
class or function can capture and expose its type parameter as a
Type value.
class TypedFactory<T> {
final T Function() _create;
TypedFactory(this._create);
T build() => _create();
Type get type => T;
bool matches(Object? obj) => obj is T;
}
void main() {
final intFactory = TypedFactory<int>(() => 0);
final strFactory = TypedFactory<String>(() => '');
print(intFactory.type); // int
print(strFactory.type); // String
print(intFactory.build()); // 0
print(strFactory.build()); // (empty string)
print(intFactory.matches(42)); // true
print(intFactory.matches('x')); // false
}
The expression T in a generic context evaluates to a
Type object at runtime. This allows factory classes and service
registries to track and compare types without any casts.
$ dart main.dart int String 0 true false
T = int vs T = String). There is no partial
application (you cannot fix one parameter and leave the other open without
creating a new type alias). Generic type parameters cannot be used as
constructors directly.
Real-world: generic repository pattern
The repository pattern abstracts data storage behind a typed interface. A generic base repository avoids duplicating CRUD logic for every entity type.
abstract class Entity {
int get id;
}
class Repository<T extends Entity> {
final _store = <int, T>{};
void save(T item) => _store[item.id] = item;
T? findById(int id) => _store[id];
void delete(int id) => _store.remove(id);
List<T> findAll() => _store.values.toList();
List<T> findWhere(bool Function(T) test) =>
_store.values.where(test).toList();
int get count => _store.length;
}
class User implements Entity {
@override
final int id;
final String name;
final String role;
User(this.id, this.name, this.role);
@override
String toString() => 'User($id, $name, $role)';
}
class Product implements Entity {
@override
final int id;
final String name;
final double price;
Product(this.id, this.name, this.price);
@override
String toString() => 'Product($id, $name, \$$price)';
}
void main() {
final users = Repository<User>();
users.save(User(1, 'Alice', 'admin'));
users.save(User(2, 'Bob', 'editor'));
users.save(User(3, 'Carol', 'admin'));
print(users.findById(2));
print(users.findWhere((u) => u.role == 'admin'));
users.delete(1);
print(users.count); // 2
final products = Repository<Product>();
products.save(Product(1, 'Apple', 0.99));
products.save(Product(2, 'Banana', 0.49));
print(products.findWhere((p) => p.price < 1.0));
}
One Repository<T> class serves both User and
Product. The type parameter enforces that only the correct entity
type is stored and retrieved. Adding a third entity type requires zero changes
to the repository implementation.
$ dart main.dart User(2, Bob, editor) [User(1, Alice, admin), User(3, Carol, admin)] 2 [Product(1, Apple, $0.99), Product(2, Banana, $0.49)]
Real-world: generic Result type
A Result<T> type represents either a successful value or an
error. It is a safer alternative to throwing exceptions for expected failure
cases. Dart 3 sealed classes make the pattern exhaustive.
sealed class Result<T> {
const Result();
}
final class Ok<T> extends Result<T> {
final T value;
const Ok(this.value);
@override
String toString() => 'Ok($value)';
}
final class Err<T> extends Result<T> {
final String message;
const Err(this.message);
@override
String toString() => 'Err($message)';
}
Result<int> parseInt(String input) {
final n = int.tryParse(input);
if (n == null) return Err('Cannot parse "$input" as int');
return Ok(n);
}
Result<double> safeDivide(double a, double b) {
if (b == 0) return Err('Division by zero');
return Ok(a / b);
}
void main() {
final results = [
parseInt('42'),
parseInt('abc'),
safeDivide(10, 2),
safeDivide(5, 0),
];
for (var r in results) {
switch (r) {
case Ok(:var value):
print('Success: $value');
case Err(:var message):
print('Error: $message');
}
}
}
The sealed modifier prevents unknown subclasses, which lets the
compiler verify that the switch statement is exhaustive. No
default branch is needed. The caller must handle both
Ok and Err or the code will not compile.
$ dart main.dart Success: 42 Error: Cannot parse "abc" as int Success: 5.0 Error: Division by zero
Real-world: generic cache
A generic cache maps keys of type K to values of type
V. It isolates the eviction policy from the types being cached.
class Cache<K, V> {
final Map<K, V> _store = {};
final int maxSize;
Cache(this.maxSize);
void put(K key, V value) {
if (_store.length >= maxSize) {
_store.remove(_store.keys.first);
}
_store[key] = value;
}
V? get(K key) => _store[key];
V getOrPut(K key, V Function() compute) {
if (!_store.containsKey(key)) put(key, compute());
return _store[key] as V;
}
bool has(K key) => _store.containsKey(key);
void evict(K key) => _store.remove(key);
int get size => _store.length;
}
void main() {
final cache = Cache<String, String>(3);
cache.put('a', 'alpha');
cache.put('b', 'beta');
cache.put('c', 'gamma');
print(cache.size); // 3
cache.put('d', 'delta'); // evicts 'a' (oldest entry)
print(cache.has('a')); // false
print(cache.has('d')); // true
print(cache.size); // 3
final val = cache.getOrPut('b', () => 'computed');
print(val); // beta (already cached)
final computed = cache.getOrPut('x', () => 'new_value');
print(computed); // new_value (computed and stored)
}
When the cache is full, the oldest entry (the first key in insertion order) is
evicted before the new entry is added. The getOrPut method avoids
redundant computation by returning the cached value when it exists.
$ dart main.dart 3 false true 3 beta new_value
Real-world: generic API response wrapper
A generic response wrapper decouples the HTTP status handling from the business logic that processes the response body.
class ApiResponse<T> {
final T? data;
final String? error;
final int statusCode;
const ApiResponse.success(this.data, [this.statusCode = 200])
: error = null;
const ApiResponse.failure(this.error, [this.statusCode = 500])
: data = null;
bool get isSuccess => statusCode >= 200 && statusCode < 300;
R fold<R>({
required R Function(T data) onSuccess,
required R Function(String error, int code) onFailure,
}) {
if (isSuccess && data != null) return onSuccess(data as T);
return onFailure(error ?? 'Unknown error', statusCode);
}
@override
String toString() => isSuccess
? 'ApiResponse.success($data)'
: 'ApiResponse.failure($error, $statusCode)';
}
class User {
final int id;
final String name;
User(this.id, this.name);
@override
String toString() => 'User($id, $name)';
}
ApiResponse<User> fetchUser(int id) {
if (id == 1) return ApiResponse.success(User(1, 'Alice'));
if (id == 2) return ApiResponse.success(User(2, 'Bob'));
return ApiResponse.failure('User not found', 404);
}
ApiResponse<List<User>> fetchAllUsers() {
return ApiResponse.success([User(1, 'Alice'), User(2, 'Bob')]);
}
void main() {
for (var id in [1, 2, 99]) {
final response = fetchUser(id);
final message = response.fold(
onSuccess: (u) => 'Got: $u',
onFailure: (e, code) => 'Failed ($code): $e',
);
print(message);
}
print('');
final all = fetchAllUsers();
all.fold(
onSuccess: (users) => users.forEach(print),
onFailure: (e, code) => print('Error: $e'),
);
}
The same ApiResponse<T> type works for a single
User, a List<User>, or any other payload type.
The fold method forces the caller to handle both the success and
failure branches.
$ dart main.dart Got: User(1, Alice) Got: User(2, Bob) Failed (404): User not found User(1, Alice) User(2, Bob)
Summary
Generics are one of the core tools for writing reusable, type-safe Dart code.
Key takeaways:
- Use
class Foo<T>when the entire class works with a single parameterized type throughout its lifetime. - Use
R method<T, R>()when only a specific method needs its own type parameter. - Use
extendsto bound type parameters and gain access to the bound type's methods. - Dart reifies generic types — they exist at runtime and support
ischecks andruntimeType. - Sealed classes with generic type parameters (like
Result<T>) are a clean alternative to exceptions for expected errors. - Generic typedefs improve readability when function types are used as parameters throughout a codebase.
Use generics when the same logic needs to work for multiple types without
duplicating code. Avoid generics when you only ever need one concrete type;
adding <T> where a plain type suffices adds noise without
benefit.
Author
List all Dart tutorials.