5 min read

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.

AndroidJetpack ComposeNavigationKotlinType Safety
HR

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. itemIdproductId is a refactor, not an archaeology exercise.
  • Types are real. args.itemId is a Long. 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:

  1. Convert leaf destinations first — they have callers but no children.
  2. Convert the graph containers next.
  3. Convert the start destination last; it's the one most likely to be referenced from tests and deep links.
  4. Delete the navArgument lists 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.

Keep reading