¿Cómo modelás el estado de la UI con sealed classes/interfaces en vez de un montón de booleanos?
Un montón de booleanos deja que tu UI llegue a estados imposibles — las sealed classes hacen que el compilador decida cuáles pueden existir.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
¿Cómo modelás el estado de la UI con sealed classes/interfaces en vez de un montón de booleanos?
Usa los ejemplos y la explicación de la lección para comprobar tu idea.
Reflexión personal. No se envía a ningún servicio de IA.Pedile a diez ingenieros Android que modelen una pantalla que carga datos, y una buena parte va a llegar a los mismos cuatro campos: isLoading: Boolean, isError: Boolean, errorMessage: String?, y data: List<Item>?. Compila, se puede shippear, y está mal en silencio — esos cuatro campos describen 2⁴ = 16 combinaciones posibles, y la pantalla sólo tiene tres estados legítimos. Nada en el sistema de tipos impide que isLoading e isError sean true al mismo tiempo, o que data sea no-nulo mientras isLoading también es true, y cada bloque when o cadena de if que renderiza ese estado ahora tiene que adivinar defensivamente cuál combinación es la real.
#Hacer que los estados ilegales no se puedan representar
La solución no es agregar más booleanos o más chequeos defensivos — es un tipo que sólo se puede construir de una manera que ya es válida. Una sealed interface (o sealed class) declara un conjunto cerrado y finito de subtipos, conocido por completo en tiempo de compilación, y el compilador de Kotlin usa ese cierre para exigir un when exhaustivo — sin rama else, y un error de compilación en el momento en que aparece una nueva variante en algún lugar que no la manejó.
sealed interface CatalogUiState {data object Loading : CatalogUiStatedata class Success(val items: List<Product>,val isRefreshing: Boolean = false,) : CatalogUiStatedata class Error(val message: String) : CatalogUiState}
Cada rama acá es mutuamente excluyente por construcción: no hay forma de tener items y a la vez estar en Loading, porque items sólo existe en Success. Loading está declarado como data object — la mejora de Kotlin 1.9 sobre un object a secas para singletons, que le da gratis un toString() razonable e igualdad por valor, algo que importa en cuanto este estado empieza a circular por un StateFlow y por aserciones de test.
#Conectándolo en un ViewModel
class CatalogViewModel(private val repo: CatalogRepository) : ViewModel() {private val _uiState = MutableStateFlow<CatalogUiState>(CatalogUiState.Loading)val uiState: StateFlow<CatalogUiState> = _uiState.asStateFlow()init { load() }fun load() {viewModelScope.launch {_uiState.value = CatalogUiState.LoadingrunCatching { repo.fetchProducts() }.onSuccess { products -> _uiState.value = CatalogUiState.Success(products) }.onFailure { e -> _uiState.value = CatalogUiState.Error(e.message ?: "Error desconocido") }}}fun refresh() {val current = _uiState.valueif (current !is CatalogUiState.Success) returnviewModelScope.launch {_uiState.value = current.copy(isRefreshing = true)val products = repo.fetchProducts()_uiState.value = CatalogUiState.Success(products, isRefreshing = false)}}}
Fijate que refresh() primero verifica que current sea Success antes de hacer nada — el pull-to-refresh sólo tiene sentido si ya hay datos en pantalla, y el sistema de tipos lo garantiza simplemente no dándote un campo isRefreshing en ningún otro lado. Ése es el bug del montón de booleanos del principio, cerrado por construcción en vez de por una guarda en runtime que alguien tiene que acordarse de escribir.
#Consumiéndolo de forma exhaustiva en Compose
@Composablefun CatalogScreen(viewModel: CatalogViewModel = viewModel()) {val uiState by viewModel.uiState.collectAsStateWithLifecycle()when (val state = uiState) {is CatalogUiState.Loading -> LoadingSpinner()is CatalogUiState.Success -> ProductGrid(items = state.items,isRefreshing = state.isRefreshing,onRefresh = viewModel::refresh,)is CatalogUiState.Error -> ErrorBanner(state.message, onRetry = viewModel::load)}}
Este when no tiene else y no lo necesita — el compilador ya sabe que esas tres ramas son las únicas que pueden existir. Agregá un cuarto estado, digamos Empty, al sealed interface el sprint que viene, y cada when sobre CatalogUiState en el código se pone en rojo hasta que se maneje. Con la versión de booleanos, el cambio equivalente es un no-op silencioso: una bandera nueva que nadie se acordó de chequear en ningún lado.
#Por qué ésta es la respuesta que vale la pena dar
Una respuesta junior a esta pregunta es "usá una sealed class en vez de booleanos". Una respuesta senior explica por qué: los booleanos modelan el estado como un producto sin restricciones de banderas independientes, mientras que una jerarquía sealed lo modela como una suma restringida de casos mutuamente excluyentes — y esa diferencia es exactamente la brecha entre estados que compilan pero no deberían existir, y estados que directamente no pueden existir. También es lo que hace seguros los refactors meses después, porque es el compilador — no un comentario viejo ni un TODO olvidado — el que garantiza que cada consumidor se mantenga al día.