wandres.dev
OPCIONALES Y ENUMS · ifLet e ifCaseLet

Diseñar destinos: cuándo un opcional, cuándo un enum

Las piezas del nivel ya están sobre la mesa; falta el criterio para colocarlas. Esta lección convierte la modelación de la navegación en una decisión razonada con aritmética explícita: por qué n banderas independientes producen 2 elevado a n estados de los que casi todos son ilegítimos, por qué un opcional de enum produce una suma en la que ninguna combinación imposible es expresable, y cómo esa diferencia elimina de raíz el fallo de las dos hojas simultáneas. Recorremos los tres arquetipos —un destino, varios excluyentes, y presentaciones deliberadamente apiladas—, el criterio para separar destinos en enums distintos, la frontera con la navegación en pila, y el hábito de leer cualquier bug de navegación como un síntoma de sobre-representación.

⏱ 20 min

Todo lo anterior de este nivel era mecánica: qué operador existe, qué genera la macro, cuándo mueren los efectos. Queda la parte que no se resuelve conociendo la API, porque es una decisión de modelación que se toma antes de escribir la primera línea: qué forma debe tener el estado de navegación de esta pantalla. La respuesta correcta casi nunca es cuestión de gusto y casi siempre se deduce contando. Contar cuántas situaciones puede representar el tipo que estás a punto de escribir, contar cuántas de ellas son legítimas, y comprobar si la diferencia entre ambos números es cero. Cuando esa diferencia es cero, familias enteras de fallos —la hoja que aparece sobre la hoja, la alerta que se traga, el retroceso que revela datos de otro paso— no se corrigen: dejan de ser expresables. Este es el criterio, con su aritmética y sus excepciones legítimas.

🎯 Al terminar esta lección sabrás
  • Cuantificar la sobre-representación de un modelo de navegación comparando estados posibles con estados legítimos.
  • Elegir entre opcional simple, opcional de enum y opcionales separados según la exclusión que quieras garantizar.
  • Justificar cuándo conviene partir los destinos en más de un enum y cuándo eso delata una feature demasiado grande.
  • Diagnosticar cualquier fallo de navegación como síntoma de un tipo que representa más estados de los debidos.

La aritmética: producto contra suma

Empieza con el modelo que casi todos escriben primero, porque es el que sugiere la API imperativa: una bandera por destino.

struct State {
  var mostrandoDetalle = false
  var mostrandoEdicion = false
  var mostrandoCompartir = false
  var alerta: String?
}

Cuenta. Tres booleanos dan ocho combinaciones; multiplicadas por la presencia o ausencia de la alerta, dieciséis situaciones representables. ¿Cuántas son legítimas? Cuatro, si aceptas que la alerta puede montarse sobre otra pantalla; una si exiges exclusión estricta. Las doce o quince restantes son estados imposibles que el tipo permite escribir, que el compilador te obligará a manejar en cada rama y que alguien alcanzará en producción por un camino que nadie previó. El fallo de las dos hojas simultáneas no es un descuido de quien programó la vista: está permitido por el tipo, y lo que un tipo permite acaba ocurriendo.

Ahora el modelo del patrón canónico.

@Presents var destination: Destination.State?

Cuenta otra vez. Los estados representables son uno —nil— más la suma de los estados de cada destino. No hay producto, no hay combinaciones cruzadas, no hay una sola situación expresable que no sea legítima. La diferencia entre ambos números es cero, y con ella desaparece la categoría entera de fallos. Esta es la operación intelectual del nivel: sustituir un producto por una suma.

Merece la pena hacer explícita la fórmula, porque el hábito de aplicarla vale más que cualquier recomendación. Con n destinos modelados como banderas independientes, los estados de navegación son dos elevado a n, de los cuales n más uno son legítimos si exiges exclusión estricta; la sobre-representación crece exponencialmente y a los cinco destinos ya arrastras veintiséis situaciones que nunca deberían darse. Con un opcional de enum, los estados son n más uno y la sobre-representación es exactamente cero, sea cual sea n. No hay un punto a partir del cual el modelo ingenuo empiece a fallar: falla desde el segundo destino, solo que con dos el equipo todavía se acuerda de la regla no escrita.

Modelo Estados de navegación representables Ilegítimos
Tres banderas independientes 8 combinaciones 7 de 8 si exiges exclusión
Tres opcionales de estado hijo producto de las tres presencias mismos 7, con datos rancios añadidos
Un opcional de enum de tres casos 4 situaciones ninguna

La segunda fila de la tabla es la más instructiva porque es el punto medio en el que mucha gente se detiene, convencida de haber resuelto el problema. Sustituir las banderas por opcionales de estado hijo es una mejora real —desaparece la desincronización entre bandera y contenido, y con ella los datos rancios de la sesión anterior—, pero deja intacta la estructura de producto: tres opcionales siguen permitiendo las mismas siete combinaciones ilegítimas de presencia simultánea. La ganancia es de higiene, no de expresividad. Solo el paso a la suma cierra la categoría, y por eso el patrón canónico no es un opcional por destino sino un opcional del enum de destinos.

Los tres arquetipos y su criterio

Con la aritmética clara, la decisión se reduce a tres formas y a una pregunta por cada una.

Antes de aplicarla, una advertencia sobre el orden de las preguntas: la primera no es qué operador uso, sino qué situaciones quiero que sean posibles. Elegir el tipo es responder a la segunda; el operador viene detrás y por sí solo.

Un opcional simple, sin enum. La feature tiene exactamente una salida y no prevés más. @Presents var detalle: Detalle.State? es honesto y más corto que envolverlo en un enum de un caso. El riesgo es de futuro: el día que aparezca la segunda salida, la tentación será añadir un segundo opcional en lugar de refactorizar hacia el enum, y ahí empieza el producto. Si sospechas que habrá una segunda, empieza ya con el enum.

Un opcional de enum. Varias salidas que se excluyen entre sí. Es el caso por defecto, el que cubre la lección anterior, y el que deberías elegir siempre que no puedas argumentar lo contrario. La pregunta de control es directa: si dos de estos destinos aparecieran a la vez, ¿sería un fallo? Si la respuesta es sí, van en el mismo enum.

Conviene notar que el enum no distingue por forma de presentación. Es un error frecuente agrupar solo las hojas y dejar fuera la alerta porque se muestra con otro modificador: el criterio no es cómo se dibuja el destino sino si compite por el mismo hueco de atención del usuario. Una alerta de confirmación de borrado y una hoja de edición se excluyen tanto como dos hojas, y por eso conviven como casos del mismo enum aunque en la vista los recojan alert y sheet respectivamente. Del mismo modo, un destino empujado con navigationDestination pertenece al enum si aparecer junto a una hoja sería un error, pese a que técnicamente el sistema lo permitiría.

Opcionales separados, a propósito. Existen presentaciones que deben poder apilarse, y negarlo sería modelar mal en la otra dirección. Una alerta de error de red que aparece sobre la hoja de edición no es un fallo: es el comportamiento deseado. En ese caso, dos campos separados —el enum de destinos y un @Presents para la alerta— expresan la verdad mejor que forzarlo todo en un enum. La regla es que la separación sea una afirmación deliberada de concurrencia, no el resultado de no haberlo pensado.

Las tres formas, escritas una junto a otra, dejan ver que la elección no es de estilo sino de significado: cada declaración afirma algo distinto sobre lo que puede ocurrir a la vez.

// Una sola salida: el opcional basta y no oculta nada.
@Presents var detalle: Detalle.State?

// Varias salidas excluyentes: el enum garantiza que solo hay una.
@Presents var destination: Destination.State?

// Concurrencia deliberada: dos ejes que sí pueden coexistir.
@Presents var destination: Destination.State?
@Presents var errorDeRed: AlertState<Action.Alerta>?

El tercer bloque merece una advertencia, porque es el que más se abusa. Cada campo adicional reintroduce un factor en el producto: dos opcionales dan cuatro situaciones de presencia, tres dan ocho, y a partir de ahí vuelves al punto de partida del que huías. La separación es legítima cuando puedes nombrar el escenario concurrente y defenderlo ante alguien de producto; deja de serlo en cuanto la justificación es que resultaba más cómodo añadir un campo que un caso.

1️⃣

Un opcional simple

Una sola salida. Corto y honesto, con el riesgo de invitar a un segundo opcional el día que aparezca la segunda.

🔀

Un opcional de enum

Varias salidas excluyentes. El caso por defecto. Si verlas juntas sería un fallo, van en el mismo enum.

🧱

Opcionales separados

Presentaciones que deben apilarse, como una alerta sobre una hoja. Legítimo si es una decisión declarada.

📚

Pila en lugar de árbol

Si el usuario acumula pantallas hacia dentro con retroceso, el modelo no es un destino sino StackState.

💡
La pregunta que resuelve el noventa por ciento de los casos

Antes de escribir el estado de navegación, formula en voz alta esta frase y complétala: si el usuario viera a la vez X e Y, sería…. Si la respuesta es un fallo, X e Y son casos del mismo enum y acabas de eliminar por construcción la posibilidad de ese fallo. Si la respuesta es lo esperado, son campos separados y acabas de documentar una decisión de producto en el tipo. Si la respuesta es no lo sé, no tienes todavía un problema de arquitectura sino de requisitos, y ninguna elección de modelación te va a salvar de averiguarlo. La virtud del método es que fuerza a decidir explícitamente algo que en el estilo imperativo nunca llega a plantearse, porque presentar es dar una orden y las órdenes no se preguntan si son compatibles entre sí.

Cuándo partir en varios enums y dónde acaba el árbol

Un solo enum Destination cubre la inmensa mayoría de las pantallas, pero hay dos señales que piden otra cosa. La primera es la aparición de dos ejes de navegación genuinamente independientes: destinos modales por un lado y, por otro, una alerta o un confirmationDialog que puede coronar cualquiera de ellos. Ahí dos campos son la modelación fiel. La segunda señal es más importante y más incómoda: cuando el enum crece hasta ocho o diez casos, el problema rara vez es el enum. Un número alto de salidas es el síntoma clásico de una pantalla que hace demasiadas cosas, y la refactorización correcta no es partir el enum sino partir la feature, dejando que cada una lleve el suyo, corto y legible.

Conviene además recordar que el enum no tiene por qué ser plano. Un destino puede llevar dentro una feature que a su vez declara su propio Destination, y esa anidación es la forma natural de describir un flujo modal que continúa hacia dentro sin convertirse en una pila. El árbol resultante es exactamente la topología de navegación de la app, expresada en tipos, y se puede recorrer con un case path encadenado desde la raíz hasta la hoja.

// El destino de un padre puede contener una feature con destinos propios.
store.state.destination?.edicion?.destination?.confirmarSalida

Que esa expresión compile es la prueba de que la navegación se ha vuelto un dato consultable de punta a punta: un deep link es construir ese valor, una restauración es decodificarlo, y una prueba de flujo es afirmarlo paso a paso.

Hay además una tercera señal, menos vistosa pero muy fiable: si al leer los casos de un enum encuentras dos que nunca se alcanzan desde la misma condición del padre —uno solo aparece cuando hay sesión y el otro solo cuando no la hay—, es probable que estés modelando dos pantallas distintas bajo un mismo nombre. La exclusión mutua que garantiza el enum es cierta pero ociosa, porque esos dos casos ya eran incompatibles por otra razón, y agruparlos oculta la separación real que pedía el diseño.

Queda por trazar la otra frontera, la que separa este nivel del siguiente. Todo lo visto aquí modela navegación en árbol: un padre presenta a lo sumo un hijo, ese hijo puede a su vez presentar el suyo, y la profundidad está fijada por los tipos. Cuando el usuario acumula pantallas del mismo tipo hacia dentro —carpeta dentro de carpeta, respuesta a la respuesta— y espera un retroceso paso a paso, la forma correcta no es un opcional de enum sino StackState, con su propia colección de elementos identificados. El síntoma inequívoco de estar forzando el modelo equivocado es un destino que se contiene a sí mismo, o una profundidad que crece con los datos en lugar de con el código.

flowchart TD
A[una pieza de navegacion] --> B{el usuario acumula pantallas con retroceso}
B -->|si| S[StackState y navegacion en pila]
B -->|no| C{cuantas salidas tiene la pantalla}
C -->|una sola| U[opcional simple del estado hijo]
C -->|varias| D{verlas a la vez seria un fallo}
D -->|si| E[un opcional de un enum Destination]
D -->|no| F[campos separados como decision declarada]
style E fill:#a6e3a1,color:#11111b
style S fill:#89b4fa,color:#11111b
Los bugs de navegación no se corrigen: se vuelven inexpresables

Hay dos maneras de acabar con un fallo. La primera es la habitual: reproducirlo, encontrar la rama que lo produce, añadir la comprobación que faltaba y una prueba de regresión que vigile esa rama en particular. Funciona, y deja intacto el terreno que lo hizo posible, de modo que el fallo volverá bajo otro disfraz por otro camino, y cada nueva comprobación defensiva hará el código un poco más difícil de razonar. La segunda manera es la de este nivel: cambiar el tipo para que la situación defectuosa no se pueda escribir. Cuando el estado de navegación es un opcional de enum, la pregunta cómo evitamos que se abran dos hojas a la vez no tiene respuesta porque no tiene sentido: no hay un valor que exprese dos hojas abiertas, no hay una secuencia de acciones que lo produzca, no hay un test que haga falta escribir para vigilarlo. El fallo no está corregido; está fuera del universo de lo decible. Esa es la diferencia entre programar defensivamente y modelar con precisión, y explica por qué el trabajo de diseño de tipos rinde de forma tan desproporcionada: cada estado ilegítimo que eliminas del tipo se lleva por delante todos los fallos presentes y futuros que dependían de él, incluidos los que nadie ha imaginado todavía. Vale la pena invertir el hábito de diagnóstico entero: ante un comportamiento raro de navegación, en vez de preguntar qué línea lo causó, pregunta qué estado ilegítimo hizo falta alcanzar para que ocurriera, y por qué tu tipo permitía representarlo. La respuesta a esa segunda pregunta casi siempre es un producto donde debía haber una suma, y arreglarla no repara un fallo: cierra una categoría.

⚔️ Cuenta estados y elimina categorías de fallos
  1. Toma una pantalla real de tu app y escribe su estado de navegación tal como está hoy. Cuenta las situaciones representables y márcalas una a una como legítima o ilegítima.
  2. Rediseña el estado hasta que la diferencia entre ambos números sea cero. Anota qué comprobaciones defensivas del código actual quedan sin sentido tras el rediseño.
  3. Identifica una presentación que deba poder apilarse sobre otra y sepárala en su propio campo, dejando escrito en un comentario por qué esa concurrencia es deliberada.
  4. Busca en tu proyecto un enum de destinos con más de siete casos. Propón la partición de la feature que lo reduciría, sin partir el enum.
  5. Localiza un flujo con profundidad variable y reescríbelo mentalmente con StackState. Explica qué síntoma te habría avisado antes de que el árbol no era la forma correcta.