Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Type-Safe Navigation in Jetpack Compose

Hello! In the previous lesson, ListingsScreen became a stateless renderer, while ListingsRoute connected it to a ViewModel. That gives navigation a natural place to attach: the screen can report that a listing was tapped, and the route can pass that event to the app’s navigation graph.

This lesson builds a two-destination marketplace flow: a listings destination and a detail destination that receives a listing ID. You’ll define destinations as Kotlin types, navigate without constructing route strings, and keep the stateless screen independent of NavController. Allow about 40 minutes, including time to run the flow in your project.


Why make destinations types?

A navigation graph defines the screens an app can show and how navigation reaches them. Older Compose navigation examples often identify destinations with strings such as "listing/{listingId}". That works, but the compiler cannot reliably check a hand-built route such as "listing/$id" against the destination’s expected arguments.

Type-safe Navigation Compose instead lets you declare a serializable Kotlin type for each destination. A destination with no arguments can be an object; one that needs a listing ID can be a data class. Navigation still handles route representations internally, but your code works with typed values.

Watch the selected portions of Navigation Compose meet Type Safety from Android Developers. Focus on the complete connection between a route declaration, a destination in NavHost, a navigation call, and argument retrieval.

Navigation Compose meet Type Safety

Watch Navigation Compose meet Type Safety from Android Developers for the motivation and the core API pattern we will adapt to listings.

Start with the motivation, which contrasts string routes with compile-time checking. Then watch defining destinations and passing arguments. Notice that the destination receives an ID, not a whole screen’s data.

Type-safe has a specific scope here: Kotlin can catch mistakes such as supplying the wrong type of argument or omitting a required constructor argument. It cannot prove that a listing ID exists in your data or that the current user may view it. Those remain application responsibilities.


Add the navigation and serialization setup

Type-safe routes require Navigation Compose 2.8.0 or later and the Kotlin serialization plugin. Since you already use a Gradle version catalog, keep the declarations there. The official Guide: Migrating to Type-Safe Navigation in Compose and Navigation 2 identifies the serialization setup in its Prerequisites section; read that short section before editing your build files.

Guide: Migrating to Type-Safe Navigation in Compose and Navigation 2  |  App architecture  |  Android Developers

Read the Android Developers guide for the serialization prerequisite behind typed destination declarations.

In Prerequisites, start at the setup notes. Read the libs.versions.toml example and the references to build.gradle.kts; stop before working through Step 1: Define Your Destinations. Focus on the Kotlin serialization plugin and JSON library. The lesson below also shows the Navigation Compose dependency needed by this project.

For example, add these entries to the existing sections of gradle/libs.versions.toml, rather than creating duplicate sections:

[versions]
navigation = "2.8.0"
serializationJson = "1.7.3"

[libraries]
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serializationJson" }

[plugins]
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

Here, kotlin refers to the Kotlin version entry already in your catalog. The versions shown are an example; retain newer compatible versions if your project already has them, and choose a serialization library version compatible with your Kotlin setup. In the app module’s build.gradle.kts, apply the plugin and add the libraries:

plugins {
    alias(libs.plugins.kotlin.serialization)
}

dependencies {
    implementation(libs.androidx.navigation.compose)
    implementation(libs.kotlinx.serialization.json)
}

Merge these lines into your existing plugins and dependencies blocks. If your root build file centrally declares plugin aliases with apply false, declare the serialization alias there as well. Sync Gradle before proceeding.


Define the destinations and their graph

Our listings destination needs no incoming argument. The detail destination needs the ID of the listing to display:

import kotlinx.serialization.Serializable

@Serializable
data object ListingsDestination

@Serializable
data class ListingDetailDestination(val listingId: String)

These types describe where the app can go, not what the screen currently displays. ListingsUiState remains the snapshot rendered by ListingsScreen; ListingDetailDestination carries just enough information to identify a detail destination. Using an ID rather than passing a whole ListingCardUiState also avoids treating a potentially stale screen snapshot as the detail screen’s source of truth.

A NavHost associates each destination type with composable content. Here is the graph we are building; the small detail screen keeps the navigation example runnable without introducing a data source:

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute

@Composable
fun MarketplaceNavHost(modifier: Modifier = Modifier) {
    val navController = rememberNavController()

    NavHost(
        navController = navController,
        startDestination = ListingsDestination,
        modifier = modifier
    ) {
        composable<ListingsDestination> {
            ListingsRoute(
                onListingClick = { listingId ->
                    navController.navigate(
                        ListingDetailDestination(listingId = listingId)
                    )
                }
            )
        }

        composable<ListingDetailDestination> { backStackEntry ->
            val destination =
                backStackEntry.toRoute<ListingDetailDestination>()

            ListingDetailScreen(
                listingId = destination.listingId,
                onBackClick = { navController.popBackStack() }
            )
        }
    }
}

@Composable
fun ListingDetailScreen(
    listingId: String,
    onBackClick: () -> Unit
) {
    Column(modifier = Modifier.padding(16.dp)) {
        Text("Listing: $listingId")
        Button(onClick = onBackClick) {
            Text("Back")
        }
    }
}

Three calls do the typed-navigation work:

  • composable<ListingDetailDestination> registers content for that destination type.
  • navigate(ListingDetailDestination(listingId = listingId)) supplies a destination instance, with its required argument.
  • backStackEntry.toRoute<ListingDetailDestination>() reconstructs that typed value when the destination is shown.

rememberNavController() belongs at the graph level here. The NavHost holds the destination wiring; neither ListingsScreen nor ListingDetailScreen needs to know about NavController. Calling popBackStack() returns from the detail destination to the preceding listings destination.

The detail screen displays the ID only to make argument passing visible. In a fuller feature, that ID would be used to obtain current listing details, with an appropriate state if the listing could not be found. Navigation itself does not fetch the listing.


Connect the existing route without changing who owns state

Now give the stateless listings screen a second callback. Keep its existing when (uiState) rendering, and add onListingClick to its parameters:

@Composable
fun ListingsScreen(
    uiState: ListingsUiState,
    onSaveClick: (String) -> Unit,
    onListingClick: (String) -> Unit,
    modifier: Modifier = Modifier
) {
    Column(modifier = modifier.padding(16.dp)) {
        when (uiState) {
            ListingsUiState.Loading -> {
                CircularProgressIndicator()
            }

            is ListingsUiState.Failure -> {
                Text(uiState.message)
            }

            is ListingsUiState.Content -> {
                if (uiState.items.isEmpty()) {
                    Text("No listings yet")
                } else {
                    uiState.items.forEach { item ->
                        Text(item.title)
                        Text(item.priceLabel)

                        Button(onClick = { onSaveClick(item.id) }) {
                            Text(if (item.isSaved) "Unsave" else "Save")
                        }
                        Button(onClick = { onListingClick(item.id) }) {
                            Text("View details")
                        }
                    }
                }
            }
        }
    }
}

The two callbacks report different interactions. onSaveClick reaches the ViewModel’s in-memory toggle, as before. onListingClick reports which item should be opened; it does not decide how navigation is implemented.

Update the existing route to forward that new callback while preserving its state-owner connection:

@Composable
fun ListingsRoute(
    onListingClick: (String) -> Unit,
    viewModel: ListingsViewModel = viewModel()
) {
    ListingsScreen(
        uiState = viewModel.uiState,
        onSaveClick = viewModel::toggleSaved,
        onListingClick = onListingClick
    )
}

The graph supplies onListingClick to ListingsRoute; the route supplies it to ListingsScreen. This is the boundary established in the previous lesson: the renderer reports an event, while its caller decides what to do with it. If you kept the previous ListingsScreenPreview, add onListingClick = {} to that preview call too. It can still render without a navigation controller.

To show this flow in the app, make MarketplaceNavHost() the composable displayed by your activity’s setContent block. Then run the app and tap View details on the sample listing. The detail screen should show listing-1; Back should return to the listings screen.

A useful final check is to temporarily try ListingDetailDestination(listingId = 42) or ListingDetailDestination() in your editor. Neither should compile: the route requires a String ID. Restore the valid call afterward. This is the practical benefit of the typed constructor—you find those particular mistakes before running the app.


Takeaways

You now have a typed Compose navigation flow. Serializable destination types define the graph, navigate() accepts a destination instance, and toRoute() retrieves its arguments. The listings screen remains reusable because it reports a listing tap through a callback rather than holding a NavController; its route and the graph handle the wiring.

The next lesson will apply a custom Material 3 theme to this app. The navigation flow gives you more than one destination on which to check that the visual treatment is consistent.

Can't find a good explanation? Sign up and we'll make it for you

Sign up