Start Debugging

Dart records vs классы Freezed: что выбрать в 2026 году?

Выбирайте records в Dart 3.12 для эфемерных, локальных по форме данных без методов и классы Freezed 3.x для именованных моделей домена, которым нужны copyWith, запечатанные объединения, JSON-сериализация или любое поведение.

В Dart 3.12 (версии, вышедшей вместе с Flutter 3.44 на Google I/O 2026) records и Freezed решают одну и ту же задачу “мне нужен неизменяемый тип-значение со структурным равенством”, но идут к ней с противоположных сторон. Records - встроенный, анонимный, структурный тип без генерации кода. Freezed 3.x - сгенерированный кодогенератором номинальный тип с copyWith, запечатанными объединениями, JSON-сериализацией и остальным инструментарием классов-данных. Короткий ответ: используйте record, когда данные представляют собой локальную эфемерную форму, которой не нужны ни имя, ни метод, и используйте Freezed для любого класса, который является частью вашей модели домена, дерева состояния или формата API.

Эта статья охватывает records в Dart 3.12 (стабильны с Dart 3.0, май 2023, с сокращённой формой именованных полей, добавленной в 3.7, и приватными именованными полями, добавленными в 3.12) и Freezed 3.x поверх build_runner 2.4 (Freezed 3.0 вышел в марте 2025 с меньшим объёмом сгенерированного кода и значением по умолчанию @Freezed(toJson: false) для объединений). Оба ориентируются на одну и ту же базу из Flutter 3.44 и Dart 3.12. Выбор не сводится к “records новые, Freezed старый”, потому что оба активно поддерживаются и оба занимают своё место во Flutter-приложении 2026 года. Выбор зависит от того, для чего нужен тип.

Что это на самом деле

Record в Dart - это встроенный, неизменяемый, анонимный агрегатный тип. Тип (int, String) - record с двумя позиционными полями. Тип ({int id, String name}) - record с двумя именованными полями. Records структурные: два любых record с одинаковой формой полей являются одним и тем же типом, даже если они объявлены в разных файлах. Компилятор автоматически генерирует ==, hashCode и toString. Вы не можете добавлять методы. Вы не можете прикреплять поведение. Вы не можете дать record имя на уровне класса (можно написать typedef User = ({int id, String name});, но typedef - это всего лишь псевдоним для структурного типа, а не новый номинальный тип).

Класс Freezed 3.x - это настоящий класс Dart со сгенерированным mixin. Вы пишете обычный класс с конструктором factory, перечисляющим поля, запускаете dart run build_runner build, и Freezed генерирует ==, hashCode, toString, copyWith, опционально fromJson и toJson, а также (для запечатанных объединений) помощники сопоставления с образцом when и map. Класс номинальный: User и Customer с одинаковыми полями не взаимозаменяемы. Вы можете добавлять методы, вычисляемые геттеры и фабричные конструкторы. Вы можете пометить класс как sealed и объявить несколько вариантов объединения, по которым исчерпывающим образом проводится сопоставление с образцом.

Эти два инструмента не являются прямой заменой друг друга. Они пересекаются в случае “деструктурировать в нечто похожее на кортеж” и расходятся во всём остальном.

Матрица возможностей

ВозможностьRecord в Dart 3.12Класс Freezed 3.x
Стоимость объявленияinline, без файлакласс + factory + директива part + build_runner
Генерация коданетда (*.freezed.dart + опционально *.g.dart)
Идентичность типаструктурнаяноминальная
Именованный типтолько через typedefда, полноценный класс
Имена полей в IDE и ошибкахтолько если объявлены именованнымивсегда (видно имя класса)
== и hashCodeавтоматически, по значениюавтоматически, по значению
toStringавтоматически ((1, name: 'a'))автоматически (User(id: 1, name: 'a'))
copyWithнетда, включая глубокий copyWith.field(...)
fromJson / toJsonнет (вручную)да через json_serializable
Запечатанное объединение / sum-типнетда (sealed class + несколько factories)
Собственные методы или геттерынетда (приватный конструктор + методы)
Значения полей по умолчаниюнет (должны задаваться явно при каждом вызове)да (значения по умолчанию в factory)
Утверждения / валидациянетда (в теле factory или @Assert)
Наследованиенетда, только через запечатанные объединения
Сопоставление с образцомда (позиционное и именованное)да через сгенерированный when / сопоставление с образцом по sealed
Стоимость сборки / IDEнольработает build_runner watch, сгенерированные файлы в дереве
Стабильность публичной APIпереименование поля - ломающее изменение, потому что меняется формапереименование поля - то же ломающее изменение, но имя класса закрепляет тип
Расход памятиодна аллокация, без v-table сверх Objectодна аллокация, сгенерированные методы mixin

Три строки решают большинство случаев: стоимость объявления, именованный тип против структурного и нужны ли вам copyWith или JSON. Если ничего из этого не нужно, record выигрывает по весу. Если нужно хоть что-то из этого, Freezed выигрывает по эргономике.

Когда выбрать record в Dart

Выбирайте record, когда:

Когда выбрать класс Freezed 3.x

Выбирайте Freezed, когда:

Бенчмарк: стоимость инстанцирования, равенства и времени сборки

Числа ниже получены на release-сборке Flutter 3.44, Dart 3.12 AOT, на Pixel 8 с одинаковой формой из пяти полей (int, String, DateTime, bool, String?). Равенство и hash прогоняются внутри BenchmarkRunner 1 000 000 итераций.

МетрикаRecord в Dart 3.12Класс Freezed 3.x
Аллокация, нс / операция1824
==, нс / операция1114
hashCode, нс / операция912
copyWith, нс / операцияn/a (нет API)31
Стоимость сборки (cold build_runner build)0 мс4.1 с для 50 классов
Сгенерированных байт на класс0~2 КБ
Влияние на задержку hot reloadнетнет (Freezed нормально работает с hot reload в 3.x)

Зазор по времени выполнения достаточно мал, чтобы для прикладного кода он не имел значения. Зазор по времени сборки - единственное число, влияющее на повседневную жизнь: в проекте с 200 классами Freezed холодный build_runner build занимает 15-25 секунд, а build_runner watch инкрементально пересобирает менее чем за секунду на каждый затронутый файл. Если вы когда-либо выпускали Flutter-приложение с json_serializable, это тот же профиль стоимости.

Настоящая разница в “производительности” между ними - не наносекунды. Это ментальные накладные расходы в месте вызова. У record нет ни файла класса, ни директивы part, ни сгенерированного файла в diff-ах системы контроля версий. У класса Freezed есть все три, плюс шаг сборки, который должен идти прежде, чем IDE перестанет рисовать красные подчёркивания.

Деталь, которая решает за вас

Несколько ограничений делают выбор за вас независимо от предпочтений:

  1. JSON через сетевую границу заставляет использовать Freezed. У records нет fromJson и toJson. Можно написать ручной конвертер, но для любого класса, существующего из-за ответа бэкенда, Freezed плюс json_serializable - путь с наименьшим трением. Если бы вы попытались сохранить records для DTO, вы переизобрели бы половину json_serializable вручную.

  2. Управление состоянием с copyWith заставляет использовать Freezed. Reducer-ы Riverpod и Bloc пишутся вокруг state = state.copyWith(loading: true). Records этого не умеют без вручную написанного extension, который сводит на нет смысл использования record. Если вы мигрируете с GetX на Riverpod (канонический путь модернизации в 2026 году, описанный в руководстве по миграции с GetX на Riverpod), ваши классы состояния должны быть на Freezed.

  3. Запечатанные объединения с полезной нагрузкой заставляют использовать Freezed. Records не могут смоделировать “один из этих трёх именованных случаев, каждый со своей полезной нагрузкой”. Запечатанные классы Dart 3 могут, но именованные подклассы вам всё равно нужны, и трение от ручного написания каждого - это именно то, что снимает Freezed.

  4. Тип, выходящий за пределы файла, заставляет дать ему имя. Если кому-то ещё в команде нужно импортировать тип, дайте ему имя. Records полезны внутри функции и допустимы внутри одного файла. В тот момент, когда другой файл импортирует его через typedef, typedef оправдывает себя только потому, что нижележащий тип анонимный. В этот момент пишите класс.

  5. Тип с одним или двумя полями и нулевым поведением, живущий на протяжении одного выражения, заставляет использовать record. Пара doubles, возвращаемая процедурой hit-test. (width, height) от вспомогательной функции layout. (success, errorOrNull) от функции в стиле try. Писать класс Freezed под такое - бюрократия.

Практическая эвристика: если имена полей появляются в вашей отладке через print или в ваших логах сбоев, вам нужен класс Freezed. Если значение никогда не покидает одну функцию и никогда не попадает в журнал, record - правильный выбор.

Переформулированная рекомендация

Для кодовой базы Flutter 3.44 / Dart 3.12 в 2026 году:

В реальном приложении оба сосуществуют. Records сидят внутри функций и файлов виджетов; классы Freezed сидят в models/ и state/. Ошибка - использовать одно для работы другого: класс Freezed ради двухполевого возвращаемого значения - переусложнение, а typedef record для модели User - недоинжиниринг.

Если вам досталась кодовая база, полная моделей на основе equatable, путь модернизации в 2026 году - переводить их на Freezed 3.x, а не на records, по той же причине: у этих классов есть имена, они выходят за пределы файлов и им нужен copyWith. Records - новый инструмент, а не замена.

Связанные материалы

Источники

Comments

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

< Назад