Enumerações em Dart com Interfaces e Polimorfismo
- Dart 03 – Generics no Dart
- Dart 02 – Exceções, Classes e Mais
- Dart 01 – Introdução
- Enumerações em Dart com Interfaces e Polimorfismo
- Mais uma Aplicação Elegante do Enum
Índice
- 1. Introdução
- 1.1. Por que enriquecer Enumerações?
- 1.2. Enumerações Aprimoradas no Dart 3
- 2. Adicionando um label a Enumeração
- 3. Polimorfismo com Enumerações via interface
- 4. Interface Position
- 4.1. Implementando Position nos Enumerações
- 5. Validando consistência em Sportsman
- 6. Método factory fromLabel
- 7. Conclusão
Introdução
Enumerações no Dart são uma ferramenta poderosa para restringir o escopo de nomes a conjuntos específicos de valores. Contudo, na sua forma “pura”, eles não oferecem suporte nativo a atributos personalizados ou comportamentos encapsulados, o que nos leva a recorrer a estruturas auxiliares — como map ou tabelas de lookup — sempre que precisamos associar metadados ou lógica a cada caso. Neste artigo, mostro como enriquecer enumerações com campos e métodos, aproveitando interfaces e polimorfismo para obter um design mais robusto, escalável, reduzir o boilerplate e centralizar a lógica de forma mais coesa.
Por que enriquecer Enumerações?
Em projetos de médio e grande porte, manter maps externos para associar rótulos, descrições ou comportamentos a cada valor de um enumeração gera duas fontes de verdade. Sempre que adicionamos ou renomeamos um valor, é preciso atualizar tanto o código quanto o map, tornando o processo suscetível a erros e difícil de manter. Ao centralizar esses metadados diretamente na declaração da enumeração, ganhamos clareza, simplificando a evolução e manutenção da aplicação.
Enumerações Aprimoradas no Dart 3
O Dart 3 introduz enumerações aprimoradas, capazes de incluir atributos, métodos e construtores personalizados (Enumerated Types). A sintaxe assemelha-se à das classes, mas impõe algumas regras:
- Todos os campos de instância devem ser
final, inclusive os adicionados via mixins. - Todos os construtores gerativos devem ser marcados como
const. - Construtores de fábrica só podem retornar uma das instâncias fixas da enumeração.
- Enumerações não podem ser estendidos por outras classes (eles já herdam internamente de
Enum). - Não é permitido sobrescrever
index,hashCodeou o operador==. - Não se deve declarar um membro estático
values, pois isso conflita com o getter automáticovalues. - Todas as instâncias precisam aparecer no início da declaração e deve haver ao menos uma.
Não explorarei todos os detalhes das enumerações aprimorados aqui, mas aplicarei alguns desses pontos nos exemplos seguintes.
Adicionando um label a Enumeração
Em geral, desenvolvemos nossos aplicativos em inglês — seja pelo simples exercício do idioma, uma questão de padronização do mercado ou para simplificar a colaboração com desenvolvedores de outras localidades — mas, na interface, é preciso exibir textos legíveis e contextualizados, diferentes dos rótulos camelCase normalmente usados nos itens de uma enumeração.
Imagine que você esteja desenvolvendo um aplicativo de esportes para o mercado nacional, em que precisa exibir em um dropdown opções como “Futebol”, “Vôlei” e “Basquete”, mas manter seu código padronizado em inglês. A enumeração “pura” ficaria assim:
enum Sports {
soccer,
volleyball,
basketball,
}Para apresentar rótulos amigáveis, seria comum criar um Map<Sports, String> ou uma tabela de lookup separada — o que introduz duas fontes de verdade e complica a manutenção.
Em vez disso, podemos enriquecer a enumeração definindo um atributo final String label e um construtor const, de modo a incorporar os metadados diretamente na declaração:
enum Sports implements LabeledEnum {
soccer('Futebol'),
volleyball('Vôlei'),
basketball('Basquete');
final String label;
const Sports(this.label);
}Agora, a enumeração Sports ganha um atributo label com o valor apropriado para exibição em um dropdown:
DropdownButton<Sports>(
value: selectedSport,
items: Sports.values.map((sport) {
return DropdownMenuItem(
value: sport,
child: Text(sport.label),
);
}).toList(),
onChanged: (sport) => setState(() => selectedSport = sport!),
);Se você quiser incluir também uma descrição mais detalhada para cada item, basta adicionar um segundo campo à enumeração:
enum Sports {
soccer('Futebol', 'O "Rei dos Esportes", jogado com 11 em cada time.'),
volleyball('Vôlei', 'Modalidade de quadra com 6 jogadores por lado.'),
basketball('Basquete', 'Jogo de bola com tabela e cestas.');
final String label;
final String description;
const Sports(this.label, this.description);
/// Método de conveniência para obter uma lista de labels.
static List<String> get labels =>
values.map((e) => e.label).toList();
}Adicionalmente, você pode disponibilizar um método de conveniência para retornar a lista de labels da enumeração, caso julgue útil.
Polimorfismo com Enumerações via interface
Suponha que, neste aplicativo, um atleta possa praticar vários esportes e assumir funções distintas em cada modalidade.
Uma abordagem inicial seria definir enumerações separadas para representar as posições de cada esporte:
enum SoccerPosition {
goalkeeper,
leftBack,
// ...
}
enum BasketballPosition {
pointGuard,
shootingGuard,
// ...
}
enum VolleyballPosition {
setter,
outsideHitter,
// ...
}Embora ainda usemos os labels em seções posteriores, o objetivo aqui será demonstrar como aplicar polimorfismo por meio de uma interface comum.
A questão que surge é: como agrupar estas diferentes enumerações em um único atributo dentro de uma classe, de modo a armazenar essas informações de forma consistente?
A proposta é consolidar tudo isso em atributos polimórficos na classe Sportsman:
class Sportsman {
final Sports sport;
final Position position;
Sportsman({
required this.sport,
required this.position,
});
}A classe Sportsman passa a ter:
sport: um valor deSports, já definido anteriormente;position: umPosition, que deve corresponder a uma das posições específicas de cada modalidade.
Embora não exista um “tipo base” nativo para enumerações, no Dart toda enumeração pode implementar uma interface genérica (como Position), viabilizando o polimorfismo entre diferentes enumerações de posição.
Interface Position
Para unificar essas enumerações, definimos uma interface específica:
abstract class Position {
String get label;
String get strName;
}Como o Dart já fornece automaticamente o getter name em cada enumeração, incluímos no contrato Position o getter strName para expô-lo de forma clara.
Implementando Position nos Enumerações
Em seguida, fazemos com que as enumerações SoccerPosition, BasketballPosition e VolleyballPosition implementem a interface Position:
enum SoccerPosition implements Position {
goalkeeper('Goleiro'),
leftBack('Lateral Esquerdo'),
centerBack('Zagueiro-Central'),
rightBack('Lateral Direito'),
defensiveMidfielder('Volante'),
centralMidfielder('Meia Central'),
attackingMidfielder('Meia Ofensivo'),
leftWing('Ponta Esquerda'),
rightWing('Ponta Direita'),
striker('Atacante');
final String _label;
@override
String get label => _label;
@override
String get strName => name;
const SoccerPosition(this._label);
}Essa primeira implementação serve apenas para esclarecer alguns pontos; em seguida, iremos simplificar esta declaração.
Observe que, no contrato Position, declaramos dois getters: um para o label e outro para o strName. Na enumeração, criamos o atributo privado _label, que é passado ao construtor SoccerPosition(this._label). Já o strName é apenas um getter que expõe o name nativo da enumeração. Isso se faz necessário porque, ao usar o contrato Position para habilitar o polimorfismo, o getter name não está diretamente acessível pelo contrato.
Mas isso é apenas um detalhe: o ponto principal é que não precisamos de um atributo privado e de um getter separado. Em geral, faríamos assim:
enum SoccerPosition implements Position {
goalkeeper('Goleiro'),
leftBack('Lateral Esquerdo'),
centerBack('Zagueiro-Central'),
rightBack('Lateral Direito'),
defensiveMidfielder('Volante'),
centralMidfielder('Meia Central'),
attackingMidfielder('Meia Ofensivo'),
leftWing('Ponta Esquerda'),
rightWing('Ponta Direita'),
striker('Atacante');
@override
final String label;
@override
String get strName => name;
const SoccerPosition(this.label);
}Observe que declaramos apenas um final String label para sobrescrever o String get label definido em Position. Essa capacidade de associar diretamente um campo a um getter herdado é uma característica exclusiva das enumerações no Dart e não está disponível em classes comuns.
Em resumo, as definições das enumerações que implementam Position para cada modalidade esportiva ficam assim:
enum SoccerPosition implements Position {
goalkeeper('Goleiro'),
leftBack('Lateral Esquerdo'),
centerBack('Zagueiro-Central'),
rightBack('Lateral Direito'),
defensiveMidfielder('Volante'),
centralMidfielder('Meia Central'),
attackingMidfielder('Meia Ofensivo'),
leftWing('Ponta Esquerda'),
rightWing('Ponta Direita'),
striker('Atacante');
@override
final String label;
@override
String get strName => name;
const SoccerPosition(this.label);
}
enum BasketballPosition implements Position {
pointGuard('Armador'),
shootingGuard('Escolta'),
smallForward('Ala'),
powerForward('Ala-Pivô'),
center('Pivô');
@override
final String label;
@override
String get strName => name;
const BasketballPosition(this.label);
}
enum VolleyballPosition implements Position {
setter('Levantador'),
outsideHitter('Ponteiro'),
opposite('Oposto'),
middleBlocker('Central'),
libero('Líbero');
@override
final String label;
@override
String get strName => name;
const VolleyballPosition(this.label);
}Imagino que ainda possa extrair o @override final String label; e o String get strName => name; para um mixin comum, sem perder o polimorfismo com o Position, mas deixarei esta implementação para um outro momento.
Validando consistência em Sportsman
Para reforçar a integridade dos dados ao criar um Sportsman, inserimos uma verificação no construtor que assegura que a Position corresponda ao sport informado:
class Sportsman {
final Sports sport;
final Position position;
static final _checkers = <Sports, bool Function(Position)>{
Sports.soccer: (p) => p is SoccerPosition,
Sports.basketball: (p) => p is BasketballPosition,
Sports.volleyball: (p) => p is VolleyballPosition,
};
Sportsman({
required this.sport,
required this.position,
}) {
if (!_checkers[sport]!(position)) {
throw ArgumentError.value(
position,
'position',
'A posição "${position.label}" não corresponde ao esporte ${sport.name}.',
);
}
}
}Essa validação previne inconsistências lógicas entre o esporte e a posição passados ao construtor, tornando a implementação mais robusta.
Método factory fromLabel
Essa factory não seria estritamente necessária depois de adicionarmos strName ao contrato Position. Contudo, para explorar mais o polimorfismo entre as implementações de Position, utilizarei o label na serialização e desserialização de Sportsman ao persistir no banco de dados.
Para isso, implementamos os métodos toMap() e fromMap() tradicionais. Em vez de usar o índice (index) da enumeração, é boa prática gravar o valor da enumeração pelo getter name, pois isso o torna mais legível e resistente a mudanças na ordem da enumeração:
Map<String, dynamic> toMap() {
return {
'athlete_id': athleteId,
'club_id': clubId,
'sport': sport.name,
// ...
factory Sportsman.fromMap(Map<String, dynamic> map) {
final sport = Sports.values.byName(map['sport'] as String);
return Sportsman(
athleteId: map['athlete_id'] as String,
clubId: map['club_id'] as String,
sport: sport
// ...Na desserialização, recuperamos a enumeração armazenado no banco de dados com o método byName, garantindo que o texto recuperado corresponda exatamente ao name declarado no código.
Na interface Position, embora o getter name esteja acessível indiretamente via strName, vamos empregar o atributo label na serialização para persistência:
Map<String, dynamic> toMap() {
return {
'athlete_id': athleteId,
'club_id': clubId,
'sport': sport.name,
'position': position.label,
};
}Uma questão natural para a desserialização de Sportsman é: como recriar o Position a partir do label gravado no banco de dados?
Para isso, aplicamos o mesmo princípio da extensão byName em enum.values, adicionando um construtor factory à interface Position. Ele recebe o label e o Sports, retornando a instância da enumeração adequada:
abstract class Position {
String get label;
String get strName;
factory Position.fromLabel(String label, Sports sport) {
switch (sport) {
case Sports.soccer:
return SoccerPosition.values.firstWhere((p) => p.label == label);
case Sports.basketball:
return BasketballPosition.values.firstWhere((p) => p.label == label);
case Sports.volleyball:
return VolleyballPosition.values.firstWhere((p) => p.label == label);
}
}
}Dessa forma, o factory encapsula todo o lookup por label, garantindo que a Position retornada corresponda ao Sports informado. Isso resolve parte do problema, mas deixa dois pontos em aberto:
- duplicação de código
values.firstWhere(...; - possível não correspondência entre o
labele as instâncias da enumeração.
O código a seguir refatora a factory e centraliza a busca por label num método genérico, eliminando duplicações e adicionando tratamento de erro:
abstract class Position {
String get label;
factory Position.byLabel(String label, Sports sport) {
switch (sport) {
case Sports.soccer:
return _findByLabel(SoccerPosition.values, label, sport);
case Sports.basketball:
return _findByLabel(BasketballPosition.values, label, sport);
case Sports.volleyball:
return _findByLabel(VolleyballPosition.values, label, sport);
}
}
static T _findByLabel<T extends Position>(
List<T> values,
String label,
Sports sport,
) {
return values.firstWhere(
(p) => p.label == label,
orElse: () {
throw ArgumentError.value(
label,
'label',
'Invalid label $label for sport ${sport.name}',
);
},
);
}
}- Busca única:
_findByLabelrecebe a lista de instâncias, olabele osport, realizando a pesquisa num único ponto. - Tratamento de erro: o
orElsedefirstWherelançaArgumentError.valuecom mensagem específica quando não há correspondência. - Tipagem genérica:
<T extends Position>assegura que o método retorne exatamente o tipo da enumeração informada (por ex.SoccerPosition). - Custo de pesquisa:
firstWherefaz uma varredura linear (O(n)), perfeitamente adequado para enumerações pequenas; em casos de enumerações maiores ou buscas frequentes, vale a pena construir umMap<String, T>imutável para converter o lookup em tempo constante (O(1)).
Com essa abordagem, a desserialização em Sportsman.fromMap fica simples e robusta, sem repetições e com validação consistente.
A seguir, a versão final da classe Sportsman:
class Sportsman {
final Sports sport;
final Position position;
Sportsman({
required this.sport,
required this.position,
}) {
final isMismatch =
(sport == Sports.soccer && position is! SoccerPosition) ||
(sport == Sports.basketball && position is! BasketballPosition) ||
(sport == Sports.volleyball && position is! VolleyballPosition);
if (isMismatch) {
throw ArgumentError.value(
position,
'position',
'The position "${position.label}" does not match the sport ${sport.name}.',
);
}
}
Map<String, dynamic> toMap() {
return {
'sport': sport.name,
'position': position.label,
};
}
factory Sportsman.fromMap(Map<String, dynamic> map) {
final sport = Sports.values.byName(map['sport'] as String);
final position = Position.fromLabel(map['position'] as String, sport);
return Sportsman(
sport: sport,
position: position,
);
}
}Agora, com a enumeração enriquecida, a posição do atleta em cada modalidade é representada polimorficamente via o atributo position, garantindo clareza e segurança na serialização e desserialização.
Conclusão
Enriquecer enumerações em Dart com atributos personalizados e interfaces expande as possibilidades de manter seu código organizado, autodescritivo e resistente a inconsistências. Ao centralizar label, description e métodos de lookup nas próprias enumerações, você reduz a necessidade de maps externos, minimiza duplicações de lógica e evita erros de sincronização entre o código e o banco de dados.
A adoção de uma interface comum (como feito aqui com o Position) permite aplicar polimorfismo de modo conciso, agrupando distintas enumerações sob um único tipo e garantindo a segurança tanto em tempo de compilação quanto em tempo de execução. Validações explícitas no construtor, juntamente com as factories como fromLabel, asseguram que valores salvos e recuperados obedeçam sempre às regras de negócio.
Quanto ao custo de performance, o impacto de enriquecer enumerações com campos e métodos é mínimo — ciclos extras na busca de valores são praticamente imperceptíveis em cenários típicos de interface de usuário ou persistência de dados. Dessa forma, você ganha clareza e facilita a manutenção sem comprometer a responsividade do aplicativo.
Por fim, esse padrão mantém o foco na clareza e na facilidade de manutenção: quaisquer alterações futuras — seja na inclusão de novas modalidades, posições ou campos — exigirão mudanças apenas na enumeração correspondente, sem impactar outras camadas do sistema. Dessa forma suas enumerações deixam de ser meros rótulos e se tornam componentes ricos em comportamento, prontos para escalar junto com o seu projeto.
Deixe uma resposta