Skip to content
All posts

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.

MFKAPPS 4 min read

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 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.