Exhaustividad y Pattern Matching con Sealed Classes

Las clases selladas (sealed [disponible desde Dart 3.0]) son la herramienta definitiva para definir jerarquías de tipos cerradas. Cuando marcas una clase como sealed, le estás diciendo al compilador: “nadie puede extender esta clase fuera de este archivo, y estas son las únicas implementaciones posibles”. Esto transforma la forma en que manejamos la lógica de decisión.

En lugar de depender de comprobaciones de tipo manuales con if (shape is Circle), utilizamos el pattern matching (coincidencia de patrones) para extraer datos y ejecutar lógica de forma declarativa. El compilador de Dart realiza un análisis estático de exhaustividad: si usas un switch sobre una clase sellada, el compilador exige que cubras todos sus subtipos. Si mañana añades un nuevo subtipo Triangle a tu jerarquía, todos los switch que antes compilaban fallarán en tiempo de compilación, obligándote a gestionar el nuevo caso. Esto elimina una clase entera de errores de lógica en producción.

¿Cuándo usar esto? Siempre que modeles estados (como en un patrón BLoC o Redux), resultados de operaciones (éxito/error) o jerarquías de datos donde el conjunto de variantes es conocido y finito. Si intentas omitir un caso en un switch expression, el compilador no te dejará generar el código; si intentas “engañar” al compilador usando un guard (when) para cubrir un caso, corres el riesgo de que la lógica no sea realmente exhaustiva.

import 'dart:math' as math;

// La jerarquía es cerrada. Solo Circle, Rectangle y Square pueden existir.
sealed class Shape {}

class Circle extends Shape {
  final double radius;
  Circle(this.radius);
}

class Rectangle extends Shape {
  final double width;
  final double height;
  Rectangle(this.width, this.height);
}

class Square extends Shape {
  final double side;
  Square(this.side);
}

double calculateArea(Shape shape) {
  // Usamos una switch expression (introducida en Dart 3.0).
  // Es exhaustiva porque cubrimos todos los subtipos de Shape.
  return switch (shape) {
    // Object Pattern: extraemos 'radius' directamente sin necesidad de casting.
    Circle(:var radius) => math.pi * math.pow(radius, 2) as double,

    // Guard (when): este caso solo se aplica si se cumple la condición.
    // OJO: El guard no contribuye a la exhaustividad del tipo.
    Rectangle(width: var w, height: var h) when w == h => w * h,

    // El caso genérico para Rectangle debe existir porque el guard anterior
    // podría fallar (si w != h), y el compilador necesita saber qué hacer.
    Rectangle(:var width, :var height) => width * height,

    // Pattern matching sobre un subtipo distinto.
    Square(:var side) => math.pow(side, 2) as double,
  };
}

void main() {
  final shapes = [
    Circle(5.0),
    Rectangle(10.0, 5.0),
    Square(4.0),
    Rectangle(5.0, 5.0), // Esto entrará en el caso con el 'when'
  ];

  for (final shape in shapes) {
    print('Área: ${calculateArea(shape)}');
  }
}

Desglose técnico

En el ejemplo anterior, fíjate en cómo el switch actúa como una expresión que devuelve un valor. El tipo de retorno de calculateArea es double, y el compilador garantiza que cada una de las ramas devuelva un double.

Cuando escribimos Circle(:var radius), estamos usando un object pattern. En lugar de hacer shape.radius, el patrón desestructura el objeto y asigna el valor de la propiedad radius a una nueva variable local en ese ámbito. Es mucho más limpio y menos propenso a errores de nullability.

Un punto crítico es el uso de la cláusula when en el Rectangle. Aunque Square es una forma de rectángulo, el compilador no puede asumir que un Rectangle siempre tendrá width == height. Por eso, aunque pongas un when para manejar rectángulos que son cuadrados, el compilador te obligará a incluir un caso Rectangle genérico para los demás. El guard es una condición de ejecución, no una definición de tipo, por lo que no ayuda a la exhaustividad del sistema de tipos.

El error frecuente

Un error clásico es intentar alcanzar la exhaustividad usando condiciones when, confiando en que cubren todos los casos de un subtipo. El compilador no es tan ingenuo.

sealed class Status {}
class Success extends Status { final String data; Success(this.data); }
class Failure extends Status { final String error; Failure(this.error); }

String getMessage(Status status) {
  return switch (status) {
    Success(:var data) => "Éxito: $data",
    // ERROR DE COMPILACIÓN: La switch expression no es exhaustiva.
    // El compilador no sabe si 'error' será siempre distinto de una string vacía.
    Failure(error: var e) when e.isNotEmpty => "Error: $e",
  };
}

Incluso si lógicamente sabes que en tu aplicación un Failure nunca tendrá un error vacío, para el motor de Dart el caso e.isEmpty es una posibilidad real. Si no cubres el caso base del tipo Failure, el código no compilará.

92

Dejar un comentario

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

Scroll al inicio