Dart 3 Records y Patterns en Flutter: ejercicio resuelto

Dart 3 Records y Patterns en Flutter: ejercicio resuelto

Dart 3 incorporó tres grandes novedades de lenguaje: records (structs anónimos con tipado estático), patterns (desestructuración y pattern matching) y sealed classes (jerarquías cerradas que habilitan exhaustividad). Juntas transforman cómo se modela el estado y la lógica de negocio en Flutter.

Enunciado

Implementa una mini-app que demuestre las tres características:

  1. Records: función que devuelve un record con nombre, precio y stock.
  2. Pattern matching con switch expression: función area() que acepta distintas formas.
  3. Sealed classes: LoadState<T> para modelar estado async (Loading, Success, Failure).
  4. List & map patterns: destructuring de listas y mapas.
  5. Muestra todos los ejemplos en una pantalla con ListView de Card.

Dependencias

Solo Dart 3 — ya incluido en Flutter 3.10+. Sin dependencias extra.

1
2
3
dependencies:
  flutter:
    sdk: flutter

Solución completa

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
import 'package:flutter/material.dart';

// ══════════════════════════════════════════════════════════════════════════════
// 1. RECORDS — struct anónimo con tipado estático
// ══════════════════════════════════════════════════════════════════════════════

// Record con campos posicionales
typedef Point = (double x, double y);

// Record con campos nombrados
typedef Product = ({String name, double price, int stock});

Product fetchProduct() =>
    (name: 'Curso Flutter', price: 29.99, stock: 100);

// Función que devuelve múltiples valores sin crear una clase
(String city, int population) getCapital(String country) {
  return switch (country) {
    'España'   => ('Madrid', 3_300_000),
    'Francia'  => ('París', 2_100_000),
    'Alemania' => ('Berlín', 3_700_000),
    _          => ('Desconocida', 0),
  };
}

// ══════════════════════════════════════════════════════════════════════════════
// 2. SEALED CLASSES — jerarquía cerrada con exhaustividad garantizada
// ══════════════════════════════════════════════════════════════════════════════

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); }

// switch expression es exhaustivo con sealed — el compilador lo verifica
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,
};

// Estado async genérico con sealed class
sealed class LoadState<T> {}
class Loading<T> extends LoadState<T> {}
class Success<T> extends LoadState<T> { final T data; Success(this.data); }
class Failure<T> extends LoadState<T> { final String message; Failure(this.message); }

// ══════════════════════════════════════════════════════════════════════════════
// 3. PATTERNS — destructuring de listas y mapas
// ══════════════════════════════════════════════════════════════════════════════

String describeList(List<int> numbers) => switch (numbers) {
  []                   => 'Lista vacía',
  [var single]         => 'Un elemento: $single',
  [var first, var second] => 'Dos elementos: $first y $second',
  [var first, ..., var last] => 'Varios: primero $first, último $last',
};

String readConfig(Map<String, dynamic> config) {
  // Pattern matching sobre mapa
  return switch (config) {
    {'mode': 'debug',   'version': var v} => 'DEBUG v$v',
    {'mode': 'release', 'version': var v} => 'RELEASE v$v',
    _ => 'Configuración desconocida',
  };
}

// ══════════════════════════════════════════════════════════════════════════════
// App Flutter
// ══════════════════════════════════════════════════════════════════════════════

Future<void> main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Dart 3 Features',
      theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.deepPurple),
      home: const Dart3Page(),
    );
  }
}

class Dart3Page extends StatelessWidget {
  const Dart3Page({super.key});

  @override
  Widget build(BuildContext context) {
    // ── 1. Records ────────────────────────────────────────────────────────────
    final product = fetchProduct();
    final (city, population) = getCapital('España'); // destructuring posicional

    // ── 2. Formas ─────────────────────────────────────────────────────────────
    final shapes = <Shape>[
      Circle(5),
      Rectangle(4, 6),
      Triangle(3, 8),
    ];

    // ── 3. LoadState ──────────────────────────────────────────────────────────
    final LoadState<String> state = Success('Datos cargados correctamente');

    // ── 4. Listas y mapas ─────────────────────────────────────────────────────
    final listExamples = [
      describeList([]),
      describeList([42]),
      describeList([1, 2]),
      describeList([10, 20, 30, 40, 50]),
    ];

    final mapExample = readConfig({'mode': 'release', 'version': '2.0.0'});

    return Scaffold(
      appBar: AppBar(title: const Text('Dart 3: Records & Patterns')),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          // Records con nombre
          _DemoCard(
            title: '📦 Record con campos nombrados',
            content: 'Producto: ${product.name}\n'
                'Precio: \$${product.price}\n'
                'Stock: ${product.stock}',
          ),
          // Record posicional + destructuring
          _DemoCard(
            title: '🗺 Record posicional + destructuring',
            content: 'Capital: $city\nPoblación: $population',
          ),
          // Sealed + switch expression
          _DemoCard(
            title: '🔷 Sealed class: formas y áreas',
            content: shapes.map((s) {
              final name = switch (s) {
                Circle()    => 'Círculo',
                Rectangle() => 'Rectángulo',
                Triangle()  => 'Triángulo',
              };
              return '$name → área ${area(s).toStringAsFixed(2)}';
            }).join('\n'),
          ),
          // LoadState
          _DemoCard(
            title: '🔄 Sealed LoadState<T>',
            content: switch (state) {
              Loading()         => 'Cargando...',
              Success(:var data) => '✅ $data',
              Failure(:var message) => '❌ $message',
            },
          ),
          // List patterns
          _DemoCard(
            title: '📋 List patterns',
            content: listExamples.join('\n'),
          ),
          // Map patterns
          _DemoCard(
            title: '🗂 Map patterns',
            content: mapExample,
          ),
        ],
      ),
    );
  }
}

class _DemoCard extends StatelessWidget {
  final String title;
  final String content;
  const _DemoCard({required this.title, required this.content});

  @override
  Widget build(BuildContext context) {
    return Card(
      margin: const EdgeInsets.only(bottom: 12),
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(title,
                style: const TextStyle(
                    fontWeight: FontWeight.bold, fontSize: 15)),
            const SizedBox(height: 8),
            Text(content, style: const TextStyle(fontFamily: 'monospace')),
          ],
        ),
      ),
    );
  }
}

Conceptos clave

ConceptoDetalle
(A, B)Record posicional; acceso con $1, $2 o destructuring (var a, var b) = rec
({String name, int age})Record con campos nombrados; acceso con .name, .age
sealed classClase cerrada: solo subclases en el mismo archivo. Permite exhaustividad en switch
switch expressionExpresión (no statement) que devuelve un valor; el compilador exige cubrir todos los casos con sealed
:var fieldObject pattern nombrado: extrae field del objeto sin repetir el nombre
[first, ..., last]List rest pattern: captura primer y último elemento, ignora el medio
{'key': var v}Map pattern: extrae el valor de la clave 'key'

Errores frecuentes

  • switch no exhaustivo: con sealed classes, el compilador emite un warning si falta algún subtipo. Añade un caso _ solo si realmente hay un caso default válido.
  • Records vs clases: los records son inmutables y no tienen métodos propios. Para lógica de negocio, una clase sigue siendo mejor. Los records brillan en retornos múltiples y datos temporales.
  • $1 vs .fieldName: los records posicionales usan $1, $2…; los records con nombre usan .fieldName. Mezclarlos da error de compilación.
  • Flutter < 3.10: Dart 3 requiere Flutter 3.10+. Verifica con flutter --version.

Aplicación práctica

  • Retorno múltiple: (String, int) parseVersion(String v) evita crear clases wrapper.
  • Estado async: LoadState<T> sealed es un patrón muy limpio que reemplaza múltiples booleans (isLoading, hasError).
  • Pattern matching en Navigator: switch (route) con sealed classes para routing type-safe.
  • Riverpod + sealed: combinado con @riverpod, AsyncValue usa el mismo patrón internamente.

Siguiente ejercicio recomendado

Práctica guiada y siguiente paso

FAQ

¿Los records reemplazan a las clases en Dart 3?

No. Los records son para datos temporales, retornos múltiples y composición simple. Las clases siguen siendo mejores cuando necesitas métodos, herencia, igualdad personalizada (aunque los records tienen igualdad estructural automática) o serialización JSON compleja.

¿Qué diferencia hay entre switch statement y switch expression?

El switch expression (con =>) devuelve un valor y es más conciso. El switch statement (con { case: break; }) ejecuta código. Con Dart 3, el expression es preferible para mapear valores; el statement para ejecutar side effects.

¿Los sealed classes son como enums?

Son complementarios. Los enums son para conjuntos de valores constantes simples. Las sealed classes son para jerarquías de tipos donde cada subtipo puede tener sus propios campos. LoadState<T> con Success(data) y Failure(message) no sería posible con un enum.