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
Í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
,hashCode
ou 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
label
e 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:
_findByLabel
recebe a lista de instâncias, olabel
e osport
, realizando a pesquisa num único ponto. - Tratamento de erro: o
orElse
defirstWhere
lançaArgumentError.value
com 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:
firstWhere
faz 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