Start Debugging

Dart records vs classes Freezed: qual escolher em 2026?

Escolha os records do Dart 3.12 para dados efêmeros com forma local e sem métodos, e as classes Freezed 3.x para modelos de domínio nomeados que precisam de copyWith, uniões seladas, serialização JSON ou qualquer comportamento.

No Dart 3.12 (a versão que chegou com Flutter 3.44 no Google I/O 2026), records e Freezed resolvem ambos “preciso de um tipo de valor imutável com igualdade estrutural”, mas o fazem a partir de direções opostas. Records são um tipo estrutural, anônimo e nativo, sem geração de código. Freezed 3.x é um tipo nominal gerado por código com copyWith, uniões seladas, serialização JSON e o restante do conjunto de ferramentas para classes de dados. A resposta curta: use um record quando os dados forem uma forma local e efêmera que não precise de nome nem de método, e use Freezed para qualquer classe que faça parte do seu modelo de domínio, da sua árvore de estado ou do formato de transporte da sua API.

Este artigo cobre os records do Dart 3.12 (estáveis desde Dart 3.0 em maio de 2023, com a sintaxe curta para campos nomeados adicionada na 3.7 e os campos nomeados privados adicionados na 3.12) e Freezed 3.x sobre build_runner 2.4 (Freezed 3.0 foi lançado em março de 2025 com uma saída gerada menor e um padrão @Freezed(toJson: false) para uniões). Ambos miram a mesma base de Flutter 3.44 e Dart 3.12. A escolha não é “records são novos, Freezed é velho”, porque ambos são mantidos ativamente e ambos têm lugar em um aplicativo Flutter de 2026. A escolha depende de para que o tipo serve.

O que cada um é de fato

Um record do Dart é um tipo agregado nativo, imutável e anônimo. O tipo (int, String) é um record com dois campos posicionais. O tipo ({int id, String name}) é um record com dois campos nomeados. Records são estruturais: quaisquer dois records com a mesma forma de campos são o mesmo tipo, mesmo que tenham sido declarados em arquivos diferentes. O compilador gera ==, hashCode e toString automaticamente. Não dá para adicionar métodos. Não dá para anexar comportamento. Não dá para dar a um record um nome em nível de classe (dá para fazer typedef User = ({int id, String name});, mas o typedef é apenas um alias para o tipo estrutural, não um novo tipo nominal).

Uma classe Freezed 3.x é uma classe Dart real com um mixin gerado. Você escreve uma classe normal com um construtor factory que lista os campos, executa dart run build_runner build, e o Freezed gera ==, hashCode, toString, copyWith, opcionalmente fromJson e toJson, e (para uniões seladas) os auxiliares de correspondência de padrões when e map. A classe é nominal: User e Customer com os mesmos campos não são intercambiáveis. Dá para adicionar métodos, getters computados e construtores fábrica. Dá para marcar a classe como sealed e declarar vários casos de união que casem com padrões de forma exaustiva.

Os dois não são substitutos diretos. Se sobrepõem no caso de “desestruturar em algo parecido com uma tupla” e divergem em todo o resto.

A matriz de recursos

CapacidadeRecord do Dart 3.12Classe Freezed 3.x
Custo de declaraçãoinline, sem arquivoclasse + factory + diretiva part + build_runner
Geração de códigonenhumasim (*.freezed.dart + *.g.dart opcional)
Identidade de tipoestruturalnominal
Tipo nomeadoapenas via typedefsim, classe completa
Nomes de campos na IDE / em errosapenas se declarados como nomeadossempre (o nome da classe aparece)
== e hashCodeautomático, baseado em valorautomático, baseado em valor
toStringautomático ((1, name: 'a'))automático (User(id: 1, name: 'a'))
copyWithnãosim, incluindo copyWith.field(...) profundo
fromJson / toJsonnão (manual)sim via json_serializable
União selada / tipo somanãosim (sealed class + várias factories)
Métodos ou getters personalizadosnãosim (construtor privado + métodos)
Valores padrão de camposnão (devem ser explícitos em cada chamada)sim (valores padrão na factory)
Asserções / validaçãonãosim (no corpo da factory ou @Assert)
Herançanãosim apenas via uniões seladas
Correspondência de padrõessim (posicional e nomeada)sim via when gerado ou correspondência de padrões sobre o sealed
Custo de build / IDEzerobuild_runner watch rodando, arquivos gerados na árvore
Estabilidade da API públicarenomear um campo é uma quebra porque muda a formarenomear um campo é a mesma quebra, mas o nome da classe ancora o tipo
Pegada de memóriauma alocação, sem v-table além de Objectuma alocação, métodos de mixin gerados

As três linhas que decidem a maioria dos casos: custo de declaração, tipo nomeado vs estrutural e se você precisa de copyWith ou JSON. Se não precisa de nenhum deles, o record ganha em peso. Se precisa de qualquer um, o Freezed ganha em ergonomia.

Quando escolher um record do Dart

Escolha um record quando:

Quando escolher uma classe Freezed 3.x

Escolha Freezed quando:

O benchmark: custo de instanciação, igualdade e tempo de build

Os números abaixo são em uma build release do Flutter 3.44, Dart 3.12 AOT, rodando em um Pixel 8 com a mesma forma de cinco campos (int, String, DateTime, bool, String?). Igualdade e hash rodam dentro do BenchmarkRunner por 1.000.000 de iterações.

MétricaRecord do Dart 3.12Classe Freezed 3.x
Alocação, ns / op1824
==, ns / op1114
hashCode, ns / op912
copyWith, ns / opn/a (sem API)31
Custo de build (cold build_runner build)0 ms4,1 s para 50 classes
Bytes gerados por classe0~2 KB
Impacto na latência de hot reloadnenhumnenhum (Freezed se dá bem com hot reload na 3.x)

A diferença em runtime é pequena o bastante para não importar em código de aplicação. A diferença em tempo de build é o único número que afeta a vida diária: em um projeto com 200 classes Freezed, um build_runner build frio leva de 15 a 25 segundos, e o build_runner watch reconstrói incrementalmente em menos de um segundo por arquivo tocado. Se você já entregou um app Flutter com json_serializable, este é o mesmo perfil de custo.

A real diferença de desempenho entre os dois não é em nanossegundos. É o overhead mental no ponto de chamada. Um record não tem arquivo de classe, nem diretiva part, nem arquivo gerado nos diffs de controle de versão. Uma classe Freezed tem os três, mais a etapa de build que precisa estar rodando antes que sua IDE pare de pintar sublinhados vermelhos.

O detalhe que decide por você

Algumas restrições decidem por você, independentemente da preferência:

  1. JSON cruzando uma fronteira de rede obriga a usar Freezed. Records não têm fromJson nem toJson. Dá para escrever um conversor manual, mas para qualquer classe que existe por causa de uma resposta de backend, Freezed mais json_serializable é o caminho de menor fricção. Se você tentasse manter records para DTOs, reinventaria metade do json_serializable à mão.

  2. Gerenciamento de estado com copyWith obriga a usar Freezed. Reducers do Riverpod e do Bloc são escritos em torno de state = state.copyWith(loading: true). Records não conseguem fazer isso sem uma extensão manual que anula o motivo de usar um record. Se você está migrando de GetX para Riverpod (o caminho canônico de modernização em 2026 coberto no guia de migração de GetX para Riverpod), suas classes de estado devem ser Freezed.

  3. Uniões seladas com payload obrigam a usar Freezed. Records não conseguem modelar “um destes três casos nomeados, cada um com seu próprio payload”. Classes seladas do Dart 3 conseguem, mas você ainda precisa de subclasses nomeadas, e a fricção de escrever cada uma à mão é exatamente o que o Freezed elimina.

  4. Um tipo que escapa do arquivo obriga a ter um nome. Se mais alguém no time precisar importar o tipo, dê um nome a ele. Records são úteis dentro de uma função e aceitáveis dentro de um único arquivo. No momento em que outro arquivo o importa via typedef, o typedef só vale a pena porque o tipo subjacente é anônimo. Nesse ponto, escreva a classe.

  5. Um tipo com um ou dois campos e zero comportamento que vive por uma instrução obriga a usar um record. Um par de doubles retornado por uma rotina de hit-test. Um (width, height) de um auxiliar de layout. Um (success, errorOrNull) de uma função estilo try. Escrever uma classe Freezed para isso é burocracia.

Uma heurística prática: se os nomes dos campos aparecem na sua depuração com print ou nos seus logs de falha, você quer uma classe Freezed. Se o valor nunca escapa de uma única função e nunca vai para log, um record é a escolha certa.

Recomendação reafirmada

Para uma base de código Flutter 3.44 / Dart 3.12 em 2026:

Em um app real os dois coexistem. Os records ficam dentro de funções e arquivos de widget; as classes Freezed ficam em models/ e state/. O erro é usar um para o trabalho do outro: uma classe Freezed para um valor de retorno de dois campos é sobre-engenharia, e um typedef de record para um modelo User é subengenharia.

Se você herdou uma base de código cheia de modelos baseados em equatable, o caminho de modernização em 2026 é movê-los para Freezed 3.x e não para records, pelo mesmo motivo: essas classes têm nomes, escapam de arquivos e precisam de copyWith. Records são uma ferramenta nova, não um substituto.

Relacionados

Fontes

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Voltar