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, когда:
-
Вы возвращаете два или более значения из одного метода. Это исходный мотивирующий сценарий из Dart 3.0, и он остаётся самым сильным. Record выигрывает у выходного параметра, списка из двух элементов или крошечного вспомогательного класса. Место вызова деструктурирует значение шаблоном.
// Dart 3.12, Flutter 3.44 (int rowsAffected, Duration elapsed) executeBatch(List<Update> updates) { final stopwatch = Stopwatch()..start(); final n = _runBatch(updates); stopwatch.stop(); return (n, stopwatch.elapsed); } // Caller final (rows, elapsed) = executeBatch(updates); print('Updated $rows rows in $elapsed');Класс Freezed для такого случая - это три файла (
batch_result.dart,batch_result.freezed.dart, опциональноbatch_result.g.dart) ради значения, живущего одну строку. -
Форма локальна для одной функции или одного виджета.
_HitTestResult, существующий десять строк в расчётах layout, должен быть record, а не классом. Если форма утекает за пределы файла, это сигнал, что ей пора расти до класса Freezed. -
Вы делаете сопоставление с образцом по форме возвращаемых данных. Switch-выражения над records - именно тот способ, которым Dart 3 предлагает обрабатывать вывод парсера, валидатора или любое возвращаемое значение “метка плюс полезная нагрузка”, где полезная нагрузка - одна из нескольких форм и живёт только в месте вызова.
// Dart 3.12 sealed class ParseTag {} final ok = ParseTag(); // marker only - real code uses sealed subclasses ({bool ok, String? error, Map<String, dynamic>? data}) tryParse(String s) { try { return (ok: true, error: null, data: jsonDecode(s) as Map<String, dynamic>); } catch (e) { return (ok: false, error: e.toString(), data: null); } } final result = tryParse(input); switch (result) { case (ok: true, data: final m?, error: _): handle(m); case (ok: false, error: final msg?, data: _): reportError(msg); default: reportError('unknown parse failure'); }Для чистого результата “ошибка или полезная нагрузка”, который поднимается на два кадра стека, record чище, чем вводить запечатанное объединение
Result<T>через Freezed. В тот момент, когда три разных места вызова делают switch по одной и той же форме, повышайте её до именованного типа. -
Вы хотите нулевую стоимость
build_runnerв этой части кода. Records не добавляют ни генерации кода, ни директив part, ни watch-процесса. В пакете Flutter или библиотеке только на Dart, где вы хотите поставлять код без какого-либо шага генератора, records - единственная опция для неизменяемого типа-значения помимо класса, написанного вручную. -
Количество полей небольшое, и типы полей очевидны из контекста. Два или три поля, смысл которых понятен в месте вызова. Как только у вас пять полей и месту вызова нужен IntelliSense, чтобы вспомнить, для чего каждое, вы переросли record и нуждаетесь в именованном классе.
Когда выбрать класс Freezed 3.x
Выбирайте Freezed, когда:
-
Тип - это модель домена, DTO API или фрагмент состояния приложения. Всё, что пересекает границу слоя, попадает в журнал, сериализуется или появляется в трассировках стека, выигрывает от настоящего имени класса.
User,Order,LineItem,AppState,AuthState. Эти типы заслуживают номинальной идентичности,toString, печатающего имя класса, и отладочного опыта, в котором IDE показываетUser { id, email, createdAt }, а не({int id, String email, DateTime createdAt}).// Dart 3.12, Flutter 3.44, freezed 3.x, json_serializable 6.x import 'package:freezed_annotation/freezed_annotation.dart'; part 'user.freezed.dart'; part 'user.g.dart'; @freezed class User with _$User { const User._(); const factory User({ required int id, required String email, DateTime? createdAt, @Default(false) bool emailVerified, }) = _User; factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json); bool get isVerified => emailVerified && email.contains('@'); }Запустите
dart run build_runner build --delete-conflicting-outputsи получите==,hashCode,toString,copyWith,fromJson,toJsonи геттерisVerified- всё в одном классе. -
Вам нужен
copyWithдля неизменяемости состояния. Это самая частая причина, по которой проекты Flutter выбирают Freezed. Riverpod, Bloc и любое управление состоянием в стиле reducer опираются наstate = state.copyWith(loading: true). У records нетcopyWith. Можно написать его вручную, но тогда теряется сам смысл использования record. -
Вам нужны запечатанные объединения для типов состояния или результата.
LoadingStateсо случаямиInitial,Loading,Success(data),Failure(error)- канонический запечатанный класс Freezed. Сопоставление с образцом в Dart 3 исчерпывающее, компилятор предупредит, если вы добавите случай и пропуститеswitch, аcopyWithработает по каждому случаю.// freezed 3.x @freezed sealed class AuthState with _$AuthState { const factory AuthState.signedOut() = AuthSignedOut; const factory AuthState.signingIn() = AuthSigningIn; const factory AuthState.signedIn(User user) = AuthSignedIn; const factory AuthState.failed(String reason) = AuthFailed; } // Pattern match Widget build(BuildContext context, AuthState state) => switch (state) { AuthSignedOut() => const LoginPage(), AuthSigningIn() => const Spinner(), AuthSignedIn(:final user) => HomePage(user: user), AuthFailed(:final reason) => ErrorPage(reason: reason), };Это нельзя смоделировать record. Records анонимны; запечатанное наследование требует именованных типов.
-
Вам нужна JSON-сериализация. Freezed интегрируется с
json_serializable, так что вы бесплатно получаетеUser.fromJsonиuser.toJson(). У record нет встроенной поддержки JSON; вы пишете преобразование вручную каждый раз. -
Классу нужны валидация, значения по умолчанию или методы. Тело factory может проверять инварианты.
@Default(0)задаёт значение по умолчанию. Приватный конструктор (const User._();) плюс обычные методы или геттеры позволяют классу нести поведение. Records ничего из этого не умеют. -
Вы хотите, чтобы имена полей появлялись в IDE и логах сбоев. Record печатается как
(1, 'a@b.com', 2026-05-27 00:00:00.000). Класс Freezed печатается какUser(id: 1, email: a@b.com, createdAt: 2026-05-27 00:00:00.000, emailVerified: false). В трассировке стека второй вариант стоит шага генерации кода.
Бенчмарк: стоимость инстанцирования, равенства и времени сборки
Числа ниже получены на 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 |
|---|---|---|
| Аллокация, нс / операция | 18 | 24 |
==, нс / операция | 11 | 14 |
hashCode, нс / операция | 9 | 12 |
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 перестанет рисовать красные подчёркивания.
Деталь, которая решает за вас
Несколько ограничений делают выбор за вас независимо от предпочтений:
-
JSON через сетевую границу заставляет использовать Freezed. У records нет
fromJsonиtoJson. Можно написать ручной конвертер, но для любого класса, существующего из-за ответа бэкенда, Freezed плюсjson_serializable- путь с наименьшим трением. Если бы вы попытались сохранить records для DTO, вы переизобрели бы половинуjson_serializableвручную. -
Управление состоянием с
copyWithзаставляет использовать Freezed. Reducer-ы Riverpod и Bloc пишутся вокругstate = state.copyWith(loading: true). Records этого не умеют без вручную написанного extension, который сводит на нет смысл использования record. Если вы мигрируете с GetX на Riverpod (канонический путь модернизации в 2026 году, описанный в руководстве по миграции с GetX на Riverpod), ваши классы состояния должны быть на Freezed. -
Запечатанные объединения с полезной нагрузкой заставляют использовать Freezed. Records не могут смоделировать “один из этих трёх именованных случаев, каждый со своей полезной нагрузкой”. Запечатанные классы Dart 3 могут, но именованные подклассы вам всё равно нужны, и трение от ручного написания каждого - это именно то, что снимает Freezed.
-
Тип, выходящий за пределы файла, заставляет дать ему имя. Если кому-то ещё в команде нужно импортировать тип, дайте ему имя. Records полезны внутри функции и допустимы внутри одного файла. В тот момент, когда другой файл импортирует его через
typedef, typedef оправдывает себя только потому, что нижележащий тип анонимный. В этот момент пишите класс. -
Тип с одним или двумя полями и нулевым поведением, живущий на протяжении одного выражения, заставляет использовать record. Пара doubles, возвращаемая процедурой hit-test.
(width, height)от вспомогательной функции layout.(success, errorOrNull)от функции в стиле try. Писать класс Freezed под такое - бюрократия.
Практическая эвристика: если имена полей появляются в вашей отладке через print или в ваших логах сбоев, вам нужен класс Freezed. Если значение никогда не покидает одну функцию и никогда не попадает в журнал, record - правильный выбор.
Переформулированная рекомендация
Для кодовой базы Flutter 3.44 / Dart 3.12 в 2026 году:
- Локальная, эфемерная, анонимная форма, без поведения, без JSON: выбирайте record. Множественные возвращаемые значения, деструктурированные кортежи, формы сопоставления с образцом внутри одной функции.
- Именованный, выходит за пределы файла, нужны
copyWith/ JSON / запечатанное объединение / методы: выбирайте класс Freezed 3.x. Модели домена, классы состояния, DTO API, всё, что окажется в трассировке стека.
В реальном приложении оба сосуществуют. Records сидят внутри функций и файлов виджетов; классы Freezed сидят в models/ и state/. Ошибка - использовать одно для работы другого: класс Freezed ради двухполевого возвращаемого значения - переусложнение, а typedef record для модели User - недоинжиниринг.
Если вам досталась кодовая база, полная моделей на основе equatable, путь модернизации в 2026 году - переводить их на Freezed 3.x, а не на records, по той же причине: у этих классов есть имена, они выходят за пределы файлов и им нужен copyWith. Records - новый инструмент, а не замена.
Связанные материалы
- Flutter vs React Native vs .NET MAUI для нового мобильного проекта в 2026 году для выбора на уровне фреймворка, который находится слоем выше этого решения.
- Dart 3.12 убирает список инициализации для приватных полей для языкового изменения, которое взаимодействует с тем, как вы объявляете параметры factory в Freezed в 2026 году.
- Как мигрировать Flutter-приложение с GetX на Riverpod для модернизации управления состоянием, где Freezed - канонический класс состояния.
- Как написать isolate в Dart для CPU-нагруженной работы для случая, когда records пересекают границу isolate и нужно подумать, что сериализуется.
- Как профилировать jank во Flutter-приложении через DevTools для рабочего процесса по производительности, когда аллокация класса состояния действительно появляется на временной шкале.
Источники
- Тур по языку: records в Dart, документация Dart, доступ 2026-05-27.
- Анонс Dart 3.0, команда Dart, май 2023.
- Пакет Freezed на pub.dev, Remi Rousselet.
- Пакет json_serializable, команда Dart.
- Заметки о релизе Flutter 3.44, документация Flutter, доступ 2026-05-27.
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.