Los modificadores de clase en Dart 3 son herramientas de diseño para definir con precisión matemática cómo se puede interactuar con una clase desde fuera de su librería (paquete). En lugar de confiar solo en la visibilidad de los miembros (private/public), estos modificadores permiten controlar la jerarquía de tipos y el contrato de implementación.
Cuando diseñas una librería, necesitas decidir si quieres que un usuario herede tu lógica mediante extends (lo que crea una relación de “es un” y puede romper tu estado interno si el usuario sobreescribe métodos) o si solo quieres que cumpla con tu estructura mediante implements (una relación de “actúa como”).
Si intentas extender una clase marcada como final desde otro paquete, el compilador te detendrá inmediatamente con un error de análisis. Si utilizas un sealed class, el compilador se vuelve capaz de realizar un análisis de exhaustividad (exhaustiveness checking), asegurando que todos los casos posibles en un switch o un pattern matching hayan sido cubiertos. Esto es vital cuando modelas estados finitos donde no quieres que terceros añadan nuevos subtipos que tu lógica no sepa manejar.
¿Cuándo deberías usarlos? Úsalos cuando estés construyendo APIs o frameworks donde la integridad del flujo de ejecución dependa de una jerarquía de tipos cerrada o controlada. Si fallas en elegir el modificador correcto, corres el riesgo de que un usuario final rompa la lógica interna de tu librería simplemente extendiendo una clase que no fue diseñada para ser heredada, o que tu código deje de ser seguro ante la adición de nuevos tipos si no utilizas la restricción de exhaustividad de sealed.
// payment_system.dart (Simulación de una librería/paquete)
/// [disponible desde Dart 3.0]
/// Representa el estado de una operación de pago.
/// Al ser `sealed`, el compilador garantiza que cualquier `switch`
/// que trabaje con `TransactionResult` sea exhaustivo si se
/// trata dentro de este mismo archivo.
sealed class TransactionResult {}
class Success extends TransactionResult {
final String transactionId;
Success(this.transactionId);
}
class Failure extends TransactionResult {
final String error;
Failure(this.error);
}
/// Define el contrato de un procesador.
/// Al ser `interface class`, los usuarios pueden usar `implements`
/// para crear su propio procesador, pero no pueden usar `extends`
/// para heredar la implementación interna, protegiendo la lógica privada.
interface class PaymentProcessor {
void process(double amount);
}
/// Permite la herencia mediante `extends`, permitiendo reutilizar su lógica,
/// pero prohíbe la implementación mediante `implements` desde fuera.
/// Esto garantiza que la jerarquía de tipos sea controlada.
base class TransactionLogger {
void log(String msg) => print('[LOG]: $msg');
}
/// Puede usarse tanto como un `mixin` como una clase base.
mixin class AuditMixin {
void audit() => print('Ejecutando auditoría de seguridad...');
}
/// No puede ser extendida ni implementada desde fuera del paquete.
/// Es ideal para objetos de valor (Value Objects) inmutables.
final class TransactionId {
final String value;
TransactionId(this.value);
}
// --- Implementación del cliente (en otro paquete/archivo) ---
class StripeProcessor implements PaymentProcessor with AuditMixin {
@override
void process(double amount) {
audit();
print('Procesando $amount en la pasarela Stripe...');
}
void handleResult(TransactionResult result) {
// El compilador exige cubrir Success y Failure porque TransactionResult es `sealed`.
// Si intentamos omitir uno, el análisis estático fallará.
final message = switch (result) {
Success(transactionId: var id) => 'Transacción exitosa: $id',
Failure(error: var e) => 'Error en la operación: $e',
};
print(message);
}
}
// El usuario puede extender una clase `base` para reutilizar código.
class DebugLogger extends TransactionLogger {
@override
void log(String msg) => print('[DEBUG]: $msg');
}
void main() {
final processor = StripeProcessor();
processor.process(150.50);
// Simulamos respuestas
processor.handleResult(Success('TXN-88291'));
processor.handleResult(Failure('Saldo insuficiente'));
final logger = DebugLogger();
logger.log('Iniciando test de sistema');
}
Análisis detallado
En el ejemplo anterior, TransactionResult es una sealed class. Esto es fundamental para la seguridad del tipo: dentro del archivo donde se define, el compilador sabe que solo existen Success y Failure. Por eso, en handleResult, el switch no necesita una cláusula default. Si intentaras añadir un nuevo subtipo Pending dentro de la misma librería, el compilador te obligaría a actualizar todos los switch existentes que usen TransactionResult.
La clase StripeProcessor implementa PaymentProcessor (que es una interface class). Esto significa que StripeProcessor está obligada a proveer su propia implementación de process, pero no puede heredar accidentalmente la lógica de la interfaz. Sin embargo, StripeProcessor utiliza AuditMixin mediante with, permitiéndole inyectar comportamiento de mezcla. Gracias a que AuditMixin es una mixin class, es versátil.
Observa la diferencia con TransactionLogger. Al ser una base class, DebugLogger puede usar extends para heredar el método log. Si TransactionLogger hubiera sido marcada como final, DebugLogger simplemente no podría existir.
Por último, TransactionId es una final class. Esto es una decisión de diseño para asegurar que el identificador de una transacción sea una unidad atómica e inmutable, sin posibilidad de que un desarrollador cree una versión “especializada” (como un MockTransactionId) que altere el comportamiento esperado del sistema.
El error frecuente
Un error común al trabajar con interface class es intentar utilizar la herencia para reutilizar código.
// ERROR: PaymentProcessor es una interface class.
// No puedes extenderla, solo implementarla.
class MyCustomProcessor extends PaymentProcessor {
@override
void process(double amount) {
print('Intentando extender...');
}
}
Este error ocurre porque el autor de la librería decidió que PaymentProcessor solo debe definir un contrato y no proporcionar una implementación base. Si necesitas heredar lógica, la clase debe haber sido definida como base class o una mixin class. Si intentas usar extends en una interface class, el compilador de Dart te detendrá antes de que tu error de arquitectura llegue a producción.
N° 47