Вот задача, которую вы писали раз двадцать: экран со списком товаров. Загрузить с сервера, обработать ошибку, отрисовать список.

Мы отдали её агенту — Claude Opus 5 — и получили вот это.

class ProductsState {
  final List<Product>? items;
  final bool isLoading;
  final Exception? error;
}

Выглядит как то, что вы написали бы сами. В этом и суть: модели обучены ровно на том, что мы все годами писали руками.

Теперь посчитаем. Три поля: bool, nullable-список, nullable-ошибка. У каждого по два значимых состояния. Два на два на два — восемь комбинаций. Сколько из них что-то значат?

isLoadingitemserrorЧто это значит
truenullnullЗагружаем
falsenullестьУпало
falseестьnullЗагрузили
trueестьnull?
truenullесть?
trueестьесть?
falseестьесть?
falsenullnull?

Три. Остальные пять — не ошибки компиляции. Это валидный код. Он собирается, проходит ревью, уезжает в прод и лежит там. isLoadingtrue, и рядом лежит ошибка: что рисуем? Или всё пустое: не грузим, списка нет, ошибки нет. Мы ещё не начинали — или сходили и вернулись ни с чем? Код не знает. Он гадает. Или гадает следующий, кто откроет файл.

Коротко

  • Невозможное состояние — то, которое ваш тип разрешает, а предметная область запрещает. Это то же самое, что сломанный инвариант, только с другой стороны.
  • Цена — не баги в рантайме. Цена — договорённость, которую никто не записал, ветки, которые никто не рассматривал, и налог, который вы платите при каждом чтении, а не один раз за инцидент.
  • Инвариант есть всегда. Вопрос только в том, держит ли его человек — комментарием, страницей в вики, assert, который не работает в релизе, — или держать нечего, потому что сломать его не компилируется.
  • enum Status плюс nullable-поля делает хуже, а не лучше: двенадцать комбинаций вместо восьми, и всё те же три осмысленных.
  • Sealed-класс с non-nullable полями оставляет только осмысленные состояния и делает остальные несобираемыми. Ни одного булева поля, ни одного nullable, ни одного !.
  • Проверка на полноту — вот ради чего всё. Добавляете четвёртое состояние — и компилятор перечисляет все места, где оно не обработано. Это обратная связь, на которую агент умеет реагировать, а не стена, в которую он упирается.
  • Одна ветка _ => выключает всё это молча. Это единственное правило ревью, которое стоит унести из статьи.
  • Алгебраические типы чинят не всё. Они убирают невозможные комбинации состояний, но не невозможные значения, и ничего не делают на границе системы.

Невозможные состояния и их более старое имя

Сначала словарь, потому что одну вещь называют двумя словами.

Невозможное состояние — то, которое ваш тип разрешает, а предметная область запрещает. Экран не может одновременно грузиться и показывать ошибку. Тип это позволяет. Это дыра.

Более старое и более точное имя — инвариант: правило, которое должно выполняться всегда. Если грузим, данных ещё нет. Если есть ошибка, у неё есть сообщение. Невозможное состояние — это просто сломанный инвариант. Один и тот же факт с двух сторон: инвариант — как должно быть, невозможное состояние — что получилось, когда не так.

Запомните это слово. Именно на нём всё разворачивается.

Почему это проблема и почему обычное объяснение неверно

Обычно говорят так: невозможные состояния — это баги, которые не падают, а тихо рисуют не тот экран. Звучит убедительно, и если бы дело было в этом, ответ был бы дешёвым: тесты, QA, алерт на пустой экран. Поймали, починили, поехали дальше.

Но проблема не в этом. Она вообще не про рантайм. Цены здесь три, и все три уже лежат в вашем коде прямо сейчас, даже если приложение работает идеально.

Раз: договорённость, которую никто не записал. Посмотрите на класс агента ещё раз. Где написано, что при isLoading == true нельзя читать items? Нигде. Где написано, что error и items никогда не заполнены одновременно? Нигде. Эти правила существуют, на них опираются, но их нет ни в типе, ни в комментарии. Они живут в голове того, кто написал класс, а все остальные восстанавливают их из кода, читая if-ы и додумывая замысел. Пять минут на файл — каждому, кто его откроет.

Два: количество веток. Восемь комбинаций — это восемь случаев, с каждым из которых кто-то должен что-то сделать: обработать или сознательно исключить. Обработаны три. Остальные пять не обработаны и не исключены — их никогда не рассматривали. Разница важная: исключённый случай — это решение, нерассмотренный — дыра.

И счёт растёт не сложением, а умножением.

Полей, которые ходят вместеКомбинацийОсмысленных
383
4163–4
5323–4
6643–4

Три: вы платите при каждом чтении, а не один раз за баг. Это главное. Баг чинится один раз. Договорённость, которой нет в типе, каждый раз выводится заново — на ревью, при добавлении фичи, при разборе инцидента. Это не разовая цена, это налог.

Отсюда же берётся привычный костыль. Дыры затыкают условиями: тут проверим, что не грузим, там — что ошибка не null. Каждый такой if — заплатка на дыре, которую вы сами прорезали, объявив три независимых поля. Дыр пять, заплаток обычно две — те, которые уже выстрелили.

И заметьте попутно: ничего из этого не падает. Ни краш-лога, ни стектрейса, ничего в трекере ошибок. Так что «мониторинг поймает» тут не работает. Но это следствие, а не суть.

Что изменилось за пару лет — объём. Раньше такой класс появлялся раз в неделю, и его писал человек, потративший хотя бы тридцать секунд на размышление о полях. Теперь он появляется за двенадцать секунд, и его никто не читает — ни агент, ни вы. А ревьюер — это человек, который смотрит на три поля, а не держит в голове восемь комбинаций.

Кто держит инвариант

Итак, вопрос никогда не в том, есть ли у вас инвариант. Он есть всегда. Вопрос в том, кто за него отвечает.

Обычно человек. Комментарий над классом. Страница в вики, которую не открывали с позапрошлого года. Договорённость, принятая на ревью, которую помнят двое из пяти. В лучшем случае assert в конструкторе.

assert в Dart — это не инвариант

Инструкции assert вырезаются из релизной сборки. Не «пропускаются» — условие вообще не вычисляется, как и аргументы assert, поэтому дорогая проверка внутри assert ничего не стоит в проде. Практическое следствие — ровно то, о чём забывают: инвариант, который вы защитили ассертом, для ваших пользователей не существует. Он работал во время разработки, когда вы и так смотрели, и его нет ровно там, где вы не смотрите. Assert — инструмент отладки, а не ограничение.

Всё это — инвариант, который кто-то держит: руками, вниманием, памятью. Альтернатива — устроить так, чтобы держать было нечего, потому что нарушение невыразимо в типе. Не «мы проверяем, что так не бывает», а «так не компилируется».

И вот почему это перестало быть академическим различием. Инвариант, который держит человек, работает ровно до тех пор, пока код пишется на человеческой скорости. Агент не читал ваш комментарий, не открывал вики и не был на вашем ревью. Договорённости не масштабируются под скорость генерации. Компиляторы масштабируются.

Алгебраические типы данных за один проход

Одна оговорка перед теорией, чтобы снять половину возражений. Пример здесь намеренно самый очевидный из возможных. Загрузка, ошибка, успех — самая заезженная иллюстрация алгебраических типов данных в интернете, она есть в каждом туториале. У идеи есть знаменитый лозунг — make illegal states unrepresentable, «сделай недопустимые состояния непредставимыми», — который обычно приписывают докладам Ярона Мински про эффективный ML, и ему больше десяти лет. Тут нет никакого открытия. Интересно другое: откуда этот класс взялся в начале статьи и что делать, когда такой код приезжает пачками.

Теперь определение. Без теории категорий.

Произведение — это «и». Обычный класс с полями: строка и число и булево. Количество возможных значений — произведение количеств. Отсюда и взялись восемь: это не риторика, это арифметика. Каждое добавленное поле умножает.

Сумма — это «или». Значение — либо одно, либо другое, третьего не дано. Самая простая сумма, которую все знают, — enum. Sealed-класс — та же сумма, только каждый случай может нести свои данные. Здесь количества складываются: загрузка — одно значение, ошибка — сколько есть ошибок, успех — сколько есть списков. Ничего не умножается просто так.

Вся идея одним предложением: вы не можете сконструировать значение, изображающее состояние, которое никогда не было валидным.

«А почему не просто enum?»

Очевидное возражение: sealed для этого не нужен, добавьте enum Status с тремя значениями и не усложняйте. Инстинкт разумный, но не работает.

enum Status { loading, error, success }

class ProductsState {
  final Status status;
  final List<Product>? items;
  final Exception? error;
}

Считаем снова: три значения статуса на два состояния списка на два состояния ошибки. Двенадцать комбинаций. Было восемь. Осмысленных по-прежнему три. Арифметически стало хуже — девять мусорных комбинаций вместо пяти.

И исходная проблема не тронута. statussuccess, itemsnull: компилируется прекрасно. А в UI вы всё так же пишете items!, клянясь компилятору, что данные точно есть. Обещание — не гарантия. Это отключённая проверка с приятным синтаксисом.

Разница в том, что несёт сумма. enum — сумма без данных: он говорит, в каком вы состоянии, но данные лежат рядом и привязаны к состоянию только вашей дисциплиной. Sealed-класс — сумма с данными: список существует ровно там, где он что-то значит, и больше нигде.

Отсюда правило: если состояния не несут данных — берите enum, он короче. Как только хотя бы у одного состояния появляется полезная нагрузка, enum перестаёт помогать, и каждый ! в вашем UI — это чек об оплате.

Идея старая. Языку ML, откуда она родом, пятьдесят лет. В Haskell, OCaml и F# это есть уже десятилетия. В мейнстриме она сейчас есть практически везде: Dart получил это в третьей версии, в 2023 году. Изменилась не идея, а аргумент в её пользу. Раньше АТД продавали через элегантность, а эстетические аргументы проигрывают спринтам. Теперь аргумент такой: код пишется быстрее, чем читается, единственный ревьюер, поспевающий за генерацией, — компилятор, и всё, что вы не выразили в типе, вы выразили в виде надежды.

Переписываем

Три шага, минуты две работы.

Шаг один: выписать состояния словами, до всякого кода. Экран грузится. Экран упал. У экрана есть список. Всё, три. Если на этом шаге у вас получилось семь, вы почти наверняка выписываете комбинации флагов, а не состояния. Состояние — это то, что называется одним словом и показывается пальцем на макете.

Шаг два: каждое состояние становится отдельным классом.

sealed class ProductsState {
  const ProductsState();
}

final class Loading extends ProductsState {
  const Loading();
}

final class Failed extends ProductsState {
  const Failed(this.error);
  final Exception error;
}

final class Loaded extends ProductsState {
  const Loaded(this.items);
  final List<Product> items;
}

Смотрите, что произошло. Failed держит ошибку, и она не nullable — состояния «упали, но ошибки нет» больше не существует, его физически нельзя сконструировать. Loaded держит список, тоже не nullable. Loading не держит ничего, потому что во время загрузки данных нет, а поле под них — это прямое приглашение к невозможному состоянию.

Обратите внимание на то, чего здесь нет. Ни одного булева поля. Ни одного nullable. Это и есть весь рефакторинг, остальное — синтаксис.

Одна деталь про Dart: sealed ограничивает наследование той же библиотекой, а библиотека на практике — это один файл, если вы намеренно не разбиваете её через part. Это не занудство, это механизм: чтобы проверить полноту, компилятор должен знать полный список случаев, а гарантировать это он может только внутри библиотеки. Отсюда и соглашение: сколько бы случаев ни было, они живут в одном файле. Сначала выглядит странно, потом оказывается удобным — всё пространство состояний экрана видно одним взглядом.

Шаг три: UI.

Widget build(BuildContext context) {
  return switch (state) {
    Loading() => const AppSpinner(),
    Failed(:final error) => ErrorView(error),
    Loaded(:final items) => ItemList(items),
  };
}

Ни одного if. Ни одной проверки на null. Данные разбираются прямо в паттерне — :final items — и приходят уже типизированными. Внутри ветки Loaded список существует, а не «возможно, существует». Сравните с тем, что было бы на флагах: цепочка проверок, порядок которых важен, а причину порядка никто уже не помнит.

Как это делать на проекте, где сорок экранов

Не переписывайте всё. Правило: новый экран — сразу sealed; старый — когда вы и так в нём что-то чините. Экран, который никто не трогал два года, не трогайте. Невозможные состояния там, скорее всего, есть, но они либо уже выстрелили, либо не выстрелят никогда.

Для тех, что придётся трогать, приоритет определяется одним: из скольких мест это состояние читают. Один switch в одном виджете — переписывать почти не окупается, там и так всё видно. Состояние, которое читают из шести мест, — UI, аналитика, логирование, обработчик пуша, — вот где проверка на полноту себя оправдывает, потому что это ровно те пять из шести, про которые забывают. Если экран уже идёт через один стрим состояния, а не через пирамиду флагов, начинать дешевле всего оттуда.

Тот же тип на четырёх других языках

Не потому, что нужен второй язык, а чтобы было видно: это не диалект Dart и не мода Flutter-сообщества. Тот же тип, три состояния, данные привязаны к состоянию.

Kotlin.

sealed interface ProductsState

data object Loading : ProductsState

data class Failed(
    val error: AppError,
) : ProductsState

data class Loaded(
    val items: List<Product>,
) : ProductsState

when в роли выражения обязан покрыть все ветки — ровно как наш switch.

Rust. Интереснее, потому что это встроенный enum:

enum ProductsState {
    Loading,
    Failed(AppError),
    Loaded(Vec<Product>),
}

В Rust enum и есть сумма с данными с самого начала, и никакой дополнительной обвязки не требуется. Пропустите случай в match — не скомпилируется.

Swift, то же самое через associated values:

enum ProductsState {
    case loading
    case failed(AppError)
    case loaded([Product])
}

И экзотика — Idris с синтаксисом семейства ML, от которого произошло всё перечисленное выше:

data ProductsState
  = Loading
  | Failed AppError
  | Loaded (List Product)

Посмотрите на вертикальную черту. Она читается как «или». Тип-сумма буквально записан символом «или» — вот откуда взялось название; это сумма задолго до того, как её кто-то назвал sealed-классом.

Idris умеет ещё кое-что, чего не умеет ни один из предыдущих четырёх:

import Data.Vect

data ProductsState : Type where
  Loading : ProductsState
  Failed  : AppError -> ProductsState
  Loaded  : Vect (S n) Product -> ProductsState

Vect (S n) в типе означает список, гарантированно содержащий хотя бы один элемент. Не «мы проверили», не «мы договорились» — вы просто не можете сконструировать Loaded с пустым списком. Запомните этот пример, он вернётся в конце.

Одна идея, пять языков, проверка на полноту везде. Если вы не на Dart, всё дальнейшее работает у вас так же.

Проверка на полноту — ради чего всё и затевалось

Через месяц приходит требование: когда список вернулся пустым, показать заглушку с кнопкой. Это не ошибка и не успех с данными. Это четвёртое состояние.

final class Empty extends ProductsState {
  const Empty();
}

Одна строка. Больше ничего не менялось. И проект не собирается:

The type 'ProductsState' is not exhaustively matched by the switch
cases since it doesn't match 'Empty()'.

Компилятор перечисляет все места, где новое состояние не обработано. Не одно из них — все.

Теперь о том, почему этому место в статье про код, написанный ИИ. «Добавь пустое состояние на экран списка» — задача ровно того размера, который отдают агенту не глядя. Агент добавит класс, обновит тот switch, который был у него в контексте, и не обновит два других — в аналитике и за кнопкой обновления. Их не было в промпте, значит, для него их не существует.

На флагах это уезжает в прод. Диффом не поймать: в диффе только то, что агент изменил. Тесты зелёные, потому что у состояния, которого месяц назад не было, тестов нет по определению. Вы узнаете об этом от пользователя через шесть недель, в формулировке «иногда просто пустой экран».

На sealed-классах это никуда не уезжает. Оно не собирается.

И вот что отсюда стоит унести. Проверка на полноту — это не защита от ИИ. Это обратная связь для него. Агент плох в этом потому, что у него нет доступа к тому списку «и вот тут ещё не забудь», который держите в голове вы. Компилятор выдаёт этот список в машиночитаемом виде, с файлами и номерами строк. Агент упирается в красное и чинит все три — а это он умеет хорошо, потому что ему сказали, где именно. Вы не мешаете ему работать, вы даёте ему то, чего не хватало. Тот же механизм помогает человеку раз в неделю, а агенту — раз в двадцать минут.

Одна строка, которая всё это выключает

return switch (state) {
  Loading() => const AppSpinner(),
  _ => ItemList(state.items),
};

Одно подчёркивание — и проверки на полноту нет. Молча, без предупреждения. Ветка «всё остальное» по определению обрабатывает всё, значит, компилятору нечего проверять. Добавьте пятое состояние, шестое, десятое — соберётся и молча провалится сюда.

Если вы уносите из статьи ровно одно правило ревью, унесите это:

В switch по sealed-типу не должно быть ветки по умолчанию.

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

Где алгебраические типы не помогают

Четыре места, где они не работают или работают против вас. Без этого раздела всё остальное — проповедь.

Сумма не лечит произведение. Вы разбили экран на три состояния — хорошо. Но Loaded — по-прежнему обычный класс с полями, и если их двенадцать, а половина nullable, вы просто перенесли болото этажом ниже. АТД — про то, какие состояния существуют, а не про то, что лежит внутри состояния.

Состояния, которые пересекаются, — самая частая ошибка при переходе. Прилетает требование pull-to-refresh: список уже на экране, и вы тянете свежий. Это Loading или Loaded? Наивный ответ — случай Refreshing с items внутри. Через неделю у вас Refreshing, RefreshingAfterError и FailedButHasCache — тот же комбинаторный взрыв, только теперь в классах, что заметно хуже флагов, потому что многословнее.

Правильный ответ людей расстраивает:

final class Loaded extends ProductsState {
  const Loaded(this.items, {this.isRefreshing = false});
  final List<Product> items;
  final bool isRefreshing;
}

Да, булево поле вернулось, и это нормально. Теперь оно живёт внутри состояния, где что-то значит: «у нас есть список, и мы его обновляем» — реальная ситуация, которую нужно уметь выразить. А «обновляем без списка» больше не существует, потому что его некуда положить.

Суммы — для взаимоисключающего, произведения — для сосуществующего

Это правило решает, поможет рефакторинг или навредит. Две вещи, которые никогда не истинны одновременно, просят сумму — отдельные случаи sealed-типа. Две вещи, которые регулярно истинны одновременно, просят произведение — поля рядом внутри одного случая. Перепутать их — главный способ оказаться в худшем положении, чем в начале: взаимоисключающие состояния, смоделированные параллельными булевыми полями, дают ту самую задачу про восемь комбинаций, а сосуществующие факты, смоделированные отдельными случаями, дают класс на каждую комбинацию.

Типы держат не всякий инвариант, и это важнее остального. Sealed-класс прекрасно выражает структурный инвариант: какие состояния существуют и какие данные с каким состоянием ходят. И ничего не говорит про инварианты на значениях: этот список отсортирован, дата начала раньше даты конца, сумма позиций сходится с итогом, эта строка — валидный email. Всё это по-прежнему держит человек, и никакой sealed тут не помогает.

Тот самый пример на Idris — и есть контрпример. Vect (S n), непустота в типе, — это инвариант на значении, затащенный в систему типов. Называется это зависимыми типами, и так же можно было бы выразить «отсортирован» и «начало раньше конца». Цена — язык, на котором вы не пишете и, будем честны, не будете. На практике используют другой приём: приватный конструктор с валидацией, чтобы тип нельзя было создать в невалидной форме.

В ту же корзину — границы системы. JSON из сети не типизирован. АТД начинаются после парсинга, и sealed-класс не спасёт вас, когда бэкенд пришлёт "succes" с одной «s», — спасёт явная десериализация с ключами, прописанными руками. Фраза, которую стоит запомнить: АТД убирают невозможные комбинации состояний, но не невозможные значения.

И оверинжиниринг. Sealed-класс с двумя случаями без данных — это enum, и enum читается быстрее. Sealed-класс с одним случаем — это класс. Честное булево поле остаётся честным булевым полем: isSelected у чекбокса — ровно два состояния, оба валидные, и АТД тут ничего не улучшают. Проверка простая: посчитайте комбинации и вычеркните бессмысленные. Вычёркивать нечего — не трогайте.

Наконец, честная цена: это многословно. Четыре состояния на Dart — примерно тридцать строк против четырёх полей. Аргумент «дольше писать» не переживает 2026 год: это ровно та работа, которую агент делает за секунды и без ошибок. А вот «дольше читать» никуда не делось, и это та цена, которую вы платите.

Найдите свои за полчаса

Ревью класса состояния

Десять проверок в трёх группах. Всё неотмеченное — решение, которое ещё никто не принял.

Сам тип

  • Взаимоисключающие ситуации — отдельные случаи sealed-типа, а не параллельные булевы поля
  • Каждое поле внутри случая non-nullable, либо null означает что-то конкретное и это записано
  • Данные лежат в том состоянии, которому принадлежат, а не рядом со статусным enum
  • Сосуществующие факты — поля внутри одного случая, а не случай на каждую комбинацию

Каждый switch по нему

  • Нет ветки по умолчанию и нет паттерна с подчёркиванием по sealed-типу
  • Нет принудительного снятия null через восклицательный знак на поле, которое состояние и так гарантирует
  • Покрыты все читатели состояния, включая аналитику, логирование и фоновые обработчики

Чего типы за вас не сделают

  • Инварианты на значениях — отсортирован, непуст, в диапазоне, корректной формы — проверяются в конструкторе, а не предполагаются
  • Парсинг на границе системы валидирует явно, а не доверяет форме данных
  • Ассерты считаются инструментом отладки, потому что в релизной сборке они не работают

Коротко о главном

Невозможное состояние — то, которое ваш тип разрешает, а предметная область запрещает; это то же самое, что сломанный инвариант. И дорого оно не потому, что что-то ломается в рантайме, а потому, что каждую договорённость, которой нет в типе, кто-то выводит заново при каждом чтении.

Инвариант есть всегда. Вопрос только в том, держит ли его человек — комментарием, страницей в вики и ассертом, который не работает в релизе, — или держать нечего, потому что нарушение не компилируется.

Три поля, которые ходят вместе, восемь комбинаций, три осмысленных. Sealed-класс с non-nullable полями оставляет ровно эти три и делает остальные пять несобираемыми. Проверка на полноту — вот в чём отдача: она превращает «кто-то забыл обновить обработку» в «проект не собирается», и работает одинаково независимо от того, кто забыл. А ветка по умолчанию выключает всё это молча.

И более крупный сдвиг. Раньше типы были способом объяснить свой замысел коллеге, который откроет файл через полгода. Теперь это единственный способ объяснить его чему-то, что пишет код быстрее, чем вы читаете. Ремесло никуда не делось — оно поднялось на уровень выше. Раньше вы писали реализацию. Теперь вы пишете границы, внутри которых реализация обязана остаться, а заполнить их может кто угодно. В том числе машина.

Частые вопросы

Состояние, которое ваш тип разрешает, а предметная область запрещает. У класса состояния экрана с булевым флагом загрузки, nullable-списком и nullable-ошибкой восемь комбинаций, но только три из них описывают ситуацию, в которой продукт реально бывает. Остальные пять компилируются, проходят ревью и уезжают в прод. Более точное имя тому же самому — сломанный инвариант: инвариант — это правило, которое должно выполняться всегда, а невозможное состояние — это то, что получается, когда тип его не обеспечивает.
Способ описать, сколько значений может принимать тип. Тип-произведение — это «и»: обычный класс с полями, количество значений которого равно произведению количеств по полям, поэтому каждое добавленное поле умножает. Тип-сумма — это «или»: значение относится к одному случаю либо к другому, третьего не дано, и количества складываются, а не умножаются. Enum — это сумма без данных, sealed-класс — сумма, где каждый случай несёт свои данные. Смысл сумм в том, что вы не можете сконструировать значение, изображающее состояние, которое никогда не было валидным.
Потому что арифметика становится хуже. Enum с тремя значениями рядом с nullable-списком и nullable-ошибкой даёт двенадцать комбинаций вместо восьми, и осмысленных среди них по-прежнему три. Исходная проблема не тронута: статус может быть success, а списка при этом нет вовсе, это компилируется, и в UI поле приходится подпирать восклицательным знаком. Enum говорит, в каком вы состоянии, но оставляет данные рядом, привязанными к состоянию только договорённостью. Sealed-класс привязывает данные к состоянию, поэтому список существует ровно там, где он что-то значит.
В одной библиотеке, а это на практике один файл, если вы намеренно не разбиваете её через part. Это механизм, а не стилевое правило: чтобы проверить полноту разбора, компилятору нужен полный список подтипов, а гарантировать полноту он может только внутри библиотеки. Отсюда соглашение держать все случаи состояния в одном файле, и оно оказывается удобным: всё пространство состояний экрана видно одним взглядом.
Потому что ветка всё остальное по определению обрабатывает любой случай, и компилятору нечего проверять. Как только в switch по sealed-типу появляется ветка по умолчанию или подчёркивание, добавление пятого или десятого состояния компилируется чисто и молча проваливается в эту ветку вместо того, чтобы уронить сборку. Это самое полезное правило ревью по теме: в switch по sealed-типу не должно быть ветки по умолчанию, и это ловится линтером.
Да, но не как барьер. Проверка на полноту — это обратная связь, а не защита. Агент, которого попросили добавить новое состояние, обновит тот switch, что был у него в контексте, и пропустит те, которых не было в промпте. На флагах это уезжает молча, потому что дифф показывает только изменённое, а у нового состояния по определению нет тестов. На sealed-типе сборка падает, и компилятор называет каждое необработанное место с файлом и номером строки — ровно тот вход, с которым агент работает хорошо.
В четырёх местах. Они не чинят случай, который сам по себе — раздутое произведение с дюжиной nullable-полей. Они вредят там, где состояния реально пересекаются, например при обновлении списка, который уже на экране: там нужно булево поле внутри загруженного состояния, а не новый случай. Они ничего не говорят про инварианты на значениях вроде отсортированности, непустоты или корректного email, для которых нужен валидирующий конструктор, и начинаются только после парсинга, так что на границе системы, где бэкенд присылает статус с опечаткой, тоже бесполезны. И они превращаются в оверинжиниринг там, где хватило бы enum или честного булева поля.

Если нужен более широкий список того, что делают с Flutter-кодом агенты без присмотра, — это пункт номер три в нашем разборе из семи пунктов, а процесс, которым мы это предотвращаем, описан в статье как мы строим с ИИ. Эта статья — компаньон к нашему видеовыпуску на ту же тему; предыдущий был ваш SSL pinning, скорее всего, не работает.

Прогоните упражнение и расскажите, сколько строк вычеркнули и на каком экране. Наша ставка: большинство придёт ровно к той же тройке состояний, что и мы.


Илья Никсан — основатель и ведущий разработчик Nerdy Production, Flutter-first агентства, которое создаёт и поддерживает приложения в финтехе, медицине и ритейле. Мы также делаем аудит кода, написанного ИИ для команд, чья кодовая база выросла быстрее, чем её успевали ревьюить.