FlutterOOPDartGenericApr 29, 20264 min read288

Don’t Fear the <T>: Demystifying Generics in Dart and Flutter

If you’ve spent any time writing Flutter apps, you’ve almost certainly used Generics. Every time you declare a List<String> or a Future<int>, you are utilizing the power of Generic Types.

But consuming generics is one thing; creating your own generic classes and functions is where you truly level up your Dart architecture. Whether you are managing real-time data streams for a messaging platform, structuring local storage for a journaling app, or building robust enterprise tools, generics are the key to keeping your codebase clean, type-safe, and DRY (Don’t Repeat Yourself).

Let’s break down how to create, constrain, and master custom generics in your Flutter projects.

---

Table of Contents

  • What Are Generics?
  • The Problem: Duplicated Code
  • The Solution: Generic Classes
  • Generic Functions
  • Restricting Generics with 'extends'
  • Conclusion
  • 1_B-DK6g9wpUVW7o94_wLCQw.webp

    ---

    What Are Generics?

    Generics allow you to write code that works with any data type while still maintaining strict type safety. In Dart, generic types are conventionally represented by a single uppercase letter:

  • <T> for Type

  • <E> for Element

  • <K, V> for Key and Value
  • Instead of writing a class that only works with a String or an int, you write a class that works with <T>. The actual type is "plugged in" later when you instantiate the class.

    ---

    The Problem: Duplicated Code

    Imagine you are fetching data from an API. You need to handle success and error states for various models. Without generics, you might write repetitive wrappers like this:

    class UserResponse {
      final User? data;
      final String? error;
      UserResponse(this.data, this.error);
    }
    
    class MessageResponse {
      final Message? data;
      final String? error;
      MessageResponse(this.data, this.error);
    }
    

    This gets tedious quickly. Every time you add a new model, you have to create a new response wrapper.

    ---

    The Solution: Generic Classes

    Let’s refactor that using a Generic Type. We can create a single ApiResponse class that adapts to whatever model we throw at it.

    class ApiResponse<T> {
      final T? data;
      final String? error;
    
      ApiResponse({this.data, this.error});
    
      bool get isSuccess => data != null;
    }
    

    Now, your code becomes incredibly reusable:

    // Usage
    final userResult = ApiResponse<User>(data: currentUser);
    final messagesResult = ApiResponse<List<Message>>(data: chatHistory);
    
    print(userResult.isSuccess); // true
    

    ---

    Generic Functions

    You aren’t limited to classes; you can apply generics directly to functions. This is useful for utility functions that perform operations on collections.

    Here is a simple function that takes a list of any type and returns the last item safely:

    T? safeGetLast<T>(List<T> items) {
      if (items.isEmpty) return null;
      return items.last;
    }
    
    void main() {
      final numbers = [1, 2, 3];
      final strings = ['A', 'B', 'C'];
      
      // Dart's type inference is smart enough to figure out the type
      print(safeGetLast(numbers)); // Outputs: 3
      print(safeGetLast(strings)); // Outputs: C
    }
    

    ---

    Restricting Generics with extends

    Sometimes, total freedom isn’t what you want. You might need your generic type <T> to have certain properties. You can constrain a generic type using the extends keyword.

    Let’s say you’re building a generic repository. You want to ensure that any object passed to this repository has an id.

    First, define a base interface:

    abstract class BaseModel {
      String get id;
    }
    
    class User implements BaseModel {
      @override
      final String id;
      final String name;
      User(this.id, this.name);
    }
    

    Next, constrain your generic repository:

    class LocalRepository<T extends BaseModel> {
      final Map<String, T> _storage = {};
    
      void save(T item) {
        // Because T extends BaseModel, Dart knows 'item' has an 'id'
        _storage[item.id] = item;
        print('Saved item with ID: ${item.id}');
      }
    
      T? getById(String id) => _storage[id];
    }
    

    If you try to pass a type that doesn’t implement BaseModel (like a standard String), the Dart compiler will throw an error, preventing runtime crashes.

    ---

    Key Takeaways

  • Type Safety: Catches errors at compile-time rather than runtime (e.g., List<int> vs List).
  • Reusability: One class/function works for many different types (e.g., ApiResponse<T>).
  • Constraints: Restrict generics to specific class hierarchies using <T extends BaseModel>.
  • Architecture Tip: Use generics for your data layers (Repositories, Services) and state management wrappers to keep your business logic clean and scalable.

    Conclusion

    Generics might look intimidating at first, but they are a fundamental tool for writing scalable Flutter applications. By abstracting the "Type" away from your logic, you can build powerful, reusable services and UI components.

    Next time you find yourself copying and pasting a class just to change a variable type, ask yourself: Could this be a Generic?

    ---

    Happy coding! 🚀

    Enjoyed this read?

    8likes
    Comments

    Loading…