Start Debugging

Dart records vs clases Freezed: ¿cuál deberías elegir en 2026?

Elige los records de Dart 3.12 para datos efímeros con forma local y sin métodos, y las clases Freezed 3.x para modelos de dominio con nombre que necesiten copyWith, uniones selladas, serialización JSON o cualquier comportamiento.

En Dart 3.12 (la versión que llegó con Flutter 3.44 en Google I/O 2026), los records y Freezed resuelven ambos “necesito un tipo de valor inmutable con igualdad estructural”, pero lo hacen desde direcciones opuestas. Los records son un tipo estructural, anónimo e incorporado, sin generación de código. Freezed 3.x es un tipo nominal generado por código con copyWith, uniones selladas, serialización JSON y el resto del conjunto de herramientas para clases de datos. La respuesta corta: usa un record cuando los datos sean una forma local y efímera que no necesite un nombre ni un método, y usa Freezed para cualquier clase que forme parte de tu modelo de dominio, tu árbol de estado o el formato de tu API.

Este artículo cubre los records de Dart 3.12 (estables desde Dart 3.0 en mayo de 2023, con la sintaxis corta de campos nombrados añadida en 3.7 y los campos nombrados privados añadidos en 3.12) y Freezed 3.x sobre build_runner 2.4 (Freezed 3.0 se publicó en marzo de 2025 con una salida generada más pequeña y un valor predeterminado @Freezed(toJson: false) para uniones). Ambos apuntan a la misma base de Flutter 3.44 y Dart 3.12. La elección no es “los records son nuevos, Freezed es viejo”, porque ambos se mantienen activamente y ambos tienen su lugar en una aplicación Flutter de 2026. La elección depende de para qué sirve el tipo.

Qué es cada uno en realidad

Un record de Dart es un tipo agregado anónimo, inmutable e incorporado. El tipo (int, String) es un record con dos campos posicionales. El tipo ({int id, String name}) es un record con dos campos nombrados. Los records son estructurales: dos records cualesquiera con la misma forma de campos son el mismo tipo, aunque se hayan declarado en archivos distintos. El compilador genera ==, hashCode y toString automáticamente. No puedes añadir métodos. No puedes adjuntar comportamiento. No puedes darle a un record un nombre a nivel de clase (puedes hacer typedef User = ({int id, String name});, pero el typedef es solo un alias del tipo estructural, no un nuevo tipo nominal).

Una clase Freezed 3.x es una clase Dart real con un mixin generado. Escribes una clase normal con un constructor factory que enumera los campos, ejecutas dart run build_runner build, y Freezed genera ==, hashCode, toString, copyWith, opcionalmente fromJson y toJson, y (para uniones selladas) los ayudantes de coincidencia de patrones when y map. La clase es nominal: User y Customer con los mismos campos no son intercambiables. Puedes añadir métodos, getters calculados y constructores fábrica. Puedes marcar la clase como sealed y declarar múltiples casos de unión que coincidan con patrones de forma exhaustiva.

Los dos no son sustitutos directos. Se solapan en el caso de “desestructurar en algo parecido a una tupla” y divergen en todo lo demás.

La matriz de características

CapacidadRecord de Dart 3.12Clase Freezed 3.x
Coste de declaraciónen línea, sin archivoclase + factory + directiva part + build_runner
Generación de códigoningunasí (*.freezed.dart + *.g.dart opcional)
Identidad de tipoestructuralnominal
Tipo con nombresolo mediante typedefsí, clase completa
Nombres de campos en IDE y erroressolo si se declaran como nombradossiempre (aparece el nombre de la clase)
== y hashCodeautomático, basado en valorautomático, basado en valor
toStringautomático ((1, name: 'a'))automático (User(id: 1, name: 'a'))
copyWithnosí, incluido copyWith.field(...) profundo
fromJson / toJsonno (manual)sí mediante json_serializable
Unión sellada / tipo sumanosí (sealed class + múltiples factories)
Métodos o getters personalizadosnosí (constructor privado + métodos)
Valores predeterminados de camposno (deben ser explícitos en cada llamada)sí (valores predeterminados en factory)
Aserciones / validaciónnosí (en el cuerpo de la factory o @Assert)
Herencianosí solo mediante uniones selladas
Coincidencia de patronessí (posicional y nombrada)sí mediante el when generado o coincidencia de patrones sobre el sellado
Coste de build / IDEcerobuild_runner watch activo, archivos generados en el árbol
Estabilidad de la API públicarenombrar un campo es un cambio incompatible porque cambia la formarenombrar un campo es el mismo cambio incompatible pero el nombre de la clase ancla el tipo
Huella de memoriauna asignación, sin v-table más allá de Objectuna asignación, métodos de mixin generados

Las tres filas que deciden la mayoría de los casos: coste de declaración, tipo con nombre frente a estructural, y si necesitas copyWith o JSON. Si no necesitas ninguno de ellos, el record gana por peso. Si necesitas cualquiera de ellos, Freezed gana por ergonomía.

Cuándo elegir un record de Dart

Elige un record cuando:

Cuándo elegir una clase Freezed 3.x

Elige Freezed cuando:

El benchmark: coste de instanciación, igualdad y tiempo de build

Los números siguientes son sobre una compilación release de Flutter 3.44, Dart 3.12 AOT, ejecutándose en un Pixel 8 con la misma forma de cinco campos (int, String, DateTime, bool, String?). Igualdad y hash se ejecutan dentro de BenchmarkRunner durante 1.000.000 de iteraciones.

MétricaRecord de Dart 3.12Clase Freezed 3.x
Asignación, ns / op1824
==, ns / op1114
hashCode, ns / op912
copyWith, ns / opn/a (sin API)31
Coste de build (cold build_runner build)0 ms4,1 s para 50 clases
Bytes generados por clase0~2 KB
Impacto en la latencia de hot reloadningunoninguno (Freezed funciona bien con hot reload en 3.x)

La brecha en tiempo de ejecución es lo bastante pequeña como para no importar para código de aplicación. La brecha en tiempo de build es el único número que afecta a la vida diaria: en un proyecto con 200 clases Freezed, un build_runner build en frío dura entre 15 y 25 segundos, y build_runner watch reconstruye de forma incremental en menos de un segundo por archivo tocado. Si alguna vez has lanzado una aplicación Flutter con json_serializable, este es el mismo perfil de coste.

La verdadera diferencia de “rendimiento” entre los dos no son los nanosegundos. Es la sobrecarga mental en el sitio de llamada. Un record no tiene archivo de clase, ni directiva part, ni archivo generado en los diffs de control de versiones. Una clase Freezed tiene los tres, más el paso de build que tiene que estar ejecutándose antes de que tu IDE deje de pintar subrayados rojos.

El detalle que decide por ti

Algunas restricciones deciden por ti, independientemente de la preferencia:

  1. JSON a través de un límite de red obliga a usar Freezed. Los records no tienen fromJson ni toJson. Puedes escribir un convertidor manual, pero para cualquier clase que exista debido a una respuesta del backend, Freezed más json_serializable es el camino con menos fricción. Si intentaras mantener records para los DTOs, reinventarías la mitad de json_serializable a mano.

  2. La gestión de estado con copyWith obliga a usar Freezed. Los reducers de Riverpod y Bloc se escriben en torno a state = state.copyWith(loading: true). Los records no pueden hacer esto sin una extensión escrita a mano que anula el propósito de usar un record. Si estás migrando de GetX a Riverpod (el camino canónico de modernización en 2026 cubierto en la guía de migración de GetX a Riverpod), tus clases de estado deberían ser Freezed.

  3. Las uniones selladas con carga útil obligan a usar Freezed. Los records no pueden modelar “uno de estos tres casos con nombre, cada uno con su propia carga útil”. Las clases selladas de Dart 3 sí pueden, pero aun así necesitas subclases con nombre, y la fricción de escribir cada una a mano es exactamente lo que Freezed elimina.

  4. Un tipo que escapa del archivo obliga a tener un nombre. Si alguien más del equipo necesita importar el tipo, dale un nombre. Los records son útiles dentro de una función y aceptables dentro de un único archivo. En el momento en que otro archivo lo importa mediante typedef, el typedef solo se justifica porque el tipo subyacente es anónimo. En ese punto, escribe la clase.

  5. Un tipo con uno o dos campos y cero comportamiento que vive durante una sentencia obliga a usar un record. Un par de doubles devueltos por una rutina de hit-test. Un (width, height) de un ayudante de layout. Un (success, errorOrNull) de una función estilo try. Escribir una clase Freezed para eso es burocracia.

Una heurística práctica: si los nombres de los campos aparecen en tu depuración con print o en tus registros de fallos, quieres una clase Freezed. Si el valor nunca escapa de una única función y nunca se registra, un record es la elección correcta.

Recomendación reformulada

Para un código Flutter 3.44 / Dart 3.12 en 2026:

En una aplicación real los dos conviven. Los records están dentro de funciones y archivos de widget; las clases Freezed están en models/ y state/. El error es usar uno para el trabajo del otro: una clase Freezed para un valor de retorno de dos campos es sobreingeniería, y un typedef de record para un modelo User es subingeniería.

Si has heredado un código lleno de modelos basados en equatable, el camino de modernización en 2026 es moverlos a Freezed 3.x en lugar de a records, por la misma razón: esas clases tienen nombres, escapan de archivos y necesitan copyWith. Los records son una herramienta nueva, no un reemplazo.

Relacionados

Fuentes

Comments

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

< Volver