ifCaseLet: cuando el hijo es un caso de un enum
Un opcional es un enum de dos casos, así que el operador que lo compone tiene por fuerza un hermano mayor: `ifCaseLet` corre un reducer hijo sobre el caso de un enum, y con él se modelan varios destinos mutuamente excluyentes sin que exista ninguna combinación imposible. Esta lección estudia la firma del operador y su dependencia de los case paths del Nivel 5, la mecánica del enrutado cuando el caso activo no coincide con la acción entrante, el orden forzado hijo antes que padre, la cancelación de efectos al cambiar de caso, y el diseño de máquinas de estados —sesión, carga, asistente por pasos— donde cada fase lleva consigo exactamente los datos que esa fase necesita y ninguno más.
Optional no es un tipo especial del lenguaje: es un enum corriente con dos casos, uno vacío y otro que carga un valor. Si esa observación es cierta —y lo es, hasta el punto de que puedes reimplementarlo en diez líneas—, entonces el operador ifLet de la lección anterior no puede ser más que un caso particular de algo más general: correr un reducer hijo sobre un caso concreto de un enum cualquiera. Ese algo más general existe y se llama ifCaseLet. Su valor no está en la generalidad por la generalidad, sino en lo que habilita: modelar varios destinos, fases o modos que se excluyen entre sí dentro de un único valor, de forma que la exclusión mutua no sea una promesa que el equipo se hace en una revisión de código, sino un hecho garantizado por el sistema de tipos.
- Reconocer
ifLetcomo el caso particular deifCaseLetsobre el casosomede un opcional. - Ensamblar
ifCaseLetcon un case path al caso portador de estado hijo y su acción correspondiente. - Predecir qué ocurre cuando llega una acción dirigida a un caso que ya no es el activo.
- Diseñar máquinas de estados por fases en las que cada caso carga solo los datos que esa fase necesita.
Un enum de estado y sus casos con dominio propio
Considera una feature cuya interfaz cambia por completo según la fase en que se encuentre: sesión de invitado o sesión autenticada. La modelación ingenua reparte campos opcionales por un struct plano y confía en la disciplina; la modelación honesta usa un enum donde cada caso carga el estado de la feature que le corresponde.
@Reducer
struct Sesion {
@ObservableState
enum State: Equatable {
case invitado(Invitado.State)
case autenticado(Autenticado.State)
}
enum Action {
case invitado(Invitado.Action)
case autenticado(Autenticado.Action)
case sesionIniciada(Usuario)
}
}
Lee con cuidado lo que ese enum afirma. No dice puede haber un invitado y puede haber un autenticado; dice que hay exactamente uno de los dos, siempre, y que los datos de uno son inaccesibles mientras el otro sea el caso activo. El Autenticado.State puede exigir un token no vacío y un identificador de usuario válido, y esa exigencia se sostiene sin esfuerzo porque solo existe cuando la sesión está autenticada. No hay un token nulo que alguien tenga que comprobar en veinte sitios: no hay token.
La firma de ifCaseLet y el enrutado por caso
El operador se aplica sobre el reducer padre, igual que ifLet y forEach, y toma un case path al caso del estado, un case path al caso de la acción y el reducer hijo. Los case paths son exactamente los que estudiaste en el Nivel 5: la macro @Reducer sobre el enum, junto con @CasePathable, los sintetiza para ti.
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case let .sesionIniciada(usuario):
state = .autenticado(Autenticado.State(usuario: usuario))
return .none
case .invitado, .autenticado:
return .none
}
}
.ifCaseLet(\.invitado, action: \.invitado) {
Invitado()
}
.ifCaseLet(\.autenticado, action: \.autenticado) {
Autenticado()
}
}
Cada ifCaseLet observa un caso y solo ese. Cuando entra una acción .invitado(...), el primer operador comprueba si el estado está en el caso invitado; si lo está, extrae el valor, corre Invitado() sobre él y vuelve a empaquetar el resultado en el enum. Si no lo está —porque el usuario ya se autenticó—, no muta nada y emite un aviso en tiempo de ejecución, con la misma lógica y por la misma razón que el aviso de ifLet. El segundo operador hace lo propio con su caso. Ambos corren antes que el Reduce del padre, siguiendo la regla invariable de la composición en TCA: el hijo primero, el padre después.
Exclusión por construcción
Un valor de enum está en un caso y solo en uno. Las combinaciones imposibles no se documentan: no existen.
Case path como selector
El case path indica qué caso mira el operador. Si el caso activo es otro, el operador se aparta sin mutar nada.
Cambiar de caso mata
Salir de un caso cancela los efectos en vuelo del hijo que vivía en él, igual que anular un opcional.
Datos por fase
Cada caso carga exactamente el estado que su fase necesita, con sus invariantes vivas solo mientras esa fase dura.
La relación entre los dos operadores es literal, no analógica. Optional es un enum con los casos none y some, y componer sobre un estado hijo opcional es componer sobre su caso some. Todo lo que aprendiste en la lección anterior —el orden hijo antes que padre, la cancelación al desaparecer, el aviso cuando la acción no encuentra dominio— se traslada sin cambios, porque es el mismo mecanismo con un case path distinto. Que TCA ofrezca ifLet con nombre propio se debe a la enorme frecuencia del caso y a la comodidad de @Presents, no a que sea una construcción diferente. Tenerlo claro te ahorra memorizar dos conjuntos de reglas donde solo hay uno.
Máquinas de estados: fases que llevan sus propios datos
El uso más productivo de ifCaseLet no es el interruptor de dos posiciones sino la máquina de estados por fases, donde la transición entre casos describe el avance de un proceso y cada caso arrastra el material que ese punto del proceso requiere.
@Reducer
struct Asistente {
@ObservableState
enum State: Equatable {
case datosPersonales(DatosPersonales.State)
case direccion(Direccion.State)
case pago(Pago.State)
case confirmacion(Confirmacion.State)
}
}
Fíjate en lo que este diseño hace imposible. No puedes estar en el paso de pago sin haber producido una dirección, porque el Pago.State se construye a partir de ella y no hay manera de fabricar el caso sin el valor. No puedes mostrar la confirmación con datos a medias, porque el Confirmacion.State exige el pedido completo. No puedes retroceder y encontrarte con residuos del paso siguiente, porque al cambiar de caso ese estado desaparece. Todas esas garantías, que en un modelo de campos opcionales se sostienen a base de comprobaciones dispersas, aquí las verifica el compilador antes de que ejecutes nada.
flowchart LR A[caso datos personales] -->|avanzar| B[caso direccion] B -->|avanzar| C[caso pago] C -->|avanzar| D[caso confirmacion] B -->|retroceder y morir el estado| A C -->|retroceder y morir el estado| B style D fill:#a6e3a1,color:#11111b
En la vista, el enum de estado se consume con un switch sobre el scope de cada caso, y la exhaustividad del compilador se convierte en una lista de verificación: si mañana añades una fase y olvidas dibujarla, el proyecto no compila.
struct AsistenteView: View {
@Bindable var store: StoreOf<Asistente>
var body: some View {
switch store.state {
case .datosPersonales:
if let store = store.scope(state: \.datosPersonales, action: \.datosPersonales) {
DatosPersonalesView(store: store)
}
case .direccion:
if let store = store.scope(state: \.direccion, action: \.direccion) {
DireccionView(store: store)
}
default:
ProgressView()
}
}
}
El precio de esta precisión es real y conviene nombrarlo. Un enum de estado exige switch exhaustivos en la vista, obliga a construir el estado del caso destino cada vez que se transita, y no admite la conservación accidental de datos entre fases: si quieres que la dirección sobreviva al retroceso, tienes que pasarla explícitamente al construir el caso anterior. Eso que parece incomodidad es en realidad el punto entero del diseño, porque convierte cada supervivencia de datos en una decisión visible en el código en lugar de un efecto colateral de que el campo siguiera allí.
En el test, la transición se afirma como lo que es: una sustitución de valor completa, sin mutaciones parciales que dejen dudas sobre qué quedó del paso anterior.
await store.send(.direccion(.continuarTocado)) {
$0 = .pago(Pago.State(direccion: direccionValida))
}
Detrás de ifCaseLet hay una tesis de teoría de tipos con consecuencias muy prácticas. Un struct es un producto: su número de estados posibles es la multiplicación de los estados de sus campos. Un enum es una suma: su número de estados posibles es la adición de los estados de sus casos. Cuatro fases modeladas con cuatro campos opcionales en un struct dan dieciséis combinaciones de presencia y ausencia, de las cuales exactamente cuatro son legítimas y doce son estados imposibles que sin embargo el tipo permite representar, que el compilador te obligará a manejar en cada if y que alguien acabará alcanzando en producción por un camino que nadie previó. Las mismas cuatro fases modeladas como enum dan cuatro estados y ni uno más. La reducción no es una optimización de memoria, es una amputación del espacio de errores: los doce estados que desaparecen son precisamente aquellos sobre los que se escriben los comentarios defensivos, las aserciones a medias y los informes de fallo irreproducibles. Y ifCaseLet es lo que impide que esa victoria en la modelación de datos se pierda al modelar el comportamiento, porque hace que la lógica también sea una suma: un reducer por caso, corriendo solo mientras su caso es el activo, con sus efectos naciendo y muriendo con él. Sin ese operador tendrías un enum de estado limpio gobernado por un reducer monolítico que vuelve a mezclarlo todo; con él, la exclusión mutua atraviesa las tres capas —datos, comportamiento y ciclo de vida de los efectos— y la app entera hereda la propiedad de que hay una fase y solo una.
- Escribe una feature de carga con el modelo ingenuo:
cargando: Bool,datos: [Item]?yerror: String?. Enumera por escrito las ocho combinaciones y marca cuáles son imposibles. - Reescríbela como enum de estado con los casos
inactivo,cargando,cargadoyfallido, cada uno con el estado hijo que necesite, y compón conifCaseLet. - Añade a la fase
cargandoun efecto con reloj inyectado que emita un latido. Transita afallidoy comprueba con unprintque el latido se detiene sin que hayas escrito una sola línea de cancelación. - Envía a propósito una acción del caso
cargandocuando el estado ya esté encargadoy observa el aviso en tiempo de ejecución. Razona qué fallo real estaría delatando. - Justifica en un párrafo por qué
ifLetyifCaseLetson el mismo operador, y escribe la versión de tu feature de carga que sustituye el casoinactivopor un opcional del resto del enum.