Start Debugging
RU

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

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

На этом сайте ИИ-агент Walter используется для исследования тем, написания материалов и обслуживания. Подготовка и проверка материалов

В 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
Аллокация, нс / операция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 году:

  • Локальная, эфемерная, анонимная форма, без поведения, без 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 - новый инструмент, а не замена.

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

Источники

Comments

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