wandres.dev
LA MACRO @REDUCER · el boilerplate desaparece

Case paths y key paths: navegar tipos con seguridad

Un key path apunta a un campo dentro de un struct; un case path apunta a un caso dentro de un enum. Swift trae los primeros de fábrica y no trae los segundos, y sin ellos TCA no podría enrutar acciones hacia features hijas de forma segura. Esta lección explica la dualidad entre producto y suma que hay debajo, muestra qué sintetiza la macro CasePathable sobre un enum de acciones, unifica ambas nociones bajo la misma sintaxis de barra invertida y punto, y recorre los operadores de TCA que las consumen: Scope, ifLet, forEach y el scope del propio Store.

⏱ 19 min

Swift te deja escribir \.nombre para referirte al campo de una estructura sin acceder a ninguna instancia: es un valor de primera clase que representa el acto de llegar a ese campo. Lo que Swift no te da es el gesto simétrico para enumeraciones, apuntar a un caso concreto sin construirlo ni desestructurarlo. Esa ausencia parece una curiosidad de lenguaje hasta que intentas escribir una arquitectura donde el padre debe enrutar acciones al hijo: sin una forma de nombrar un caso, o escribes ese enrutamiento a mano en cada composición, o renuncias a la seguridad de tipos. Los case paths de Point-Free llenan exactamente ese hueco, y la macro @CasePathable los sintetiza por ti.

🎯 Al terminar esta lección sabrás
  • Leer un key path como una función de acceso y escritura sobre un campo de un tipo producto.
  • Entender el case path como su dual sobre un tipo suma y qué sintetiza @CasePathable.
  • Usar la sintaxis unificada de barra invertida y punto para ambos, y saber cuándo aparece el símbolo del dólar.
  • Identificar los operadores de TCA que consumen key paths y case paths para enrutar dominios.

Producto y suma: dos formas de contener

Un struct es un tipo producto: contiene un valor de cada uno de sus campos, todos a la vez. Un enum es un tipo suma: contiene un valor de exactamente uno de sus casos, nunca de dos. Esa asimetría se refleja en cómo se apunta hacia dentro de cada uno. En el producto siempre hay algo en el campo al que apuntas, así que un key path puede leer y escribir sin condiciones. En la suma puede que el valor no esté en el caso al que apuntas, así que la extracción es opcional y la construcción es total.

struct Usuario {
  var nombre: String
  var edad: Int
}

let alNombre = \Usuario.nombre     // WritableKeyPath: leer y escribir siempre

@CasePathable
enum Resultado {
  case exito(String)
  case fallo(any Error)
}

// El case path: extraer puede fallar, construir nunca
let aExito = \Resultado.Cases.exito

Un case path es, formalmente, un par de funciones: una que construye el todo a partir de la parte, y otra que intenta extraer la parte del todo devolviendo un opcional. Esa pareja es exactamente la dual del par de funciones que define un key path escribible: una que lee la parte del todo y otra que la escribe. Producto y suma, lectura total y extracción parcial: la simetría es completa y no es casual, es teoría de tipos aplicada a la ergonomía.

Lo que sintetiza CasePathable

La macro @CasePathable genera, dentro del enum, un tipo anidado Cases con una propiedad estática por cada caso, y hace el enum conforme a dynamicMemberLookup para que \.exito resuelva a esa propiedad. El efecto práctico es que la sintaxis de key path funciona sobre enums sin que tengas que aprender una segunda notación.

@CasePathable
enum Accion {
  case incrementar
  case texto(String)
  case hijo(Hijo.Action)
}

let a: Accion = .texto("hola")

// Extraer: devuelve un opcional
if let s = a.texto { print(s) }

// Comprobar sin desestructurar
if a.is(\.texto) { print("es texto") }

// Construir mediante el case path
let b = Accion.texto("adios")

En TCA nunca escribes @CasePathable sobre el enum de acciones porque la macro @Reducer ya lo hace, como vimos en la primera lección de este bloque. Sí lo escribirás a mano sobre enums propios que no sean acciones de una feature: un enum de estado modelado como suma, por ejemplo, o un enum de errores del dominio sobre el que quieras apuntar.

ℹ️
Cuándo aparece el símbolo del dólar

Verás dos formas de key path en TCA. \.hijo apunta al campo tal cual; \.$hijo apunta a su proyección, la que expone un envoltorio de propiedad como @Presents o @Shared. La regla es directa: usa la forma con dólar cuando el operador necesita la maquinaria del envoltorio, típicamente ifLet sobre un estado presentado, y la forma sin dólar cuando el operador solo necesita el valor.

Dónde los consume TCA

Los case paths no son un adorno teórico: son el mecanismo de enrutamiento de toda la composición. Cada operador que conecta un padre con un hijo pide dos coordenadas, una en el estado y otra en la acción, y esas coordenadas son un key path y un case path.

@Reducer
struct AppFeature {
  @ObservableState
  struct State: Equatable {
    var contador = Contador.State()
    @Presents var hoja: Hoja.State?
    var filas: IdentifiedArrayOf<Fila.State> = []
  }

  enum Action {
    case contador(Contador.Action)
    case hoja(PresentationAction<Hoja.Action>)
    case filas(IdentifiedActionOf<Fila>)
  }

  var body: some ReducerOf<Self> {
    Scope(state: \.contador, action: \.contador) {
      Contador()
    }
    Reduce { state, action in
      return .none
    }
    .ifLet(\.$hoja, action: \.hoja) {
      Hoja()
    }
    .forEach(\.filas, action: \.filas) {
      Fila()
    }
  }
}

Lee los tres operadores como la misma frase con distinta cardinalidad. Scope dice: el estado del hijo está siempre en este campo y sus acciones llegan siempre por este caso. ifLet dice lo mismo, pero el campo es opcional y el reducer hijo solo corre cuando hay valor. forEach dice lo mismo sobre una colección identificada, y el enrutamiento incluye el identificador del elemento. En los tres, el primer argumento es un key path hacia el estado y el segundo un case path hacia la acción.

flowchart TD
P[Dominio del padre] --> KP[Key path hacia el estado hijo]
P --> CP[Case path hacia la accion hija]
KP --> OP[Operador de composicion]
CP --> OP
OP --> H[Reducer hijo aislado]
H --> M[Mutacion sobre la rebanada del estado]

La vista usa el mismo par de coordenadas para recortar un Store. Cuando pasas a una subvista solo la porción que le corresponde, escribes store.scope(state: \.contador, action: \.contador) y obtienes un StoreOf<Contador> que la subvista consume sin saber nada del padre. Es la misma idea aplicada al otro lado de la arquitectura: el estado se recorta con key paths y las acciones se enrutan con case paths, en el reducer y en la vista.

Cuando el estado también es una suma

Falta un cuarto operador, y es el que cierra la simetría. Si el estado de una feature no es un producto sino una suma, porque la pantalla está en uno de varios modos excluyentes, entonces la coordenada hacia el estado hijo ya no es un key path sino otro case path. Ese es el dominio de ifCaseLet.

@Reducer
struct Flujo {
  @ObservableState
  @CasePathable
  enum State: Equatable {
    case bienvenida(Bienvenida.State)
    case formulario(Formulario.State)
  }

  enum Action {
    case bienvenida(Bienvenida.Action)
    case formulario(Formulario.Action)
  }

  var body: some ReducerOf<Self> {
    Reduce { state, action in
      return .none
    }
    .ifCaseLet(\.bienvenida, action: \.bienvenida) {
      Bienvenida()
    }
    .ifCaseLet(\.formulario, action: \.formulario) {
      Formulario()
    }
  }
}

Modelar el estado como suma tiene una virtud que ninguna cantidad de campos opcionales alcanza: hace inexpresables los estados imposibles. Con dos opcionales podrías tener ambos a la vez, o ninguno, y tendrías que defenderte de esas dos combinaciones sin sentido en cada lectura; con una suma de dos casos, el sistema de tipos garantiza que hay exactamente uno. Cuando descubras que tu State acumula banderas mutuamente excluyentes, la refactorización correcta suele ser esta.

💡
El error más común es cruzar las coordenadas

Si el compilador se queja de que no puede convertir un tipo en otro dentro de un Scope, comprueba antes que nada que no has intercambiado los argumentos o apuntado a un campo que no corresponde al hijo. Como ambos parámetros aceptan la misma sintaxis de barra invertida, confundirlos es fácil y el diagnóstico resultante habla de tipos genéricos, no de argumentos cruzados.

Un case path es la prueba de que la composición necesita simetría

Merece la pena detenerse en por qué Point-Free tuvo que inventar los case paths antes de poder escribir TCA, porque revela una carencia estructural del diseño de lenguajes que casi nadie había nombrado. Swift trata productos y sumas con una asimetría profunda: para el producto te da acceso por punto, key paths de primera clase, envoltorios de propiedad, protocolos que operan sobre campos; para la suma te da switch y poco más. Esa asimetría es invisible mientras programas dentro de un tipo, y se vuelve un muro en cuanto intentas escribir código genérico sobre partes de tipos, que es precisamente lo que hace un operador de composición. Scope no sabe nada de tu feature ni de tu hijo; solo sabe que existe una manera de llegar de un estado a otro y de una acción a otra, y para expresar eso necesita que ambas maneras sean valores manipulables. Sin case paths, el enrutamiento de acciones tendría que escribirse a mano en cada composición, con un switch que extrae el caso y otro que lo reconstruye, y esa repetición no solo sería tediosa: sería el lugar donde se colarían los errores, porque enrutamiento escrito a mano es enrutamiento que se olvida de un caso al añadir uno nuevo. Al restaurar la simetría entre producto y suma, los case paths convierten un problema de disciplina en un problema de tipos, y así la composición deja de ser algo que haces con cuidado para volverse algo que el compilador verifica. Toda la C de Composable descansa sobre esa restauración; es literalmente la pieza sin la cual la arquitectura no existiría.

⚔️ Apunta a las partes de tus propios tipos
  1. Declara un enum EstadoCarga con los casos inactivo, cargando y cargado(String), anótalo con @CasePathable y extrae el valor asociado usando la propiedad sintetizada.
  2. Comprueba el caso actual con la forma que no desestructura y explica por escrito en qué se diferencia de un switch con un solo caso relevante.
  3. Escribe un @Reducer struct Padre que componga dos hijos con Scope, uno de ellos opcional con ifLet. Anota cuál de los dos key paths lleva dólar y por qué.
  4. Cruza a propósito los argumentos de un Scope y guarda el mensaje de error íntegro. Vuelve a leerlo tras corregirlo y describe qué pista tenía y cuál te faltaba.
  5. En la vista, recorta el Store hacia cada hijo con scope y verifica que la subvista compila sin conocer ni el estado ni las acciones del padre.