Введение: почему без sealed class не обходится ни один проект

«Опишите UiState в вашем проекте» — вопрос, который в современных командах встречается в каждом втором собеседовании. И кандидат, который отвечает «ну, у нас enum со статусами», почти всегда проигрывает тому, кто рассказал про sealed class с Loading, Success и Error.

Sealed class (запечатанный класс) — это класс с ограниченным, контролируемым наследованием: все его прямые подклассы известны на этапе компиляции. Других появиться не может — компилятор просто не даст объявить их за пределами разрешённой области. Именно это свойство делает sealed class незаменимым для UI-состояний, ошибок, сетевых ответов и любых «закрытых» иерархий.

На собеседовании тему любят по двум причинам: она проверяет понимание модели наследования Kotlin и умение проектировать типы. Разберём по порядку.

Зачем нужен sealed class

Сначала главный вопрос: чем sealed class отличается от обычного класса? Обычный open класс может иметь наследников где угодно — в другом модуле, в сторонней библиотеке, в коде клиента. Вы не знаете полный набор подклассов, а значит, не можете обработать их все.

Sealed class решает эту проблему. Пример из документации Kotlin: библиотека объявляет иерархию ошибок. Если классы ошибок открытые, пользователи библиотеки могут добавить свои — и библиотека не сможет обработать то, чего не знает. С sealed иерархией автор уверен: все возможные типы ошибок у меня в руках, новых не появится.

Error.kt
sealed interface Error

sealed class IOError : Error
class FileReadError(val file: String) : IOError()
class DatabaseError(val source: String) : IOError()
object RuntimeError : Error

Здесь Error — sealed interface, IOError — sealed class, а FileReadError, DatabaseError и RuntimeError — его подклассы. Вся иерархия замкнута.

Sealed ≠ enum

Частая путаница: «sealed class — это как enum?». Нет. Константы enum существуют в единственном экземпляре и не хранят состояние. А подклассы sealed class могут иметь свои свойства и создаваться в любом количестве: FileReadError("a.txt") и FileReadError("b.txt") — два разных экземпляра с разными данными.

Ограничения наследования

Теперь правила — их спрашивают дословно:

  • Прямые подклассы должны находиться в том же пакете и в том же модуле, где объявлен sealed class. За пределами модуля новые подклассы создать нельзя.
  • Sealed class всегда абстрактный — его нельзя инстанцировать напрямую, только подклассы.
  • Конструктор sealed class — protected по умолчанию или private. Публичным или internal он быть не может.
  • Подклассы могут быть top-level или вложенными в другие классы, но не могут быть локальными или анонимными объектами.
  • enum классы не могут наследовать sealed class, но могут реализовывать sealed interface.
SealedRules.kt
sealed class Result {
    data class Success(val data: String) : Result()
    data class Error(val message: String) : Result()
    object Empty : Result()
}

// Не скомпилируется:
// class AnotherError : Result() // если класс в другом пакете

Нюанс про косвенных наследников: если прямой подкласс не помечен как sealed, его могут наследовать где угодно — ограничение действует только на прямой уровень иерархии. Это хорошая деталь, которая показывает понимание, а не заучивание.

И отдельный вопрос, который любят задавать: «sealed interface или sealed class?». Короткий ответ: если подклассам не нужны общие поля в конструкторе — берите sealed interface, он позволяет подклассам наследовать ещё что-то (множественное наследование интерфейсов). Если же у всех подклассов есть общее состояние, например message: String, удобнее sealed class с этим полем в конструкторе — оно перейдёт в каждый подкласс автоматически. Универсального «правильного» выбора нет, но аргументировать свой — обязательное требование на middle-собеседовании.

Исчерпывающий when

Ключевое преимущество sealed class раскрывается в паре с when. Если ветви покрывают все подклассы, компилятор считает выражение исчерпывающим, и else не нужен. А если вы добавите новый подкласс — компилятор укажет на все места, где when стал неполным. Ошибки ловятся на этапе компиляции, а не в проде.

Renderer.kt
sealed interface UiState {
    data object Loading : UiState
    data class Success(val items: List<String>) : UiState
    data class Error(val message: String) : UiState
}

fun render(state: UiState) = when (state) {
    is UiState.Loading -> showSpinner()
    is UiState.Success -> showList(state.items)
    is UiState.Error -> showError(state.message)
}

Обратите внимание: ветка для Loading — это is UiState.Loading без скобок, потому что это объект-синглтон, а для Success и Error — с умным приведением, которое даёт доступ к state.items и state.message.

Типичная ошибка: кандидат пишет else -> «на всякий случай». Это не просто лишняя строка — это сигнал, что вы не доверяете компилятору и не понимаете исчерпываемость. Без else добавление нового подкласса сломает сборку в каждом when, и вы узнаете о проблеме до релиза. С else новый подкласс тихо упадёт в ветку по умолчанию — и баг уедет в прод.

UI-состояния и ошибки кандидатов

Главный сценарий в Android-проектах — модель экрана. Вместо нескольких булевых флагов (isLoading, isError) используется один тип:

ProfileScreen.kt
sealed interface ProfileState {
    data object Loading : ProfileState
    data class Content(val user: User, val posts: List<Post>) : ProfileState
    data class Error(val message: String, val retry: () -> Unit) : ProfileState
}

Такой подход заставляет обрабатывать все состояния явно: в when просто не остаётся места для «забыл про ошибку». И это то, что интервьюеры хотят услышать, когда спрашивают про архитектуру UI-слоя.

Теперь соберу типичные промахи кандидатов:

  • «Sealed class — это enum с данными» — неверно: у подклассов могут быть свои свойства и множество экземпляров.
  • «Подклассы можно объявлять где угодно» — нет, только в том же пакете и модуле.
  • «Sealed class можно инстанцировать» — нет, он абстрактный.
  • «В when с sealed нужен else» — не нужен, если ветви покрывают все подклассы.
  • «Для синглтонов нужны обычные object, а не data object» — data object даёт нормальные toString/equals для объектов-состояний.

Иерархия

Одна замкнутая иерархия вместо набора флагов — то, что даёт sealed class в реальном проекте: компилятор проверяет полноту обработки, а новый подкласс сразу показывает все места, где его забыли обработать.

Готовитесь к собеседованию на Android-разработчика?

Уверенная работа с sealed class, UI-состояниями и архитектурой — это уровень middle. На менторстве мы закрываем такие пробелы и тренируем ответы на реальных собеседованиях.

Итоги

Sealed class — один из главных инструментов моделирования ограниченных иерархий в Kotlin, и без него не обходится ни один современный Android-проект. Три тезиса в копилку:

  1. Все прямые подклассы известны компилятору и должны жить в том же пакете и модуле — за пределами иерархия не расширяется.
  2. when с sealed class — исчерпывающий: без else, а новый подкласс ломает сборку во всех незакрытых местах.
  3. Классика применения — UI-состояния: один тип UiState вместо набора булевых флагов.

Подробности и примеры — в официальной документации Sealed classes and interfaces на kotlinlang.org, а про выражение when — в Control flow.

Главное, что нужно запомнить

Sealed class — это контролируемое наследование: подклассы только в своём пакете и модуле, when проверяется компилятором на полноту. Отвечаете так — тема закрыта, и интервьюер идёт дальше.

Если на пробном ответе вы поняли, что плаваете не только в sealed class, но и в архитектуре в целом — не нужно готовиться в одиночку наугад. Напишите в Telegram, разберём вашу ситуацию и составим план.