Type-Safe Navigation in Jetpack Compose: Routes as Data, Not Strings
How to replace string-based Compose Navigation routes with serializable Kotlin objects — type-safe arguments, nested graphs, and deep links that survive a refactor.
For the first few years of Compose Navigation, every route was a string. You built "item/{itemId}", matched it in a NavHost, and pulled itemId back out of a NavBackStackEntry with getString("itemId") — hoping the key you typed on the way out matched the key you typed on the way in. The compiler couldn’t help you. A typo in either string compiled fine and crashed at runtime, usually on a screen you weren’t looking at when you made the change. Navigation Compose’s type-safe routes fix this by making a route a Kotlin object instead of a string, and after moving every screen in a shipping app over to it, the argument-typo class of bug is gone entirely — the compiler catches it before the build finishes.
Routes as a sealed hierarchy
The core idea is small: define your destinations as a sealed interface, annotate each one @Serializable, and let kotlinx.serialization handle turning them into a route and back.
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
}
No string template, no key name to keep in sync — the arguments are the class’s properties. Navigating is a function call with real types:
navController.navigate(Screen.ItemDetail(itemId = item.id))
If itemId were renamed or its type changed from Long to String somewhere upstream, every call site would fail to compile instead of failing to parse at runtime.
Wiring the NavHost
The NavHost side mirrors this with the composable<T>() overload, which takes the type as a reified parameter instead of a string pattern:
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() deserializes the back stack entry straight into the destination type. There’s no Bundle key to misspell and no manual null-check on a missing argument — a required property that isn’t present simply doesn’t compile, and an optional one gets its declared default, the same as any other Kotlin function argument.
Nested graphs stay type-safe too
The same pattern extends to nested navigation graphs, which is where string routes used to get genuinely painful — a typo in a nested graph’s start destination fails silently and just shows a blank screen. With sealed routes, the nested graph is its own type:
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()
// ...
}
}
Because OnboardingGraph extends Screen, code elsewhere in the app that navigates against the outer graph doesn’t need to know the nested graph exists — it just calls navigate(OnboardingGraph.Welcome) and the graph boundary is resolved structurally, not by string prefix matching.
Deep links without a URI template to get wrong
Deep links attach to the same typed destinations, so an incoming URI still resolves into a real object rather than a bag of strings to parse by hand:
composable<Screen.ItemDetail>(
deepLinks = listOf(navDeepLink<Screen.ItemDetail>(basePath = "myapp://item"))
) { backStackEntry ->
val args: Screen.ItemDetail = backStackEntry.toRoute()
ItemDetailScreen(itemId = args.itemId)
}
myapp://item/42 and an in-app navigate(Screen.ItemDetail(42)) both land in the same composable with the same typed argument — there’s exactly one place that owns what an ItemDetail destination looks like, whether it was reached from a notification tap, a widget, or a button inside the app.
Two things worth knowing before you migrate
Custom types in a route need their own NavType. A Long or String argument works out of the box, but a route holding an enum or a value class needs a small NavType<T> implementation registered on the composable call, since the back stack ultimately stores the route as a serialized string and needs to know how to encode and decode your type into it. It’s a few lines, but it’s not free — budget for it on the first screen that needs a non-primitive argument.
Migrate leaf-first, not root-first. Converting the innermost screens before the graphs that host them lets you run string and type-safe routes side by side while you go, instead of a big-bang rewrite where nothing compiles until every route is done. I moved one flow at a time — onboarding, then item detail, then settings — verifying navigation manually after each, and never had more than a handful of files in an inconsistent state.
The result isn’t faster navigation code, it’s navigation code where the class of bug that used to survive code review — a renamed argument that still parses as the wrong type — simply doesn’t exist anymore. For an app maintained solo, with no second reviewer to catch the typo, that’s the argument that matters most.
// Related reading
More from the journal
The Android In-App Review API: asking for a rating without being annoying
A practical guide to Google's In-App Review API — how it actually works, where to trigger it, and why the usual 'rate us' popup is quietly hurting your Play Store rating.
Android app shortcuts and Quick Settings Tiles: logging water without opening the app
A practical guide to Android's dynamic ShortcutManager API and TileService — how to let a one-tap action skip the app entirely, with the pitfalls that trip most implementations.
Foreground service types on Android in 2026: picking the one your feature actually qualifies for
A practical guide to Android's foreground service type restrictions — dataSync, mediaPlayback, specialUse, shortService — and how to pick the right one without getting killed or rejected.