Este artigo é a parte 1 de 4 na série Dart

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ático values.
  • 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 de Sports, já definido anteriormente;
  • position: um Position, 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, o label e o sport, realizando a pesquisa num único ponto.
  • Tratamento de erro: o orElse de firstWhere lança ArgumentError.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 um Map<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.