Navegación con seguridad de tipos en Jetpack Compose: rutas como datos, no como strings
Cómo reemplazar las rutas basadas en strings de Compose Navigation por objetos Kotlin serializables — argumentos con seguridad de tipos, grafos anidados y deep links que sobreviven a un refactor.
Durante los primeros años de Compose Navigation, cada ruta era un string. Construías "item/{itemId}", lo emparejabas en un NavHost, y volvías a sacar itemId de un NavBackStackEntry con getString("itemId") — esperando que la clave que escribiste al salir coincidiera con la que escribiste al entrar. El compilador no podía ayudarte. Un error de tipeo en cualquiera de los dos strings compilaba sin problema y fallaba en tiempo de ejecución, normalmente en una pantalla que no estabas mirando cuando hiciste el cambio. Las rutas con seguridad de tipos de Navigation Compose solucionan esto convirtiendo una ruta en un objeto Kotlin en lugar de un string, y después de migrar cada pantalla de una app en producción a este enfoque, la clase de bug de argumento mal tipeado desaparece por completo — el compilador lo detecta antes de que termine el build.
Rutas como una jerarquía sealed
La idea central es pequeña: define tus destinos como una sealed interface, anota cada uno con @Serializable, y deja que kotlinx.serialization se encargue de convertirlos en una ruta y de vuelta.
sealed interface Screen {
@Serializable
data object Home : Screen
@Serializable
data class ItemDetail(val itemId: Long) : Screen
@Serializable
data class EditItem(val itemId: Long, val fromDetail: Boolean = false) : Screen
}
Sin plantilla de string, sin nombre de clave que mantener sincronizado — los argumentos son las propiedades de la clase. Navegar es una llamada a función con tipos reales:
navController.navigate(Screen.ItemDetail(itemId = item.id))
Si itemId se renombrara o su tipo cambiara de Long a String en algún punto anterior, cada lugar donde se llama fallaría al compilar en lugar de fallar al parsear en tiempo de ejecución.
Conectando el NavHost
El lado del NavHost refleja esto con la sobrecarga composable<T>(), que toma el tipo como un parámetro reificado en lugar de un patrón de string:
NavHost(navController = navController, startDestination = Screen.Home) {
composable<Screen.Home> {
HomeScreen(onItemClick = { id -> navController.navigate(Screen.ItemDetail(id)) })
}
composable<Screen.ItemDetail> { backStackEntry ->
val args: Screen.ItemDetail = backStackEntry.toRoute()
ItemDetailScreen(itemId = args.itemId)
}
composable<Screen.EditItem> { backStackEntry ->
val args: Screen.EditItem = backStackEntry.toRoute()
EditItemScreen(itemId = args.itemId, cameFromDetail = args.fromDetail)
}
}
toRoute() deserializa la entrada del back stack directamente al tipo de destino. No hay clave de Bundle que se pueda escribir mal ni comprobación manual de nulos sobre un argumento faltante — una propiedad requerida que no está presente simplemente no compila, y una opcional recibe su valor por defecto declarado, igual que cualquier otro argumento de función en Kotlin.
Los grafos anidados también mantienen la seguridad de tipos
El mismo patrón se extiende a los grafos de navegación anidados, que es donde las rutas basadas en strings solían volverse realmente dolorosas — un error de tipeo en el destino inicial de un grafo anidado falla en silencio y simplemente muestra una pantalla en blanco. Con rutas sealed, el grafo anidado es su propio tipo:
sealed interface OnboardingGraph : Screen {
@Serializable data object Welcome : OnboardingGraph
@Serializable data object Permissions : OnboardingGraph
@Serializable data class Done(val skippedPermissions: Boolean) : OnboardingGraph
}
navigation<OnboardingGraph>(startDestination = OnboardingGraph.Welcome) {
composable<OnboardingGraph.Welcome> { /* ... */ }
composable<OnboardingGraph.Permissions> { /* ... */ }
composable<OnboardingGraph.Done> { entry ->
val args: OnboardingGraph.Done = entry.toRoute()
// ...
}
}
Como OnboardingGraph extiende Screen, el código en otras partes de la app que navega contra el grafo externo no necesita saber que el grafo anidado existe — simplemente llama a navigate(OnboardingGraph.Welcome) y el límite del grafo se resuelve de forma estructural, no por coincidencia de prefijo de string.
Deep links sin una plantilla de URI que se pueda equivocar
Los deep links se conectan a los mismos destinos tipados, así que una URI entrante igualmente se resuelve en un objeto real en lugar de una bolsa de strings para parsear a mano:
composable<Screen.ItemDetail>(
deepLinks = listOf(navDeepLink<Screen.ItemDetail>(basePath = "myapp://item"))
) { backStackEntry ->
val args: Screen.ItemDetail = backStackEntry.toRoute()
ItemDetailScreen(itemId = args.itemId)
}
Tanto myapp://item/42 como un navigate(Screen.ItemDetail(42)) dentro de la app terminan en el mismo composable con el mismo argumento tipado — hay exactamente un lugar que define cómo es un destino ItemDetail, sin importar si se llegó a él desde un toque en una notificación, un widget o un botón dentro de la app.
Dos cosas que vale la pena saber antes de migrar
Los tipos personalizados en una ruta necesitan su propio NavType. Un argumento Long o String funciona de fábrica, pero una ruta que contiene un enum o una value class necesita una pequeña implementación de NavType<T> registrada en la llamada al composable, ya que el back stack en última instancia almacena la ruta como un string serializado y necesita saber cómo codificar y decodificar tu tipo dentro de él. Son pocas líneas, pero no son gratis — cuéntalo en el presupuesto de la primera pantalla que necesite un argumento no primitivo.
Migra primero las hojas, no la raíz. Convertir primero las pantallas más internas, antes que los grafos que las alojan, te permite tener rutas de string y rutas con seguridad de tipos funcionando en paralelo mientras avanzas, en lugar de una reescritura de una sola vez en la que nada compila hasta que cada ruta está terminada. Yo migré un flujo a la vez — onboarding, luego el detalle de un item, luego los ajustes — verificando la navegación manualmente después de cada uno, y nunca tuve más que un puñado de archivos en un estado inconsistente.
El resultado no es un código de navegación más rápido, es un código de navegación donde la clase de bug que solía sobrevivir a la revisión de código — un argumento renombrado que aún se parsea con el tipo equivocado — simplemente ya no existe. Para una app mantenida en solitario, sin un segundo revisor que detecte el error de tipeo, ese es el argumento que más importa.
// Lecturas relacionadas
Más del diario
La API In-App Review de Android: pedir una valoración sin resultar molesto
Una guía práctica de la API In-App Review de Google: cómo funciona realmente, cuándo activarla y por qué el típico popup de 'valóranos' está perjudicando en silencio tu puntuación en Play Store.
App shortcuts y Quick Settings Tiles en Android: registrar agua sin abrir la app
Una guía práctica de la API dinámica ShortcutManager y de TileService en Android — cómo hacer que una acción de un solo toque se salte la app por completo, con los errores en los que caen la mayoría de las implementaciones.
Tipos de servicio en primer plano en Android en 2026: elegir el que realmente encaja con tu función
Una guía práctica sobre las restricciones de tipos de servicio en primer plano de Android — dataSync, mediaPlayback, specialUse, shortService — y cómo elegir el correcto sin que te lo maten o te lo rechacen.