wandres.dev
OPCIONALES Y ENUMS · ifLet e ifCaseLet

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.

⏱ 19 min

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.

🎯 Al terminar esta lección sabrás
  • Reconocer ifLet como el caso particular de ifCaseLet sobre el caso some de un opcional.
  • Ensamblar ifCaseLet con 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.

💡
ifLet es ifCaseLet mirando el caso some

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))
}
La suma frente al producto: por qué un enum tiene menos estados que un struct

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.

⚔️ Convierte un struct de banderas en una máquina de estados
  1. Escribe una feature de carga con el modelo ingenuo: cargando: Bool, datos: [Item]? y error: String?. Enumera por escrito las ocho combinaciones y marca cuáles son imposibles.
  2. Reescríbela como enum de estado con los casos inactivo, cargando, cargado y fallido, cada uno con el estado hijo que necesite, y compón con ifCaseLet.
  3. Añade a la fase cargando un efecto con reloj inyectado que emita un latido. Transita a fallido y comprueba con un print que el latido se detiene sin que hayas escrito una sola línea de cancelación.
  4. Envía a propósito una acción del caso cargando cuando el estado ya esté en cargado y observa el aviso en tiempo de ejecución. Razona qué fallo real estaría delatando.
  5. Justifica en un párrafo por qué ifLet y ifCaseLet son el mismo operador, y escribe la versión de tu feature de carga que sustituye el caso inactivo por un opcional del resto del enum.