A route is a string until you make it a type
String routes fail at runtime on a typo or a missing argument. Serializable types move both into the compiler.
TL;DR
A string route is a parser and a formatter written twice, by hand, at every call site. "detail/$id"
and "detail/{id}" have to agree, and nothing checks that they do.
So a typo is a runtime crash, a missing argument is a runtime crash, a renamed argument compiles fine and crashes on navigation, and an optional argument's absence is a null you have to remember to handle. We shipped three of those four.
Type-safe navigation makes the route a @Serializable object. The destination declaration and the
navigation call share one type, so the compiler does the agreement check — and a new required
argument becomes a compile error at every call site instead of a crash on one path.
The case this post is about
A graph with four destinations, the string way:
NavHost(nav, startDestination = "list") {
composable("list") { ListRoute(onItem = { id -> nav.navigate("detail/$id") }) }
composable("detail/{itemId}") { entry ->
val itemId = entry.arguments?.getString("itemId")!! // parse, by hand
DetailRoute(itemId)
}
composable("edit/{itemId}?draft={draft}") { entry ->
val itemId = entry.arguments?.getString("itemId")!!
val draft = entry.arguments?.getString("draft") // optional: nullable
EditRoute(itemId, draft)
}
composable("settings") { SettingsRoute() }
}
Fourteen navigate calls across the app, each composing a string.
The symptom
A typo that compiled:
nav.navigate("detials/$id") // transposed
java.lang.IllegalArgumentException: Navigation destination that matches request
NavDeepLinkRequest{ uri=android-app://androidx.navigation/detials/88214 }
cannot be found in the navigation graph
Found by a user, in a flow reachable only from a long-press menu that no test covered.
A missing argument, also compiling:
nav.navigate("edit/") // the id was an empty string from a stale state
NullPointerException at EditRoute(EditScreen.kt:28)
entry.arguments?.getString("itemId")!!
A rename that broke silently. Someone renamed the argument in the declaration and missed one call site:
composable("detail/{id}") { … } // was {itemId}
nav.navigate("detail/$itemId") // still works — positional!
entry.arguments?.getString("itemId") // now null → NPE
The route pattern matched because the argument is positional in the path; only the lookup broke. That one took an hour to find.
And an unencoded argument:
nav.navigate("search/${query}") // query = "shoes/size 10"
The slash created a path segment, so the route did not match. Nobody had thought about encoding, because a string template does not invite you to.
crashes attributed to navigation, 90 days: 41
destination not found (typos): 12
argument null (missing/misnamed): 22
malformed route (encoding): 7
Forty-one crashes from a class the compiler could have prevented entirely.
Why the obvious fix failed
"Put the routes in constants." We did, and it fixed the typos and nothing else:
object Routes {
const val DETAIL = "detail/{itemId}"
fun detail(id: String) = "detail/$id"
}
Better — and the builder function and the pattern still have to agree by hand, the argument lookup
is still a stringly-typed getString("itemId"), and encoding is still forgotten.
"Add a test that navigates to every destination." Helpful and it enumerates destinations, not call sites. The typo was in one of fourteen call sites, in a flow the test did not reach.
"Use a sealed class of route objects, with a path property." Closer, and the version I built
first still formatted a string in the middle:
sealed class Route(val path: String) {
data class Detail(val id: String) : Route("detail/$id") // still a string, still unencoded
}
It centralises the formatting and leaves the parse side — getString — completely untouched.
"Generate the routes with KSP." This is what some libraries do and it works. We did not need to build it, because the navigation library gained type-safe routes.
The actual mechanism
A string route is two hand-written halves of one contract:
formatter: "detail/$id" at the navigate call site
parser: "detail/{itemId}" at the destination declaration
+ getString("itemId") the extraction
Nothing relates them. The compiler sees three unrelated string expressions, so:
A typo in either half is valid Kotlin. "detials/$id" is a well-formed string.
The argument name is only checked at runtime, by a map lookup that returns null on a miss.
Positional matching hides a rename. A path pattern matches on structure, so the pattern keeps working while the lookup breaks — which is why that bug was the slowest to find.
And encoding is the caller's job with no reminder, because string interpolation does not distinguish data from structure. This is the same shape as SQL injection: data concatenated into a structured string.
Type-safe routes collapse the two halves into one declaration:
@Serializable data class Detail(val id: String)
→ the destination declares it composable<Detail> { … }
→ the navigation call passes it nav.navigate(Detail(id))
→ the extraction is typed entry.toRoute<Detail>()
One type, three uses, and the compiler checks all three agree.

A route is a structured value, and writing it as a string means hand-writing a parser and a formatter that nothing checks against each other. Making it a type hands both to the compiler.
The fix
@Serializable route types, with the graph declared in terms of them.
// one declaration per destination. These ARE the routes.
@Serializable data object ListRoute
@Serializable data class DetailRoute(val itemId: String)
@Serializable data class EditRoute(val itemId: String, val draft: String? = null)
@Serializable data object SettingsRoute
The sub-case that converts a compile-time win back into a runtime crash: these types still
have to survive a URI. The compiler checks that the declaration, the call and the extraction agree
on DetailRoute — it does not check that the navigation library knows how to put a
DetailRoute into a back-stack entry. Primitives and enums it handles; anything else needs a
NavType you supply:
@Serializable data class FilterRoute(val tags: List<String>) // compiles fine
composable<FilterRoute>(
typeMap = mapOf(typeOf<List<String>>() to TagsNavType), // without this: crash at navigate
) { … }
Miss the typeMap and you get an IllegalArgumentException the first time that destination is
reached — later than a compile error and earlier than the string version's silent null, which is
the honest summary of what type safety bought here. The rule of thumb: a route field that is not a
primitive, an enum or a @Serializable type with a declared NavType is a field the URI layer
cannot represent, and keeping route types flat is usually cheaper than writing the converter.
NavHost(nav, startDestination = ListRoute) {
composable<ListRoute> {
ListScreen(onItem = { id -> nav.navigate(DetailRoute(itemId = id)) })
}
composable<DetailRoute> { entry ->
val route: DetailRoute = entry.toRoute() // typed. no getString, no !!
DetailScreen(route.itemId, onEdit = { nav.navigate(EditRoute(route.itemId)) })
}
composable<EditRoute> { entry ->
val route: EditRoute = entry.toRoute()
EditScreen(route.itemId, route.draft) // draft is String?, honestly
}
composable<SettingsRoute> { SettingsScreen() }
}
Five things this fixes, each mapping to one of the four symptoms:
Typos are compile errors. nav.navigate(DetialRoute(id)) does not resolve.
Arguments cannot be missing. DetailRoute() without itemId does not compile — a required
constructor parameter is a required argument.
Renames propagate. Rename itemId and every call site and every usage fails to compile, with
the IDE's rename refactoring handling it in one step.
Encoding is the library's job. The route is serialised, so "shoes/size 10" is encoded
correctly without the caller thinking about it.
And optionality is in the type. draft: String? = null means the destination's route.draft is
genuinely nullable and the compiler enforces handling it — rather than a getString that returns
null for both "absent" and "misnamed".
Two further details worth having. Custom argument types need a NavType:
@Serializable data class FilterRoute(val filter: Filter) // Filter is @Serializable
composable<FilterRoute>(
typeMap = mapOf(typeOf<Filter>() to serializableNavType<Filter>()),
) { entry -> FilterScreen(entry.toRoute<FilterRoute>().filter) }
And deep links keep a declared pattern, which is the one place a string remains — correctly, since the external URL format is a published contract:
composable<DetailRoute>(
deepLinks = listOf(navDeepLink<DetailRoute>(basePath = "https://example.com/item")),
) { … }
How we proved it
That each symptom is now a compile error, which is the whole claim:
// 1. the typo
nav.navigate(DetialRoute(id))
e: Unresolved reference: DetialRoute
// 2. the missing argument
nav.navigate(DetailRoute())
e: No value passed for parameter 'itemId'
// 3. the rename: rename itemId → id in DetailRoute
e: No value passed for parameter 'id' (at 3 call sites)
e: Unresolved reference: itemId (at 2 usages)
Three bugs, three compile errors, found by the compiler rather than by users.
Encoding, verified rather than assumed:
@Test
fun argumentsWithSlashesRoundTrip() {
composeTestRule.setContent { AppNav(nav) }
nav.navigate(SearchRoute(query = "shoes/size 10"))
composeTestRule.waitForIdle()
assertEquals("shoes/size 10", nav.currentBackStackEntry!!.toRoute<SearchRoute>().query)
}
string routes: FAILED — destination not found
typed routes: PASSED
And the production outcome over 90 days:
navigation crashes
before: 41
after: 0
destination not found: 12 → 0
argument null: 22 → 0
malformed route: 7 → 0
The migration cost, since it touched the whole graph:
destinations: 18
navigate call sites: 47
files changed: 31
compile errors during migration: 83 ← each a place that had been stringly-typed
of those, actual latent bugs: 4 (2 wrong argument names, 2 unencoded)
time: 1 day
Four latent bugs surfaced by the migration, in code nobody had reported yet.
One limit: type-safe navigation requires the kotlinx.serialization plugin and a recent
Navigation-Compose version, and the custom-NavType story for non-primitive arguments is more
verbose than it should be — we have three typeMap entries and they are the least pleasant part of
the code. For primitives and strings, which is most routes, it is strictly better.
What this generalizes to
A stringly-typed structure is a hand-written parser and formatter that nothing checks. Routes, feature-flag keys, cache keys, event names, column names, environment variable names. Each pair of "write it" and "read it" sites has to agree, and the compiler is not involved.
Positional matching hides renames. Any format where position carries meaning — a path, a CSV, a positional argument list — keeps working after a rename while the named lookup breaks. That asymmetry makes the bug slow to diagnose.
Encoding is forgotten whenever data is interpolated into structure. String templates make
"detail/$id" look like concatenation rather than construction. The same reasoning that produces
parameterised SQL produces typed routes.
A migration that produces compile errors is a bug list. Eighty-three errors, four of them latent bugs. Moving a runtime contract into the type system does not just prevent future mistakes — it enumerates the current ones.
Related
- Shared navigation has no back stack owner, and that is the hard part — the multiplatform half of navigation
- The null that meant three different things — optionality in the type
- kotlinx.serialization is compile-time, which is why your polymorphism fails at runtime — the serializer these routes use
Keep reading
This is part of 100 Days of Compose — the mechanisms underneath the API, measured rather than guessed. Follow MobileByteSensei for the rest.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".