Compose Navigation: Type-Safe Routes and Why Strings Rot
String routes are an untyped API between your screens — renames don't propagate, every argument is a String, and special characters break matching silently. How Navigation 2.8's @Serializable routes fix it, and what still needs strings.
Hessam Rastegari
Senior Android Developer · 12 years shipping Android
TL;DR — String routes like "detail/{itemId}?tab={tab}" are a hand-maintained URL format with no
compiler involvement: renames don't propagate, arguments arrive as String?, and unescaped characters
break matching at runtime. Navigation Compose 2.8 replaces them with @Serializable route types, so
navigate(Detail(42)) and backStack.toRoute<Detail>() are checked at build time. Deep links still
need string patterns — that's the one place the old model survives, and it's the right place for it.
Why string routes rot
They don't rot on day one. They rot on the day someone renames a field.
// Three places that must agree, and nothing enforces it
composable(
route = "detail/{itemId}",
arguments = listOf(navArgument("itemId") { type = NavType.LongType }),
) { entry ->
DetailScreen(entry.arguments?.getLong("itemId") ?: 0L)
}
navController.navigate("detail/$id")
The route pattern, the navArgument declaration, and the read are connected by a string literal.
Change one, and the compiler is perfectly happy. You find out when the screen opens with itemId = 0.
Then there's the ?: 0L. Every argument read produces a nullable, so every call site invents a default
for a case that shouldn't be reachable — and that default is what turns a routing bug into a screen
quietly showing the wrong data instead of crashing loudly.
The failure that costs a release
navController.navigate("search/$query") // query = "kotlin/compose"
That's now search/kotlin/compose, which matches nothing. You add Uri.encode(query) at this call
site. Six months later a new call site forgets, and the bug is back — because the requirement lives in
your head rather than in a type.
Type-safe routes
Navigation 2.8 makes a route a serializable Kotlin type:
@Serializable data object Home
@Serializable
data class Detail(
val itemId: Long,
val tab: Tab = Tab.Info,
)
NavHost(navController, startDestination = Home) {
composable<Home> { HomeScreen(onOpen = { navController.navigate(Detail(it)) }) }
composable<Detail> { backStackEntry ->
val args: Detail = backStackEntry.toRoute()
DetailScreen(itemId = args.itemId, tab = args.tab)
}
}
Four things changed, and they're the four things that were broken:
- Renames propagate.
itemId→productIdis a refactor, not an archaeology exercise. - Types are real.
args.itemIdis aLong. No parsing, no nullable, no invented default. - Encoding is handled. Serialization escapes the value; you don't.
- Adding a required argument breaks the build. Which is where you want to find out.
Optional arguments become what they always should have been — Kotlin default values. No ?tab={tab}
syntax to get wrong.
Nested graphs and results
The same typing applies to graphs and to popping back to a destination:
@Serializable data object CheckoutGraph
navigation<CheckoutGraph>(startDestination = Address) {
composable<Address> { … }
composable<Payment> { … }
}
navController.popBackStack<CheckoutGraph>(inclusive = true)
For custom argument types, provide a NavType:
composable<Detail>(
typeMap = mapOf(typeOf<Tab>() to serializableNavType<Tab>()),
) { … }
That's the one piece of ceremony left. It's per-type, not per-route, so you pay it once.
What still needs strings — and should
Deep links. An external URL genuinely is a string, and the pattern belongs at the boundary:
composable<Detail>(
deepLinks = listOf(navDeepLink<Detail>(basePath = "https://example.com/detail")),
)
That's the correct shape: strings at the edge where the outside world speaks them, types everywhere inside. The old model's mistake was using the external representation as the internal one.
Migrating without a big-bang refactor
Both models coexist in the same NavHost, so migrate a destination at a time:
- Convert leaf destinations first — they have callers but no children.
- Convert the graph containers next.
- Convert the start destination last; it's the one most likely to be referenced from tests and deep links.
- Delete the
navArgumentlists as you go — a half-migrated destination that keeps both is the only genuinely confusing state, so don't linger there.
You'll need kotlinx-serialization applied to the module holding the route types. If that module is
pure Kotlin, this is a one-line plugin addition; if your routes currently live in the UI module next to
the composables, this is a good moment to move them somewhere a feature module can see without
depending on the whole screen.
The argument for doing it now
The cost is a mechanical migration you can do incrementally. The benefit is that an entire class of bug — the one where a route argument silently changes meaning — becomes a compile error.
A navigation graph is an API between screens. You would not ship an internal service whose arguments were positional strings parsed at runtime with a fallback default. That's exactly what a string route is; it only felt acceptable because the framework once made it mandatory.