DEV Community

Ge Ji
Ge Ji

Posted on

Dart Lesson 12: Generics — Writing “generic” code

Today we'll learn about generics – a core technology that solves the problem of "reusing generic logic" while maintaining "type safety".

I. Why Do We Need Generics? – From "Duplicate Code" to "Generic Logic"

Suppose we need to implement a "box" class to store different types of data (like numbers or strings). Without generics, we might write code like this:

// Box for storing integers
class IntBox {
  int value;
  IntBox(this.value);
  int getValue() => value;
}

// Box for storing strings
class StringBox {
  String value;
  StringBox(this.value);
  String getValue() => value;
}

void main() {
  IntBox intBox = IntBox(10);
  StringBox stringBox = StringBox("Dart");
  print(intBox.getValue()); // Output: 10
  print(stringBox.getValue()); // Output: Dart
}
Enter fullscreen mode Exit fullscreen mode

The problem with this approach is obvious: the logic is identical, but we have to implement it repeatedly for each type. If we need to support more types (like bool or List), the code becomes unnecessarily bloated.

Even worse, if we use dynamic to create a "generic" box, we lose type safety:

// "Generic" box using dynamic (loses type safety)
class DynamicBox {
  dynamic value;
  DynamicBox(this.value);
  dynamic getValue() => value;
}

void main() {
  DynamicBox box = DynamicBox(10);
  // No compile-time error, but crashes at runtime (integers don't have length)
  print(box.getValue().length); // Runtime error: NoSuchMethodError
}
Enter fullscreen mode Exit fullscreen mode

Generics were created to solve both problems simultaneously: enabling code reuse while maintaining type safety.


II. Generics Basics: Generic Classes and Type Parameters

The core idea of generics is: when defining classes or functions, we don't specify concrete types, but use a "placeholder" to represent types, and specify the concrete type when using them. This "placeholder" is called a type parameter.

1. Generic Classes

Taking our "box" example, a generic class can be implemented once to support all types:

// Generic class: declare type parameter T with <T> (T is a placeholder, can be custom named)
class Box<T> {
  T value; // Use T as the type for value
  Box(this.value); // Constructor parameter type is T
  T getValue() => value; // Return type is T
}

void main() {
  // Specify concrete type when using: int
  Box<int> intBox = Box<int>(10);
  int num = intBox.getValue(); // Type-safe: return value is definitely int
  print(num); // Output: 10

  // Specify concrete type when using: String
  Box<String> stringBox = Box<String>("Dart");
  String str = stringBox
      .getValue(); // Type-safe: return value is definitely String
  print(str); // Output: Dart

  // Type error: compile-time error (can't store String in int box)
  // Box<int> errorBox = Box<int>("error"); // Compile error
}
Enter fullscreen mode Exit fullscreen mode

Advantages of generic classes:

  • Code reuse: One implementation supports all types
  • Type safety: Compile-time type checking prevents runtime errors
  • Type inference: Dart automatically infers type parameters from values, simplifying code:
Box<int> intBox = Box(10); // Omit <int> - compiler infers it
Box<String> stringBox = Box("Dart"); // Omit <String>
Enter fullscreen mode Exit fullscreen mode

2. Generic Functions

Not only classes can be generic – functions and methods can also implement generic logic through generics.

// Generic function: declare type parameter with <T>, use T for parameters and return type
T getFirstElement<T>(List<T> list) {
  if (list.isEmpty) {
    throw Exception("List is empty");
  }
  return list[0]; // Return first element with type T
}

void main() {
  List<int> numbers = [1, 2, 3];
  int firstNum = getFirstElement(numbers); // Automatically infers T as int
  print(firstNum); // Output: 1

  List<String> fruits = ["apple", "banana"];
  String firstFruit = getFirstElement(
    fruits,
  ); // Automatically infers T as String
  print(firstFruit); // Output: apple
}
Enter fullscreen mode Exit fullscreen mode

Generic functions allow us to write common algorithms (like sorting, filtering, or transforming) for different input types while maintaining type safety.


III. Generic Constraints: Restricting Type Parameter Range

By default, generic type parameters can be any type. But sometimes we need to restrict type parameters to specific types or their subclasses – these are called generic constraints.

Use the extends keyword to specify generic constraints:

1. Constraining to Specific Types

// Define a class with a name property
class HasName {
  String name;
  HasName(this.name);
}

// Generic class constraint: T must be HasName or its subclass
class NameBox<T extends HasName> {
  T value;
  NameBox(this.value);

  // Safely call T's name property (since T must be a HasName subclass)
  String getValueName() => value.name;
}

// Subclass also satisfies the constraint
class Person extends HasName {
  Person(String name) : super(name);
}

void main() {
  // Valid: T is HasName
  NameBox<HasName> box1 = NameBox<HasName>(HasName("Generic"));
  print(box1.getValueName()); // Output: Generic

  // Valid: T is Person (subclass of HasName)
  NameBox<Person> box2 = NameBox<Person>(Person("Alice"));
  print(box2.getValueName()); // Output: Alice

  // Error: T is String (doesn't satisfy HasName constraint)
  // NameBox<String> errorBox = NameBox<String>("error"); // Compile error
}
Enter fullscreen mode Exit fullscreen mode

With constraints, we can safely call methods and properties of the constrained type in generic classes/functions, avoiding errors from uncertain types.

2. Constraining to Interfaces (Using Dart's Built-in Comparable)

For scenarios requiring comparison, we can use Dart's built-in Comparable interface as a constraint, ensuring type parameters implement comparison methods:

// Import Dart core library (contains Comparable interface)
import 'dart:core';

// Generic function: constraint T must implement built-in Comparable interface
T findMax<T extends Comparable>(List<T> list) {
  T max = list[0];
  for (T item in list) {
    // Safely call compareTo method (since T is constrained to Comparable)
    if (item.compareTo(max) > 0) {
      max = item;
    }
  }
  return max;
}

// Custom class implementing Comparable interface
class Student implements Comparable<Student> {
  int score;
  Student(this.score);

  @override
  int compareTo(Student other) {
    return score - other.score;
  }
}

void main() {
  // Works with int (int implements Comparable interface)
  List<int> numbers = [3, 1, 4, 2];
  print(findMax(numbers)); // Output: 4

  // Works with custom Student class
  List<Student> students = [Student(80), Student(95), Student(70)];
  print(findMax(students).score); // Output: 95
}
Enter fullscreen mode Exit fullscreen mode

In this example, the findMax function uses the constraint T extends Comparable to ensure list elements can call the compareTo method, implementing a generic "find maximum value" logic. It works with both Dart's built-in types (like int and String) and custom types.


IV. Advanced Generics: Multiple Type Parameters

Generics support declaring multiple type parameters separated by commas, useful for scenarios requiring associate multiple types (like key-value pairs).

// Multiple type parameters: K for key type, V for value type
class Pair<K, V> {
  K key;
  V value;
  Pair(this.key, this.value);

  @override
  String toString() => "$key: $value";
}

void main() {
  // Key is String, value is int
  Pair<String, int> agePair = Pair("age", 20);
  print(agePair); // Output: age: 20

  // Key is int, value is String
  Pair<int, String> idPair = Pair(1001, "Alice");
  print(idPair); // Output: 1001: Alice
}
Enter fullscreen mode Exit fullscreen mode

Many built-in Dart classes (like Map) use multiple type parameters. For example, Map represents a mapping with string keys and integer values.


V. Practical Applications of Generics

Generics are common in real-world development, especially in these scenarios:

  1. Container classes: Collections like List, Set, and Map use generics to store and manipulate elements of any type.
  2. Network request utilities: Encapsulate generic network request functions by specifying the return data type:
Future<T> fetchData<T>(String url) async {
  // Generic network request logic
  // ...
  return parsedData as T;
}
// Usage: specify return type as User
fetchData<User>("/api/user").then((user) {
  print(user.name);
});
Enter fullscreen mode Exit fullscreen mode

3 . State management: In Flutter state management, use generics to define generic state containers:

class StateHolder<T> {
  T state;
  StateHolder(this.state);
  void update(T newState) {
    state = newState;
    // Notify listeners
  }
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)