Type-Safe Navigation in Compose Without Adding a Library
One file owns every route, nothing else writes a route string, and no more string concatenation in your deep links.
Compose navigation routes are strings. That is the entire problem. "detail/$id" compiles whether or not the destination exists, whether or not the argument is named id on the other side, and whether or not you remembered to encode it. You find out at runtime, usually from a crash report.
You do not need a library to fix this. You need one file where every route is declared once, and a rule that nothing outside that file ever writes a route string.
First, check whether you already have this
Navigation Compose 2.8 added type-safe routes built on kotlinx.serialization — you declare a @Serializable data class per destination and pass the object itself to navigate(). If you are on 2.8 or later and can adopt it, do that instead of what follows.
The pattern below is for the common cases where you cannot: an older Navigation version pinned by another dependency, a module that cannot take the serialization plugin, or a codebase you are migrating incrementally. It gets you the same safety at the call site with no new dependencies.
One file owns every route
A destination has two distinct things: the pattern the graph registers, and the path a caller navigates to. Keep them together and derive one from the other.
object Routes {
const val SUBSCRIPTIONS = "subscriptions"
object Detail {
const val ARG_ID = "id"
const val PATTERN = "detail/{id}"
// The only place a detail path is ever constructed.
fun path(id: String) = "detail/" + Uri.encode(id)
}
}Uri.encode is not optional. The moment an id contains a slash or a space — and one day it will, because ids come from a backend you do not control — an unencoded path silently routes somewhere else or matches nothing at all.
Wiring the graph
NavHost(navController, startDestination = Routes.SUBSCRIPTIONS) {
composable(Routes.SUBSCRIPTIONS) {
SubscriptionsScreen(
onOpen = { id -> navController.navigate(Routes.Detail.path(id)) },
)
}
composable(
route = Routes.Detail.PATTERN,
arguments = listOf(
navArgument(Routes.Detail.ARG_ID) { type = NavType.StringType },
),
// The deep link reuses the same pattern, so an external link
// and an in-app navigation can never drift apart.
deepLinks = listOf(
navDeepLink { uriPattern = "trackmysubs://" + Routes.Detail.PATTERN },
),
) { entry ->
val id = requireNotNull(entry.arguments?.getString(Routes.Detail.ARG_ID)) {
"Detail reached without an id — check the route pattern"
}
DetailScreen(id = id)
}
}The requireNotNull matters. A missing argument here is a programming error, not a user-facing state, and you want it to fail loudly in debug rather than render an empty screen in production while you wonder why the analytics look wrong.
Do not pass objects through the back stack
The temptation with a typed route is to pass the whole model — the subscription, the user, the order. Pass the id and re-read from your source of truth on the other side.
The back stack survives process death and your object does not. Serialise a model into a route and you get a stale copy after a cold restart: the user edits the record on another screen, the system kills the app, Android restores the back stack, and the detail screen renders the old values. An id plus a fresh read is always correct.
The rule that keeps it working
Nothing outside Routes.kt may contain a route string literal. Once one screen builds its own path inline, the guarantee is gone and you are back to grepping for "detail/" when a pattern changes.
It is worth a lint rule, but a code review habit is usually enough on a codebase this size.
TrackMySubs — $11
A native Compose app with the whole navigation graph wired up this way — routes, arguments and deep links included.