Records vs. Clases: ¿Estructura o Identidad?

La diferencia fundamental entre un record [disponible desde Dart 3.0] y una clase radica en cómo el compilador reconoce sus tipos: el primero utiliza tipado estructural y el segundo tipado nominal. En un record, la identidad del tipo depende exclusivamente de su forma (sus campos y sus tipos); si dos records tienen la misma estructura, son el mismo tipo para el compilador. En cambio, en una clase, el nombre es el que manda: aunque dos clases tengan exactamente los mismos campos y métodos, se consideran tipos totalmente distintos.

Esta distinción determina su propósito. Los records son tipos anónimos, inmutables y con implementaciones de == y hashCode basadas en sus valores por defecto. Al no tener nombre, no puedes añadirles métodos ni lógica de comportamiento; son puramente contenedores de datos. Por su parte, las clases están diseñadas para modelar entidades de dominio que poseen identidad propia, comportamiento (métodos) y jerarquías de herencia.

Usa records cuando necesites devolver múltiples valores de una función de forma rápida, cuando manejes datos temporales dentro de un método para agrupar información sin crear archivos nuevos, o cuando la estructura sea tan obvia que el nombre del tipo no aporte semántica. Usa clases cuando los datos representen el núcleo de tu aplicación, cuando necesites encapsular lógica, o cuando necesites que el tipo sea único y no se confunda con otros similares. Si usas records para representar entidades críticas de tu modelo de negocio, corres el riesgo de que el compilador acepte valores de un tipo distinto que casualmente compartan la misma estructura, provocando errores lógicos difíciles de rastrear.

class Product {
  final String id;
  final String name;
  final double price;

  Product(this.id, this.name, this.price);

  void applyDiscount(double percentage) {
    // En una clase, el comportamiento vive aquí.
    print('Aplicando $percentage% de descuento a $name');
  }

  @override
  String toString() => 'Product(id: $id, name: $name, price: $${price})';
}

/// Devuelve un record con el estado de la validación y un mensaje.
/// Usamos campos nombrados para mayor claridad en el retorno.
({bool isValid, String message}) validateStock(Product product, int requestedQuantity) {
  if (requestedQuantity <= 0) {
    return (isValid: false, message: 'La cantidad debe ser mayor a cero');
  }
  if (product.price <= 0) {
    return (isValid: false, message: 'El producto tiene un precio inválido');
  }
  return (isValid: true, message: 'Stock disponible para ${product.name}');
}

void main() {
  final laptop = Product('p-100', 'MacBook Pro', 2499.99);

  // 1. Uso de records para retorno múltiple y desestructuración [disponible desde Dart 3.0]
  final (isOk, msg) = validateStock(laptop, 1);

  if (isOk) {
    print('Resultado: $msg');
  }

  // 2. Pattern matching con records de campos nombrados
  final checkResult = validateStock(laptop, -5);

  switch (checkResult) {
    case (:var isValid, :var message) when !isValid:
      print('Error de validación: $message');
    case (:var isValid):
      if (isValid) print('Todo correcto');
  }

  // 3. Records con campos posicionales para datos muy simples y locales
  final dimensions = (1920, 1080); // (width, height)
  print('Resolución: ${dimensions.$1}x${dimensions.$2}');
}

Desglose del ejemplo

En el código anterior, Product es una clase. Tiene un nombre único, un constructor y un método applyDiscount. Si intentaras usar un record para representar un producto, no podrías definir ese método dentro de él. Además, Product tiene identidad: aunque dos productos tengan el mismo nombre y precio, son instancias distintas.

La función validateStock utiliza un record con campos nombrados. Esto es preferible cuando el retorno tiene más de dos elementos o cuando queremos evitar confusiones sobre qué es cada valor (en lugar de confiar en el orden). En el main, vemos la desestructuración al asignar (isOk, msg). Aquí, el compilador extrae los valores del record directamente a variables locales.

Cuando aplicamos pattern matching en el switch, estamos aprovechando que los records son tipos estructurales. La sintaxis (:var isValid, :var message) busca coincidencias en los nombres de los campos del record, permitiéndonos extraer valores de forma declarativa. Finalmente, para dimensions, usamos un record posicional. Es la forma más rápida de agrupar valores ad hoc cuando el contexto (como una resolución de pantalla) hace que el uso de $1 o $2 sea totalmente evidente.

El error frecuente

Un error común es usar records para representar conceptos de dominio que parecen similares pero no lo son. Imagina que tienes dos entidades distintas: un User(id, name) y un Product(id, name).

// ERROR: Confusión por tipado estructural
void processOrder((String id, String name) entity) {
  print('Procesando ${entity.name}');
}

void main() {
  // Esto funciona, pero semánticamente es peligroso
  final user = (id: 'u1', name: 'Alice');
  final product = (id: 'p1', name: 'Laptop');

  processOrder(user);    // OK
  processOrder(product); // ¡También OK! Pero un producto no es un usuario.
}

Si processOrder espera un usuario, el compilador no te avisará si le pasas un producto por error porque ambos son records con la misma estructura (String, String). En estos casos, siempre debes usar una class para asegurar que el tipado nominal proteja la lógica de tu negocio.

90

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio