| |

Dart 31 🎯 Generics: Generic Classes, Methods, and Type Constraints ()

Generics are Dart’s mechanism for writing code that works with a type without knowing what the type is. A List is a list of something, and the something is a type parameter. A Map is a map from one type to another. The framework’s collections are generic, and the pattern extends to any class, method, or function. Generics preserve type information that would otherwise be lost, and they let the compiler catch type errors that would otherwise be runtime failures.

This chapter covers generics in full. You will learn the syntax for generic classes, generic methods, and generic functions, the type parameter bounds that constrain what types can be used, the difference between generics and dynamic, and the patterns that make generic code readable and safe.

Key point: A generic class or function declares a type parameter with <T>, and the parameter is used as a type within the declaration. The type parameter is a placeholder; the caller supplies the actual type. The bound <T extends SomeType> constrains the placeholder to types that are subtypes of SomeType. Without the bound, the parameter is Object? and only the members available on Object? can be used.


Why generics exist

The type-loss problem. A list without a type parameter is a list of dynamic. The elements are whatever the developer puts in, and the compiler cannot check what comes out. List (without a type argument) accepts anything, and reading from it produces dynamic, which is a source of runtime errors. List<String> accepts only strings, and reading from it produces String, which the compiler can verify.

The duplicate-code problem. A Box class that holds a value could be written for int, for String, for User, and for every other type. Each version is a separate class, and the logic is the same in every one. A generic Box<T> is one class that works for every type, and the type is preserved.

The type-safety problem. The compiler checks the types that flow through a generic. Box<String> cannot be assigned to Box<int>. The type parameter is part of the type, and the mismatch is a compile error. Without generics, the mismatch is a runtime error.

The API problem. A method that takes a list of some type and returns the first element of that type is generic. The signature declares the relationship: the element type of the input is the same as the type of the output. Without the type parameter, the relationship is lost, and the caller has to cast.

The dynamic problem. dynamic defers the type check to runtime. A List<dynamic> accepts anything, and reading from it produces dynamic, which the compiler does not check. A List<T> is checked at compile time. The choice between dynamic and a type parameter is the choice between runtime flexibility and compile-time safety.

The framework problem. The Flutter framework uses generics extensively. Future<T>, Stream<T>, ValueNotifier<T>, Provider<T>, and the widget types are all generic. The type parameter carries the data type through the framework, and the compiler verifies the flow. Understanding generics is understanding the framework’s types.


a. Generic classes

A generic class declares one or more type parameters after the class name.

class Box<T> {
  final T value;

  Box(this.value);

  T get() => value;

  @override
  String toString() => 'Box<$T>($value)';
}

The Box<T> is a class with one type parameter, T. The field value has the type T, and the getter returns T. The type parameter is used as a type within the class.

The caller supplies the type argument.

var intBox = Box<int>(42);
var strBox = Box<String>('hello');

print(intBox.get());   // 42
print(strBox.get());   // hello

The Box<int> and Box<String> are distinct types. The compiler checks the assignments. intBox.get() returns int, and strBox.get() returns String.

The type argument can be inferred from the constructor.

var box = Box(42);  // Box<int>, inferred
var box2 = Box('hello');  // Box<String>, inferred

The inference is from the argument. The Box(42) is Box<int> because the argument is int. The explicit type argument is optional when the inference is clear.

A generic class can have multiple type parameters.

class Pair<K, V> {
  final K key;
  final V value;

  Pair(this.key, this.value);

  @override
  String toString() => 'Pair($key, $value)';
}

var pair = Pair<String, int>('age', 30);
print(pair.key);   // 'age'
print(pair.value); // 30

The Pair<K, V> has two type parameters. The key is K, and the value is V. The caller supplies both.

A generic class can extend another generic class.

class Container<T> extends Box<T> {
  Container(super.value);
}

The Container<T> extends Box<T> and passes the type parameter through. The Container<int> is a Box<int>, and the compiler verifies the subtype relationship.

A generic class can implement an interface with a type argument.

class Repository<T> {
  final List<T> _items = [];

  void add(T item) => _items.add(item);
  List<T> getAll() => List.unmodifiable(_items);
}

class UserRepository implements Repository<User> {
  final List<User> _items = [];

  @override
  void add(User item) => _items.add(item);

  @override
  List<User> getAll() => List.unmodifiable(_items);
}

The UserRepository implements Repository<User>. The T is replaced by User, and the methods have the User type.


b. Generic methods and functions

A generic method declares its own type parameter, independent of the class’s type parameter.

class Utilities {
  T first<T>(List<T> items) {
    if (items.isEmpty) throw ArgumentError('Empty list');
    return items.first;
  }
}

The first<T> method has a type parameter T that is not the class’s type parameter. The method works for any list, and the return type is the element type of the list.

var utils = Utilities();
print(utils.first([1, 2, 3]));       // 1
print(utils.first(['a', 'b']));      // 'a'

The type argument is inferred from the argument. first([1, 2, 3]) is first<int>, and the return is int.

A generic function is a top-level function with a type parameter.

T first<T>(List<T> items) {
  if (items.isEmpty) throw ArgumentError('Empty list');
  return items.first;
}

print(first([1, 2, 3]));       // 1
print(first(['a', 'b']));      // 'a'

The top-level first<T> is the same as the method. The type parameter is declared on the function.

A generic method can have multiple type parameters.

Map<K, V> zip<K, V>(List<K> keys, List<V> values) {
  final result = <K, V>{};
  for (var i = 0; i < keys.length && i < values.length; i++) {
    result[keys[i]] = values[i];
  }
  return result;
}

var zipped = zip(['a', 'b'], [1, 2]);
print(zipped);  // {a: 1, b: 2}

The zip<K, V> takes two lists and returns a map. The type parameters are K and V, and the return type is Map<K, V>.

A generic method can be called with explicit type arguments.

var firstInt = first<int>([1, 2, 3]);

The explicit type argument is optional when the inference works. It is required when the inference cannot determine the type.

var empty = <int>[];
var firstEmpty = first(empty);  // inference from the list type

The type argument is inferred from the argument’s type. The <int>[] declares the list as List<int>, and the first returns int.

A generic method can be static.

class Lists {
  static T first<T>(List<T> items) => items.first;
}

print(Lists.first([1, 2, 3]));  // 1

The static generic method is called on the class, not on an instance.


c. Type constraints with extends

A type constraint restricts the types that can be used for a type parameter. The syntax is <T extends SomeType>.

class NumberBox<T extends num> {
  final T value;

  NumberBox(this.value);

  double get doubled => value * 2;
}

The NumberBox<T extends num> restricts T to num or its subtypes (int, double). The value * 2 works because num has the * operator. Without the constraint, T would be Object?, and * would not be available.

var intBox = NumberBox(42);       // NumberBox<int>
var doubleBox = NumberBox(3.14);  // NumberBox<double>
// var strBox = NumberBox('a');   // error: String is not num

The constraint is enforced at compile time. The NumberBox<String> is an error because String is not a subtype of num.

The constraint can be a class, an interface, or a mixin.

abstract class Identifiable {
  int get id;
}

class EntityBox<T extends Identifiable> {
  final List<T> items = [];

  void add(T item) => items.add(item);

  T? findById(int id) {
    for (var item in items) {
      if (item.id == id) return item;
    }
    return null;
  }
}

The T extends Identifiable ensures that every item has an id getter. The findById method uses the getter, and the constraint makes it available.

class User implements Identifiable {
  @override
  final int id;
  final String name;

  User(this.id, this.name);
}

var box = EntityBox<User>();
box.add(User(1, 'Alice'));
print(box.findById(1)?.name);  // 'Alice'

The User implements Identifiable, so it satisfies the constraint. The EntityBox<User> is valid.

The constraint can be another type parameter.

class Storage<K, V extends K> {
  final Map<K, V> _map = {};

  void put(K key, V value) => _map[key] = value;
  V? get(K key) => _map[key];
}

The V extends K constrains V to be a subtype of K. The Storage<num, int> is valid because int is a subtype of num. The Storage<int, num> is not, because num is not a subtype of int.

The bound can be a function type.

class Transformer<T, R> {
  final R Function(T) _transform;

  Transformer(this._transform);

  R apply(T input) => _transform(input);
}

var length = Transformer<String, int>((s) => s.length);
print(length.apply('hello'));  // 5

The Transformer<T, R> takes a function from T to R. The type parameters are related by the function type.

The bound can be Object to exclude null.

class NonNullBox<T extends Object> {
  final T value;
  NonNullBox(this.value);
}

var box = NonNullBox(42);
// var nullBox = NonNullBox(null);  // error: null is not Object

The T extends Object excludes null. In Dart’s null safety, Object is the non-nullable top type, and Object? is the nullable top type. The T extends Object means the type parameter cannot be null.

The dynamic type and the Object? type are the default bound. Without an explicit bound, the type parameter is Object?, and the members available are those of Object?, which includes ==, hashCode, toString, runtimeType, and noSuchMethod.

class Box<T> {
  final T value;
  Box(this.value);

  // value.toString() works
  // value.length does not
}

The toString is available because it is a member of Object?. The length is not because it is specific to String.


Complete Example Session

// ============================================
// PART 1: BASIC GENERIC CLASS
// ============================================
class Box<T> {
  final T value;
  Box(this.value);
  T get() => value;
}

var intBox = Box<int>(42);
var strBox = Box<String>('hello');
// ============================================
// PART 2: INFERRED TYPE ARGUMENT
// ============================================
var box = Box(42);       // Box<int>
var box2 = Box('hello'); // Box<String>
// ============================================
// PART 3: MULTIPLE TYPE PARAMETERS
// ============================================
class Pair<K, V> {
  final K key;
  final V value;
  Pair(this.key, this.value);
}

var pair = Pair<String, int>('age', 30);
// ============================================
// PART 4: GENERIC METHOD
// ============================================
T first<T>(List<T> items) {
  if (items.isEmpty) throw ArgumentError('Empty');
  return items.first;
}

print(first([1, 2, 3]));  // 1
print(first(['a', 'b'])); // 'a'
// ============================================
// PART 5: GENERIC METHOD IN CLASS
// ============================================
class Utilities {
  T first<T>(List<T> items) => items.first;
}

var utils = Utilities();
print(utils.first([1, 2, 3]));
// ============================================
// PART 6: TYPE CONSTRAINT
// ============================================
class NumberBox<T extends num> {
  final T value;
  NumberBox(this.value);
  double get doubled => value * 2;
}

var nb = NumberBox(42);
print(nb.doubled);  // 84.0
// ============================================
// PART 7: INTERFACE CONSTRAINT
// ============================================
abstract class Identifiable {
  int get id;
}

class EntityBox<T extends Identifiable> {
  final List<T> items = [];
  void add(T item) => items.add(item);
  T? findById(int id) => items.where((i) => i.id == id).firstOrNull;
}
// ============================================
// PART 8: TYPE PARAMETER CONSTRAINT
// ============================================
class Storage<K, V extends K> {
  final Map<K, V> _map = {};
  void put(K key, V value) => _map[key] = value;
}

var storage = Storage<num, int>();
storage.put(1, 42);
// ============================================
// PART 9: OBJECT CONSTRAINT
// ============================================
class NonNullBox<T extends Object> {
  final T value;
  NonNullBox(this.value);
}

var box = NonNullBox(42);
// ============================================
// PART 10: FUNCTION TYPE PARAMETER
// ============================================
class Transformer<T, R> {
  final R Function(T) _transform;
  Transformer(this._transform);
  R apply(T input) => _transform(input);
}

var length = Transformer<String, int>((s) => s.length);
print(length.apply('hello'));  // 5

The ten parts covered the basic generic class, the inferred type argument, multiple type parameters, the generic function, the generic method in a class, the type constraint, the interface constraint, the type parameter constraint, the Object constraint, and the function type parameter.


Quick Reference

Generic Syntax

FormExample
Generic classclass Box<T> { }
Multiple parametersclass Pair<K, V> { }
Generic methodT first<T>(List<T> items)
Generic functionT first<T>(List<T> items)
Constraintclass Box<T extends num> { }
Multiple constraintsclass S<K, V extends K> { }
Explicit typeBox<int>(42)
Inferred typeBox(42)

Type Parameter Bounds

BoundMeaning
(none)Object?
T extends ObjectNon-nullable
T extends numint or double
T extends SomeClassSubtype of SomeClass
V extends KSubtype of another parameter
T extends Comparable<T>Comparable to itself

Generics vs dynamic

AspectGenericdynamic
Type checkCompile timeRuntime
Type preservedYesNo
Member accessDepends on boundAny
SafetyHighLow
Use caseType-safe containersFlexible APIs

Common Bounds

BoundRestricts To
ObjectNon-nullable
numint, double
Comparable<T>Types with compareTo
Iterable<T>Iterables
FunctionFunctions

Best Practices

✅ Do This:

// Use a type parameter for a container
class Box<T> { final T value; Box(this.value); }               // ✅

// Infer the type argument when possible
var box = Box(42);                                             // ✅

// Constrain the parameter when the code needs members
class NumberBox<T extends num> { }                             // ✅

// Use a descriptive type parameter name
class Repository<T> { }                                        // ✅

// Use the constraint to enforce the interface
class EntityBox<T extends Identifiable> { }                    // ✅

// Use `T extends Object` to exclude null
class NonNullBox<T extends Object> { }                         // ✅

// Use generic methods for type-safe utilities
T first<T>(List<T> items) => items.first;                      // ✅

❌ Don’t Do This:

// Don't use dynamic when a generic fits
List<dynamic> items = [];                                      // ⚠️

// Don't use a generic without a constraint when members are needed
class Box<T> { double get doubled => value * 2; }  // error       // ❌

// Don't use a single-letter name for a complex parameter
class X<A, B, C, D> { }  // unclear                             // ⚠️

// Don't forget the type argument when the inference fails
var empty = <T>[];  // error                                     // ❌

// Don't constrain to a type that is too specific
class Box<T extends int> { }  // only int                       // ⚠️

// Don't cast when the generic would preserve the type
var value = (box as Box<String>).value;                         // ⚠️

Common Pitfalls

PitfallWhy It HappensFix
Member not availableNo constraintAdd T extends SomeType
Type argument requiredInference failedProvide <int>
Null not allowedT extends ObjectUse T extends Object?
Type mismatchWrong type argumentCheck the type
Generic method no inferenceNo argument to infer fromUse explicit <T>
Constraint too strictBound is a concrete typeUse the interface
dynamic leaksNo type parameterAdd the parameter

Real-World Examples

1. Box

class Box<T> {
  final T value;
  Box(this.value);
}

2. Pair

class Pair<K, V> {
  final K key;
  final V value;
  Pair(this.key, this.value);
}

3. Result

class Result<T> {
  final T? data;
  final Object? error;
  Result.success(this.data) : error = null;
  Result.failure(this.error) : data = null;
}

4. Repository

class Repository<T> {
  final List<T> _items = [];
  void add(T item) => _items.add(item);
  List<T> getAll() => _items;
}

5. Number Constraint

class NumberBox<T extends num> {
  final T value;
  NumberBox(this.value);
  double get doubled => value * 2;
}

6. Comparable Constraint

T max<T extends Comparable<T>>(T a, T b) => a.compareTo(b) >= 0 ? a : b;

print(max(3, 5));       // 5
print(max('a', 'b'));   // 'b'

7. Interface Constraint

class EntityBox<T extends Identifiable> {
  final List<T> items = [];
  T? findById(int id) => items.where((i) => i.id == id).firstOrNull;
}

8. Non-Null Constraint

class NonNullBox<T extends Object> {
  final T value;
  NonNullBox(this.value);
}

9. Function Type

class Transformer<T, R> {
  final R Function(T) _transform;
  Transformer(this._transform);
  R apply(T input) => _transform(input);
}

10. Static Generic Method

class Lists {
  static T first<T>(List<T> items) => items.first;
}

Visual

Generic Class

┌─────────────────────────────────────────────────────────────┐
│  class Box<T> {                                             │
│    final T value;                                           │
│    Box(this.value);                                         │
│    T get() => value;                                        │
│  }                                                          │
│                                                             │
│  Box<int>(42)       → T = int                               │
│  Box<String>('hi')  → T = String                            │
│                                                             │
│  The type parameter is a placeholder.                       │
│  The caller supplies the actual type.                       │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Type Constraint

┌─────────────────────────────────────────────────────────────┐
│  class NumberBox<T extends num> {                           │
│    final T value;                                           │
│    double get doubled => value * 2;                         │
│  }                                                          │
│                                                             │
│  T extends num                                              │
│    ├── int     ✓                                            │
│    ├── double  ✓                                            │
│    └── String  ✗                                            │
│                                                             │
│  The constraint makes the * operator available.             │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Bound Comparison

┌─────────────────────────────────────────────────────────────┐
│  NO BOUND: T is Object?                                     │
│    Members: ==, hashCode, toString, runtimeType             │
│                                                             │
│  T extends Object: non-nullable                             │
│    Members: same as Object?, minus null                     │
│                                                             │
│  T extends num: int or double                               │
│    Members: +, -, *, /, abs, round, etc.                    │
│                                                             │
│  T extends Identifiable: has id                             │
│    Members: id, plus Object? members                        │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Generics vs Dynamic

┌─────────────────────────────────────────────────────────────┐
│  GENERIC                                                    │
│                                                             │
│  List<String> names = ['Alice', 'Bob'];                     │
│  String first = names.first;                                │
│  // names.add(42) → compile error                           │
│                                                             │
│  Type checked at compile time.                              │
│                                                             │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  DYNAMIC                                                    │
│                                                             │
│  List<dynamic> items = ['Alice', 42];                       │
│  var first = items.first;  // dynamic                       │
│  first.nonExistentMethod();  // runtime error               │
│                                                             │
│  Type checked at runtime.                                   │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Summary

ItemValue
Generic classclass Box<T>
Multiple parametersclass Pair<K, V>
Generic methodT first<T>(List<T> items)
Generic functionT first<T>(List<T> items)
ConstraintT extends SomeType
Non-null boundT extends Object
Default boundObject?
Explicit typeBox<int>(42)
Inferred typeBox(42)
Multiple constraintsK, V extends K
Function boundR Function(T)

Key takeaways:

  • A generic class or function declares a type parameter with <T>. The parameter is a placeholder for a type. The caller supplies the actual type, and the compiler checks the flow.
  • The type parameter is used as a type within the declaration. The T in Box<T> is the type of the field, the parameter, and the return value. The compiler treats the parameter as the caller’s type.
  • The type argument is inferred when possible. Box(42) is Box<int> because the argument is int. The explicit <int> is optional when the inference is clear.
  • A type constraint restricts the types that can be used. <T extends num> restricts T to num and its subtypes. The constraint makes the members of the bound available inside the declaration.
  • The default bound is Object?. Without a constraint, the type parameter can be any type, including null. The members available are those of Object?: ==, hashCode, toString, runtimeType, and noSuchMethod.
  • T extends Object excludes null. In Dart’s null safety, Object is the non-nullable top type. The constraint is the way to require a non-nullable type.
  • A type parameter can be constrained by another type parameter. <K, V extends K> requires V to be a subtype of K. The constraint expresses the relationship between the two parameters.

Remember: Generics are the mechanism for writing code that works with a type without knowing what the type is. The type parameter is the placeholder, the bound is the constraint, and the compiler is the checker. Use generics for containers, for utilities, and for any code where the type flows through. Use the constraint when the code needs the members of the bound. Use the explicit type argument when the inference fails. And use generics instead of dynamic whenever the type is known, because the compile-time check is the difference between a caught error and a runtime crash.



Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!