ZetCode

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.

main.dart
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.

main.dart
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.

main.dart
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.

main.dart
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.

Note: covariance introduces a soundness gap. Adding an 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.

main.dart
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.

main.dart
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.

main.dart
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.

main.dart
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.

main.dart
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.

main.dart
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.

main.dart
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.

main.dart
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.

main.dart
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.

main.dart
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.

main.dart
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.

main.dart
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.

main.dart
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.

main.dart
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
Note: Dart generics have practical limits. There is no template specialization (you cannot write a different implementation for 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.

main.dart
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.

main.dart
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.

main.dart
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.

main.dart
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 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

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Dart tutorials.