Dart 3.6: records, pattern matching y sealed classes en la práctica

Dart 3.0, publicado en mayo de 2023, fue una de las actualizaciones más grandes del lenguaje en años: records, pattern matching y sealed classes de golpe. Desde entonces, Dart 3.5 y 3.6 han ido afinando estas características. Este artículo va directo a cómo funcionan en la práctica y cuándo merece la pena usarlas.

Records: tuplas con nombre

Los records son el mecanismo de Dart para agrupar valores sin necesidad de crear una clase. Hay dos sabores: posicional y con nombre.

// Record posicional
(String, int) getCoordinates() => ('Madrid', 40);

// Record con nombre
({String city, double lat, double lon}) getLocation() {
  return (city: 'Madrid', lat: 40.416, lon: -3.703);
}

void main() {
  final (city, pop) = getCoordinates();
  print(city); // Madrid

  final loc = getLocation();
  print(loc.city); // Madrid
  print(loc.lat);  // 40.416
}

La diferencia con las clases es que los records son value types: dos records con los mismos valores son iguales, sin necesidad de sobrescribir == ni hashCode. Esto los hace perfectos para devolver múltiples valores desde una función sin crear una clase auxiliar.

Una limitación que conviene tener clara: los records no tienen métodos propios ni pueden heredar. Son solo datos. Si necesitas lógica asociada, una clase sigue siendo la opción correcta.

Pattern matching: switch que de verdad trabaja

El pattern matching llegó con Dart 3.0 y transformó los switch en algo bastante más potente. Puedes hacer destructuring directamente en el switch, combinar condiciones con when y hasta usar if-case para comprobaciones puntuales.

// Switch expression (devuelve valor)
String describe(Object obj) => switch (obj) {
  int n when n < 0 => 'negativo',
  int n when n == 0 => 'cero',
  int _ => 'positivo',
  String s => 'texto: $s',
  _ => 'otro tipo',
};

// Destructuring de record en switch
void processPoint((double, double) point) {
  switch (point) {
    case (0, 0):
      print('Origen');
    case (var x, 0):
      print('En eje X: $x');
    case (0, var y):
      print('En eje Y: $y');
    case (var x, var y):
      print('Punto: ($x, $y)');
  }
}

// if-case para un solo patrón
void checkUser(Object user) {
  if (user case {'name': String name, 'age': int age}) {
    print('$name tiene $age años');
  }
}

El if-case es especialmente útil cuando trabajas con JSON decodificado o con respuestas de API donde el tipo exacto no está garantizado hasta tiempo de ejecución.

Sealed classes: exhaustividad garantizada

Las sealed classes son la pieza que hace que el pattern matching sea seguro. Al declarar una clase como sealed, el compilador sabe exactamente qué subclases existen y puede comprobar que tu switch cubre todos los casos posibles.

sealed class Shape {}
class Circle extends Shape {
  final double radius;
  Circle(this.radius);
}
class Rectangle extends Shape {
  final double width, height;
  Rectangle(this.width, this.height);
}
class Triangle extends Shape {
  final double base, height;
  Triangle(this.base, this.height);
}

double area(Shape shape) => switch (shape) {
  Circle(:var radius) => 3.14159 * radius * radius,
  Rectangle(:var width, :var height) => width * height,
  Triangle(:var base, :var height) => 0.5 * base * height,
};
// Si añades una cuarta subclase y olvidas el caso en el switch,
// el compilador te avisa con un error en tiempo de compilación.

Esto es especialmente valioso para modelar estados de UI. Si tienes un sealed class con Loading, Success y Error, el compilador te obliga a manejar los tres casos en el widget. Nada de olvidarse el estado de error y que aparezca en producción.

Combinando las tres características

El verdadero potencial aparece cuando usas las tres juntas. Un caso típico es modelar el resultado de una operación asíncrona:

sealed class Result<T> {}
class Success<T> extends Result<T> {
  final T data;
  Success(this.data);
}
class Failure<T> extends Result<T> {
  final String message;
  final int? code;
  Failure(this.message, {this.code});
}

Future<Result<String>> fetchUsername(int id) async {
  // simulación
  if (id > 0) return Success('usuario_$id');
  return Failure('ID inválido', code: 400);
}

void main() async {
  final result = await fetchUsername(42);
  switch (result) {
    case Success(:var data):
      print('Usuario: $data');
    case Failure(:var message, :var code):
      print('Error $code: $message');
  }
}

Este patrón es muy parecido a lo que hace Rust con Result<T, E> o Kotlin con sealed class. Si vienes de Kotlin, te resultará familiar, aunque la sintaxis difiere.

Dart 3.6: digit separators y otras mejoras

Dart 3.6 (diciembre de 2024) no añadió nuevas grandes características de lenguaje, pero trajo digit separators para mejorar la legibilidad de constantes numéricas:

// Antes
const int maxRequestSize = 10485760;
// Después, en Dart 3.6
const int maxRequestSize = 10_485_760; // 10 MB

También mejoras en el análisis de tipo nulo en expresiones complejas y optimizaciones en el compilador AOT para builds de release. Para un proyecto Flutter que compile para Android ARM64, la diferencia en tiempo de compilación es apreciable en proyectos grandes.

Cuándo usar cada cosa

Los records van bien para funciones que devuelven múltiples valores relacionados sin necesitar un tipo nombrado permanente. Los sealed classes con pattern matching son la opción correcta cuando modelas un conjunto cerrado de estados o variantes que el compilador debe verificar. El if-case es útil para extraer datos de tipos dinámicos de forma segura.

Si ya usas Flutter con gestión de estado, estas características encajan bien con los patrones de Riverpod y Bloc. Puedes ver más sobre eso en el artículo sobre estado en Flutter en 2026.

Para quien venga de TypeScript, el sistema de tipos de Dart 3.x con sealed classes y pattern matching se parece bastante a los discriminated unions. Las diferencias están más en la sintaxis que en el concepto.

Imagen: Pexels / Andrey Matveev

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP