wandres.dev
NAVEGACIÓN · rutas con tipos seguros

Rutas con tipos seguros: el adiós a las cadenas

Durante años el destino de una navegación fue una cadena de texto construida por concatenación y desmontada por interpolación, con el compilador mirando hacia otro lado en ambos extremos. Esta lección estudia por qué esa representación era una fuente estructural de fallos y no un descuido de estilo; cómo los destinos declarados como clases serializables convierten cada argumento en un parámetro verificado; qué papel juegan `@Serializable`, la función `toRoute` y la inferencia de tipos; cómo se modelan argumentos opcionales, nulos y tipos propios; y qué estrategia permite migrar un grafo existente sin detener el desarrollo.

⏱ 22 min

Hay una clase de error que ninguna revisión de código detecta con fiabilidad y que sin embargo se reproduce en todos los proyectos que usan rutas textuales: alguien cambia el nombre de un argumento en el sitio donde se declara y olvida cambiarlo en uno de los siete sitios donde se construye. El proyecto compila sin una sola advertencia, las pruebas unitarias pasan, y el fallo aparece en un dispositivo real como una pantalla vacía o una excepción de argumento ausente. La causa no es la falta de cuidado: es que se eligió representar una estructura tipada —un destino con sus parámetros— mediante una cadena de texto, es decir, mediante el tipo que por definición no lleva información sobre su contenido. Las rutas con tipos seguros no son una comodidad sintáctica ni azúcar sobre lo mismo de antes. Son el reconocimiento de que un destino siempre fue un valor con estructura, y de que escribirlo como texto era serializar demasiado pronto y perder por el camino todo lo que el compilador podía haber verificado gratis.

🎯 Al terminar esta lección sabrás
  • Enumerar las cuatro categorías de fallo que la ruta como cadena hace inevitables.
  • Declarar destinos como clases y objetos serializables y navegar hacia ellos con instancias.
  • Recuperar argumentos tipados en el destino y explicar dónde ocurre la conversión.
  • Modelar argumentos opcionales, nulos y tipos propios sin volver a la concatenación.

Lo que la cadena no podía saber

La ruta textual falla de cuatro maneras distintas, y conviene separarlas porque tienen remedios distintos y solo una desaparece con disciplina.

La primera es el nombre. Una ruta se declara en un sitio con un patrón y se construye en otro por concatenación; nada obliga a que coincidan. Renombrar un argumento es una operación que las herramientas de refactorización no pueden ayudarte a completar, porque para ellas no hay ninguna relación entre dos cadenas parecidas.

La segunda es el tipo. Todo argumento viaja como texto y hay que declarar aparte cómo interpretarlo. Si el patrón dice que un campo es entero y el sitio que construye la ruta mete un identificador alfanumérico, no hay error hasta que alguien pulsa.

La tercera es la aridad: nada comprueba que se pasen todos los argumentos requeridos, ni que no sobren. La cuarta es el escapado, el más silencioso de todos: un valor con una barra, un espacio o un acento rompe la ruta o la trunca, y el fallo depende de los datos y no del código, así que aparece en producción y no en desarrollo.

// El patron y su construccion viven separados y nada los relaciona.
composable("detalle/{productoId}?origen={origen}") { entrada ->
    val id = entrada.arguments?.getString("productoId")   // puede ser nulo
    PantallaDetalle(id!!)                                  // y aqui se paga
}

navController.navigate("detalle/${producto.id}?origen=catalogo")

Los cuatro fallos comparten una firma reconocible: se manifiestan lejos de donde se originan y siempre en ejecución. El compilador ha visto pasar dos cadenas de texto perfectamente válidas y no tenía manera de saber que una pretendía ser el molde de la otra.

flowchart LR
subgraph Antes
  A1[Clase de datos con campos] --> A2[Se aplasta a una cadena]
  A2 --> A3[Viaja sin tipo]
  A3 --> A4[Se reconstruye a mano]
  A4 --> A5[Fallo en ejecucion]
end
subgraph Ahora
  B1[Clase serializable] --> B2[Viaja como valor]
  B2 --> B3[El compilador verifica]
  B3 --> B4[Se recupera con toRoute]
  style B3 fill:#a6e3a1,color:#11111b
end

Solo el primer fallo se mitiga con disciplina, extrayendo constantes y funciones auxiliares. Los otros tres son consecuencia de haber elegido un tipo sin estructura para representar algo que la tiene, y por eso ninguna cantidad de cuidado los elimina.

Destinos como valores

La propuesta con tipos seguros invierte el planteamiento: en lugar de describir el destino con un patrón y rellenarlo, se declara una clase serializable por destino y se navega pasando una instancia.

@Serializable
object Catalogo

@Serializable
data class Detalle(val productoId: String, val origen: String = "catalogo")

@Serializable
data class Resultados(val consulta: String, val pagina: Int = 1)

Un destino sin argumentos es un object; uno con argumentos es una data class cuyos campos son los argumentos. No hay patrón que escribir, ni nombres que repetir, ni tipos que declarar aparte: la firma del constructor ya lo dice todo.

NavHost(navController, startDestination = Catalogo) {
    composable<Catalogo> {
        PantallaCatalogo(alPulsar = { id -> navController.navigate(Detalle(id)) })
    }
    composable<Detalle> { entrada ->
        val args: Detalle = entrada.toRoute()
        PantallaDetalle(productoId = args.productoId, origen = args.origen)
    }
}

Conviene ver con precisión qué acaba de cambiar. navigate ya no recibe texto sino un valor, así que un identificador de tipo equivocado no compila y un argumento que falta tampoco. composable se parametriza por el tipo del destino, de modo que registrar un destino y navegar hacia él comparten una única fuente de verdad. Y toRoute devuelve una instancia reconstruida, con lo que dentro de la pantalla los argumentos son campos de un objeto y no consultas a un mapa que podrían devolver nulo.

La serialización sigue existiendo, porque la pila tiene que sobrevivir a la muerte del proceso y un enlace profundo tiene que ser una cadena de texto. Lo que ha cambiado es quién la hace: antes la escribías tú en cada llamada, ahora la genera el compilador a partir de la declaración. El escapado, la conversión y la reconstrucción dejan de ser código tuyo, y con ellos desaparecen las tres categorías de fallo que la disciplina no podía cubrir.

ℹ️
El nombre completo de la clase es la identidad del destino

La ruta serializada se deriva del nombre cualificado de la clase más sus argumentos. Dos consecuencias prácticas: mover una clase de destino de paquete cambia su ruta, lo que invalida enlaces profundos publicados y estados guardados de versiones anteriores; y dos destinos declarados con el mismo nombre en paquetes distintos no colisionan. Cuando la ruta forma parte de un contrato externo, conviene fijarla explícitamente en lugar de dejar que la herede del paquete.

Argumentos que no son cadenas ni enteros

Los tipos primitivos y sus versiones nulas se soportan directamente. Lo interesante empieza justo después, y hay tres casos que aparecen en cualquier aplicación real.

🎚️

Opcionales con valor por defecto

Un parámetro con valor por defecto en el constructor se convierte en argumento opcional. Navegar sin él es legal y el destino recibe el valor declarado, sin ninguna comprobación de nulo.

🕳️

Nulos explicitos

Un tipo nulo modela la ausencia genuina de dato, que es distinta de un valor por defecto. Úsalo cuando el destino deba comportarse de otro modo si el argumento no vino.

🧬

Tipos propios y enumeraciones

Un tipo serializable puede viajar completo si se declara cómo se convierte a texto y cómo se recupera, mediante un tipo de navegación personalizado.

@Serializable
enum class Orden { RECIENTES, PRECIO, VALORACION }

@Serializable
data class Busqueda(
    val consulta: String,
    val orden: Orden = Orden.RECIENTES,
    val categoriaId: String? = null,
)

Una consecuencia que se agradece pronto es que el destino inicial deja de ser una cadena y pasa a ser un valor, con lo que puede llevar argumentos calculados al montar el grafo. Y otra, menos visible pero más útil a largo plazo, es que añadir un argumento obligatorio a un destino rompe la compilación exactamente en los puntos desde los que se navega hacia él, que son justo los que hay que revisar.

Sobre los tipos propios conviene una advertencia de diseño que pesa más que la técnica: el hecho de que se pueda pasar un objeto complejo no significa que se deba. Un argumento de navegación forma parte de una estructura que se serializa, se guarda en disco y a veces se publica como enlace. Pasar un objeto de dominio entero acopla el formato guardado a la forma de tu modelo, multiplica el tamaño de lo que se archiva y garantiza que cualquier cambio del modelo rompa estados guardados de la versión anterior.

💡
Pasa identidad, no contenido

La regla que resiste el paso del tiempo es que un destino reciba lo mínimo para saber qué mostrar, no el contenido de lo que muestra. Un identificador y, como mucho, algún parámetro de presentación. El contenido se obtiene en el destino a partir de la fuente de verdad, que además puede haber cambiado desde que se apiló la entrada. Un destino que recibe el objeto completo muestra datos congelados en el momento de navegar, y ese desfase es un fallo real que se manifiesta al volver atrás tras una edición.

La migración de un grafo existente no exige una operación de todo o nada, y conviene saberlo porque el temor a lo contrario es lo que la retrasa. Ambas formas de declarar destinos conviven en el mismo grafo, así que se puede convertir un destino cada día y mantener el proyecto compilando entre paso y paso. El orden que menos fricción produce es empezar por las hojas, los destinos a los que se navega pero desde los que no se navega, porque cada conversión toca solo los puntos de llamada y no el interior de otras pantallas.

⚠️
Serializable no es el de la plataforma

La anotación que se usa aquí es la de la biblioteca de serialización de Kotlin, no la interfaz homónima de Java. Requiere aplicar su complemento de compilación en el módulo, y confundirlas produce un error de compilación oscuro o, peor, un destino que parece registrarse pero nunca se encuentra. Es el tropiezo más frecuente al empezar, y no tiene nada que ver con la navegación.

Serializar tarde es la forma general del consejo

Lo que ocurre al pasar de rutas textuales a destinos tipados es un caso particular de un principio que reaparece en todas las capas de un sistema, y verlo así vale mucho más que memorizar la sintaxis nueva. Una cadena de texto es la representación universal: cualquier cosa cabe en ella, y por eso mismo no dice nada sobre lo que contiene. En cuanto un valor estructurado se aplasta a texto, toda la información sobre su forma abandona el ámbito donde el compilador puede razonar y pasa a existir solo como convención entre dos trozos de código que ya no tienen forma de verificarse entre sí. El fallo que aparece después no se produce donde se manifiesta, sino en el punto donde se decidió aplastar; y esa distancia entre la causa y el síntoma es exactamente la razón de que estos fallos sean tan difíciles de atribuir. La misma historia se repite en todas partes: consultas de base de datos concatenadas en lugar de construidas, respuestas de red tratadas como mapas de texto en lugar de tipos, claves de preferencias escritas a mano en dos sitios, identificadores de eventos de analítica que solo coinciden por costumbre. En todos los casos el remedio tiene la misma forma, que es retrasar la serialización hasta la frontera real del sistema y mantener el valor estructurado en todo el trayecto anterior. Y en todos los casos el beneficio va mucho más allá de evitar erratas: cuando el destino es un tipo, renombrar un campo es una operación mecánica que las herramientas completan solas, encontrar todos los sitios desde los que se llega a una pantalla es una búsqueda de referencias en lugar de una búsqueda de texto, y un destino nuevo con un argumento obligatorio hace que deje de compilar exactamente el código que hay que revisar. Esa última propiedad es la más valiosa de todas, porque convierte al compilador en la lista de tareas de una migración, y una lista de tareas que el compilador mantiene por ti nunca se queda incompleta.

⚔️ Convierte un grafo de cadenas en un grafo de tipos
  1. Toma un grafo con al menos tres destinos y dos argumentos por ruta textual y anota cuántos sitios distintos construyen cada ruta.
  2. Declara cada destino como clase u objeto serializable y migra un destino cada vez, comprobando que el proyecto compila entre pasos.
  3. Renombra un argumento con la herramienta de refactorización y comprueba que ningún punto de llamada queda descolgado.
  4. Añade un argumento opcional con valor por defecto y otro nulo, y describe en qué se diferencian desde dentro del destino.
  5. Prueba a navegar con un valor que contenga una barra y un acento, y compara el resultado con lo que hacía la versión textual.