Los genéricos son parámetros de tipo que funcionan como marcadores de posición para tipos que no conoces hasta que usas la clase o el método. En lugar de definir una clase que solo acepte String o int, utilizas un type parameter (convencionalmente T) para que la lógica sea reutilizable para cualquier tipo. Esto es fundamental para construir estructuras de datos, contenedores o wrappers de respuesta.
Esto funciona de esta manera porque el sistema de tipos de Dart es reified [disponible desde la creación de Dart]. A diferencia de lenguajes como Java, donde la información del tipo genérico se “borra” en runtime (type erasure), en Dart la información del tipo se mantiene durante la ejecución. La VM sabe exactamente si un objeto es un List<String> o un List<int>, lo que permite realizar comprobaciones de tipo precisas en tiempo de ejecución.
Debes usar genéricos siempre que la estructura de tu código sea independiente de los datos que procesa. Si estás creando una respuesta para una API, un contenedor de datos o una utilidad para transformar valores, los genéricos te permiten mantener la sound null safety sin repetir código para cada tipo.
Si decides evitar los genéricos y usar dynamic para ganar flexibilidad, lo que realmente estás haciendo es deshabilitar la seguridad del compilador. Esto provocará que los errores de tipo pasen de ser errores de compilación (fáciles de arreglar) a errores de ejecución (TypeError) que solo verás cuando el código ya esté corriendo en producción.
// Un wrapper para manejar respuestas de servicios o APIs
class ApiResponse<T> {
final T? data;
final String? error;
final bool isSuccess;
// El constructor usa T para permitir que la clase maneje cualquier modelo
ApiResponse({this.data, this.error, this.isSuccess = true});
}
// Una clase para representar un par de valores de distintos tipos
class Pair<K, V> {
final K key;
final V value;
Pair(this.key, this.value);
}
// Una función genérica que devuelve el mismo tipo que recibe
T identity<T>(T value) => value;
void main() {
// 1. Instanciación con tipos explícitos
final userResponse = ApiResponse<Map<String, dynamic>>(
data: {'id': 1, 'name': 'Alex'},
);
// 2. Inferencia de tipos: Dart deduce que es ApiResponse<int>
final ageResponse = ApiResponse(data: 30);
// 3. Uso de múltiples parámetros de tipo
final entry = Pair<int, String>(101, 'Elemento clave');
// 4. Demostración de Reificación (Runtime type checking)
// En Dart, esto es posible gracias a que el tipo es reified
print('¿Es una respuesta de String?: ${userResponse.data is Map<String, dynamic>.Type}'); // Falso, es un Map
print('¿Es la edad un int?: ${ageResponse.data is int}'); // true
// 5. Uso de métodos genéricos
final doubled = identity<double>(10.5);
print('Valor duplicado (identidad): $doubled');
// 6. Diferencias entre tipos de Listas
final listDynamic: List<dynamic> = ['Hola', 1, true];
final listObject: List<Object> = ['Hola', 1, true];
// listDynamic permite agregar cualquier cosa sin restricciones.
// listObject es más restrictivo en su estructura, pero permite tipos mixtos.
print('Listas listas: ${listDynamic.length}, ${listObject.length}');
}
En el código anterior, la clase ApiResponse<T> utiliza un único parámetro de tipo T. Cuando instanciamos userResponse, le indicamos que T es un Map<String, dynamic>, permitiendo que la propiedad data sea tratada con seguridad como ese tipo. Nota cómo en ageResponse, no necesitamos escribir <int> porque el compilador aplica type inference al observar el valor 30.
La clase Pair<K, V> demuestra el uso de múltiples parámetros de tipo: K para la clave y V para el valor. Esto nos permite crear una estructura que sea útil tanto para un Pair<int, String> como para un Pair<String, double> sin escribir dos clases distintas.
El método identity<T> es un ejemplo de un método genérico independiente de su clase. Al llamarlo como identity<double>(10.5), le estamos indicando explícitamente al compilador que el tipo T es double.
Un punto crítico es la comprobación ageResponse.data is int. Gracias a la reificación, la VM verifica el tipo real en la memoria durante la ejecución. Si los genéricos no fueran reificados, ageResponse habría perdido la información de que contenía un int, y la comprobación podría fallar o comportarse de forma impredecible.
Finalmente, en la sección de las listas, List<dynamic> es el tipo por defecto si no especificas nada (equivalente a List de forma “raw”). List<Object> es una distinción importante: mientras que dynamic le dice al compilador “no verifiques nada”, Object le dice “esto es un objeto, pero respeta las reglas de tipos”.
El error frecuente
Un error común es confundir List<Object> con List<dynamic> al trabajar con funciones que esperan tipos específicos.
void processStrings(List<String> list) {
print(list.length);
}
void main() {
// Esto causará un error de tipo en tiempo de ejecución (runtime TypeError)
// aunque el compilador podría dejarlo pasar si usas dynamic intermedio.
List<Object> mixedList = ['a', 'b'];
// Error: List<Object> no es un subtipo de List<String>
// Aunque 'mixedList' solo contenga strings, su contrato es de 'Object'.
// No puedes tratar una lista de objetos como una lista de strings.
// processStrings(mixedList);
}
Si tienes una List<dynamic> que por accidente contiene un int donde esperabas un String, el error no saltará cuando añadas el elemento, sino en el momento exacto en que intentes usar ese elemento como un String en otra parte del código, lo que hace que el debugging sea más difícil.
N° 53