wandres.dev
SWIFT PARA TCA · value types y macros

Enums con valores asociados: acciones sin huecos

La Action de una feature es un tipo suma, y esa elección tiene consecuencias que van mucho más allá de la sintaxis. Esta lección modela las acciones como enums con valores asociados, explica por qué un switch sin default convierte al compilador en un revisor que jamás olvida un caso, muestra cómo anidar acciones de hijos y agruparlas por procedencia, e introduce los case paths que la macro Reducer habilita sobre el enum para que la composición, la navegación y el TestStore puedan apuntar a un caso concreto con la misma precisión con que un key path apunta a un campo.

⏱ 18 min

Si el State es el catálogo de todo lo que la feature puede saber, la Action es el catálogo de todo lo que le puede pasar. Y la palabra catálogo hay que tomarla al pie de la letra: en TCA una acción no es un mensaje suelto ni un String con un nombre, sino un caso de un enum cerrado, y esa clausura tiene un efecto que ninguna otra decisión de diseño consigue. Cuando el conjunto de sucesos posibles es finito y está declarado, el compilador puede recorrerlo entero y exigirte una respuesta para cada uno. Añadir un evento nuevo deja de ser un cambio silencioso que alguien tendrá que recordar propagar a mano y se convierte en una lista de errores de compilación que enumera, uno por uno, todos los sitios donde falta decidir qué hacer. Esta lección es sobre cómo se aprovecha esa maquinaria y sobre las formas de tirarla por la borda sin darte cuenta.

🎯 Al terminar esta lección sabrás
  • Modelar la Action como tipo suma con valores asociados que transporten exactamente el dato de cada suceso.
  • Escribir switch exhaustivos sin default y entender qué garantía se pierde en cuanto aparece uno.
  • Anidar acciones de features hijas y agrupar las propias por procedencia para que el enum no degenere en una lista plana.
  • Usar los key paths de caso que @Reducer habilita para apuntar a una acción concreta en composición, navegación y tests.

El enum como tipo suma: un caso por suceso

Un struct es un tipo producto: sus valores posibles son todas las combinaciones de sus campos, y por eso su cardinalidad es el producto de las cardinalidades de cada uno. Un enum es un tipo suma: sus valores posibles son los de un caso o los de otro, nunca los de dos a la vez, y su cardinalidad es la suma. El estado quiere ser un producto porque describe cosas simultáneas; la acción quiere ser una suma porque los sucesos son mutuamente excluyentes: en un instante dado pasó una cosa, no cuatro.

Los valores asociados completan el modelo. Cada caso lleva consigo el dato que ese suceso —y solo ese— necesita, de modo que no existen campos huérfanos que sobren la mitad de las veces.

@Reducer
struct Busqueda {
  enum Action {
    case consultaCambiada(String)
    case buscarPulsado
    case resultadosRecibidos(Result<[Item], any Error>)
    case resultadoSeleccionado(id: Item.ID)
    case borrarPulsado
  }
}

Compáralo con la alternativa que se ve en muchos sistemas de eventos: una struct con tipo: String y un diccionario de datos opcionales. Ahí resultadosRecibidos y buscarPulsado comparten forma, así que nada impide construir un evento de pulsación que traiga resultados o uno de recepción sin ellos; el compilador no puede ayudarte porque el tipo no distingue casos. Con el enum, esas combinaciones sencillamente no se pueden escribir: la ilegalidad se vuelve inexpresable, que es la formulación fuerte de hacer imposibles los estados inválidos.

switch exhaustivo: el compilador como revisor

La contrapartida de declarar el catálogo entero es que Swift puede comprobar que lo cubres. Un switch sobre un enum debe ser exhaustivo, y esa exigencia es el mecanismo central del nivel.

var body: some ReducerOf<Self> {
  Reduce { state, action in
    switch action {
    case let .consultaCambiada(texto):
      state.consulta = texto
      return .none
    case .buscarPulsado:
      state.cargando = true
      return .run { [consulta = state.consulta] send in
        await send(.resultadosRecibidos(Result { try await self.api.buscar(consulta) }))
      }
    case let .resultadosRecibidos(.success(items)):
      state.cargando = false
      state.items = items
      return .none
    case .resultadosRecibidos(.failure):
      state.cargando = false
      return .none
    case let .resultadoSeleccionado(id):
      state.seleccion = id
      return .none
    case .borrarPulsado:
      state.consulta = ""
      state.items = []
      return .none
    }
  }
}

Añade mañana un caso case refrescoSolicitado y el proyecto dejará de compilar en todos los switch que traten esas acciones, con el mensaje exacto de qué falta. El error no es una molestia: es una lista de tareas generada por la máquina, exhaustiva y sin olvidos, que sustituye a la revisión humana que sí olvida.

⚠️
default es la puerta trasera que anula la garantía

Escribir default: return .none compila hoy y sigue compilando mañana, cuando añadas un caso que ese reducer debería haber tratado. El silencio es el fallo: no hay error, no hay aviso, y el suceso nuevo se traga sin que nadie lo note hasta que un usuario reporta que un botón no hace nada. La regla práctica es tajante: en el switch de un reducer no se usa default. Si de verdad hay un grupo de acciones sin efecto, enuméralas por nombre —case .a, .b, .c: return .none— y así el próximo caso volverá a romper la compilación, que es justo lo que quieres.

Anidar y agrupar: enums dentro de enums

Un enum plano con cuarenta casos es tan poco navegable como una clase de mil líneas. La estructura llega por dos vías. La primera es la composición: la acción de un hijo se incrusta como un caso del padre, y el enum refleja el árbol de features tal cual.

enum Action {
  case fila(IdentifiedActionOf<Fila>)
  case destino(PresentationAction<Destino.Action>)
}

La segunda es la agrupación por procedencia, un patrón muy extendido en bases de código grandes: separar lo que hace el usuario, lo que devuelve el sistema y lo que esta feature quiere comunicar hacia arriba.

enum Action {
  case view(View)
  case interna(Interna)
  case delegate(Delegate)

  enum View: Equatable {
    case aparecio
    case guardarPulsado
  }
  enum Interna {
    case guardadoCompletado(Result<Void, any Error>)
  }
  enum Delegate: Equatable {
    case elementoGuardado(Item.ID)
  }
}

La ganancia no es cosmética. Con esta partición, la vista solo puede enviar casos de View —los demás quedan fuera de su alcance—, los efectos internos no se filtran a quien te incrusta, y el padre observa únicamente delegate, que es el contrato público de la feature. El enum deja de ser una lista y pasa a ser una interfaz con niveles de visibilidad expresados en el sistema de tipos.

Key paths de caso: apuntar a un caso como se apunta a un campo

Un key path como \State.consulta es una referencia de primera clase a un campo de un producto: sirve para leerlo y escribirlo sin saber de qué valor concreto se trata. Los tipos suma tienen un dual exacto: una referencia a un caso, que sirve para construirlo —envolver un valor asociado en su caso— y para extraerlo —recuperar el valor si el caso coincide, y nada si no—. La macro @Reducer marca el enum de acciones como @CasePathable, y con eso los casos se escriben con la misma sintaxis que los campos.

Scope(state: \.fila, action: \.fila) { Fila() }

await store.receive(\.resultadosRecibidos.success) {
  $0.items = [.demo]
}

Esta simetría es la que hace posible que la composición y los tests sean tipados. Scope recibe un key path de estado y uno de caso, y comprueba en compilación que ambos apuntan al dominio del mismo hijo; receive(\.resultadosRecibidos.success) afirma no solo que llegó esa acción, sino que traía un Result exitoso, encadenando dos niveles de caso. Sin key paths de caso tendrías que escribir a mano closures de construcción y extracción para cada punto de conexión, y ese es precisamente el andamiaje que la macro escribe por ti.

flowchart TD
A[Action de la feature] --> V[view lo que hace el usuario]
A --> I[interna respuestas del sistema]
A --> D[delegate contrato hacia el padre]
A --> H[hijo accion anidada de otra feature]
V --> SW[switch exhaustivo en el reducer]
I --> SW
D --> SW
H --> SC[Scope enruta al reducer hijo]
SW --> C[el compilador exige cubrir todos los casos]
style C fill:#a6e3a1,color:#11111b
style SC fill:#89b4fa,color:#11111b
El enum exhaustivo convierte una decisión de diseño en una obligación verificada

Merece la pena detenerse en la naturaleza del cambio que introduce el tipo suma, porque no es de grado sino de categoría. En cualquier arquitectura razonable existe la intención de tratar todos los eventos posibles; lo que distingue a TCA es que esa intención deja de depender de la memoria de quien programa. Un enum cerrado más un switch sin default transforman acordarse de manejar el caso nuevo en el proyecto no compila hasta que lo manejes, y esa mudanza —de la disciplina humana al sistema de tipos— es la misma jugada que la arquitectura repite en cada nivel: el TestStore no confía en que recuerdes afirmar cada cambio, lo exige; @Dependency no confía en que no llames a la red desde el reducer, hace que el cuerpo sea síncrono. La consecuencia práctica se aprecia el día que una feature madura recibe un requisito nuevo. En un sistema con eventos abiertos, añadir un evento es barato de escribir y caro de propagar: nada te dice dónde falta, y los huecos aparecen semanas después como comportamientos que simplemente no ocurren. Con el enum exhaustivo el coste se invierte y se adelanta: el compilador te presenta de golpe la lista completa de sitios afectados, y esa lista es demostrablemente completa, no una búsqueda de texto con suerte. Añade la agrupación por procedencia y el tipo empieza a documentar la feature entera: leyendo View, Interna y Delegate sabes qué puede hacer el usuario, qué puede devolver el mundo y qué promete la feature a quien la incruste, sin abrir el reducer. Y añade los key paths de caso y ese mismo tipo se vuelve además direccionable, de modo que componer y testear consiste en apuntar a un caso con la precisión con que apuntarías a un campo. Modelar bien la Action no es, por tanto, un ejercicio previo a la arquitectura: es la arquitectura, escrita en el único lenguaje que la máquina puede verificar.

⚔️ Reescribe una Action y hazla romper la compilación a propósito
  1. Toma una pantalla real y enumera en papel todo lo que puede pasarle. Escribe la Action como un enum donde cada caso lleve, en sus valores asociados, exactamente el dato de ese suceso y ni uno más.
  2. Implementa el switch del reducer sin un solo default. Si algún caso no hace nada todavía, enuméralo por nombre junto a los demás inertes.
  3. Añade ahora un caso nuevo, compila y anota cuántos sitios señala el compilador. Ese número es la deuda que un default te habría ocultado.
  4. Introduce un default deliberadamente, repite el paso anterior y comprueba que el proyecto compila sin decir nada. Bórralo y explica por escrito qué garantía acabas de recuperar.
  5. Reparte tus casos en View, Interna y Delegate. Comprueba que la vista solo puede enviar los primeros y que el padre solo observa los últimos; después escribe un receive en un test apuntando con un key path de caso a un valor asociado anidado.