Type safe navigation for Compose

Don Turner
Android Developers
Published in
12 min readSep 5, 2024

--

With the latest release of Jetpack Navigation 2.8.0, the type safe navigation APIs for building navigation graphs in Kotlin are stable 🎉. This means that you can define your destinations using serializable types and benefit from compile-time safety.

This is great news if you’re using Jetpack Compose for your UI because it’s simpler and safer to define your navigation destinations and arguments.

The design philosophy behind these new APIs is covered in this blog post which accompanied the first release they appeared in — 2.8.0-alpha08.

Since then, we’ve received and incorporated lots of feedback, fixed some bugs and made several improvements to the API. This article covers the stable API and points out changes since the first alpha release. It also looks at how to migrate existing code and provides some tips on testing navigation use cases.

The basics

The new type safe navigation APIs for Kotlin allow you to use Any serializable type to define navigation destinations. To use them, you’ll need to add the Jetpack navigation library version 2.8.0 and the Kotlin serialization plugin to your project.

Once done, you can use the @Serializableannotation to automatically create serializable types. These can then be used to create a navigation graph.

The remainder of this article assumes you’re using Compose as your UI framework (by including navigation-compose in your dependencies), although the examples should work equally well with Fragments (with some slight differences). If you’re using both, we have some new interop APIs for that too.

A good example of one of the new APIs is composable. It now accepts a generic type which can be used to define a destination.

@Serializable data object Home

NavHost(navController, startDestination = Home) {
composable<Home> {
HomeScreen()
}
}

Nomenclature is important here. In navigation terms, Home is a route which is used to create a destination. The destination has a route type and defines what will be displayed on screen at that destination, in this case HomeScreen.

These new APIs can be summarized as: Any method that accepts a route now accepts a generic type for that route. The examples that follow use these new methods.

Passing data between destinations

One of the primary benefits of these new APIs is the compile-time safety provided by using types for navigation arguments. For basic types, it’s super simple to pass them to a destination.

Let’s say we have an app which displays products on the Home screen. Clicking on any product displays the product details on a Product screen.

Navigation graph with two destinations: Home and Product

We can define the Product route using a data class which has a String id field which will contain the product ID.

@Serializable data class Product(val id: String)

By doing so, we’re establishing a couple of navigation rules:

  • The Product route must always have an id
  • The type of id is always a String

You can use any basic type as a navigation argument, including lists and arrays. For more complex types, see the “Custom types” section of this article.

New since alpha: Nullable types are supported.

New since alpha: Enums are supported (although you’ll need to use @Keep on the enum declaration to ensure that the enum class is not removed during minified builds, tracking bug)

Obtaining the route at the destination

When we use this route to define a destination in our navigation graph, we can obtain the route from the back stack entry using toRoute. This can then be passed to whatever is needed to render that destination on screen, in this case ProductScreen. Here’s how our destination is implemented:

composable<Product> { backStackEntry ->
val product : Product = backStackEntry.toRoute()
ProductScreen(product)
}

New since alpha: If you’re using a ViewModel to provide state to your screen, you can also obtain the route from savedStateHandle using the toRoute extension function.

ProductViewModel(private val savedStateHandle: SavedStateHandle, …) : ViewModel {
private val product : Product = savedStateHandle.toRoute()
// Set up UI state using product
}

Note on testing: As of release 2.8.0, SavedStateHandle.toRoute is dependent on Android Bundle. This means your ViewModel tests will need to be instrumented (e.g. by using Robolectric or by running them on an emulator). We’re looking at ways we can remove this dependency in future releases (tracked here).

Passing data when navigating

Using the route to pass navigation arguments is simple — just use navigate with an instance of the route class.

navController.navigate(route = Product(id = "ABC"))

Here’s a complete example:

NavHost(
navController = navController,
startDestination = Home
) {
composable<Home> {
HomeScreen(
onProductClick = { id ->
navController.navigate(route = Product(id))
}
)
}
composable<Product> { backStackEntry ->
val product : Product = backStackEntry.toRoute()
ProductScreen(product)
}
}

Now that you know how to pass data between screens inside your app, let’s look at how you can navigate and pass data into your app from outside.

Straight to your destination with deep links

Sometimes you want to take users directly to a specific screen within your app, rather than starting at the home screen. For example, if you’ve just sent them a notification saying “check out this new product”, it makes perfect sense to take them straight to that product screen when they tap on the notification. Deep links enable you to do this.

Here’s how you add a deep link to the Product destination mentioned above:

composable<Product>(
deepLinks = listOf(
navDeepLink<Product>(
basePath = "www.hellonavigation.example.com/product"
)
)
) {

}

navDeepLink is used to construct the deep link URL from both the class, in this case Product, and the supplied basePath. Any fields from the supplied class are automatically included in the URL as parameters. The generated deep link URL is:

www.hellonavigation.example.com/product/{id}

To test it, you could use the following adb command:

adb shell am start -a android.intent.action.VIEW -d "https://www.hellonavigation.example.com/product/ABC" com.example.hellonavigation

This will launch the app directly on the Product destination with the Product.id set to “ABC”.

URL parameter types

We’ve just seen an example of the navigation library automatically generating a deep link URL containing a path parameter. Path parameters are generated for required route arguments. Looking at our Product again:

@Serializable data class Product(val id: String)

The id field is mandatory so the deep link URL format of /{id} is appended to the base path. Path parameters are always generated for route arguments, except when:

1. the class field has a default value (the field is optional), or

2. the class field represents a collection of primitive types, like a List<String> or Array<Int> (full list of supported types, add your own by extending CollectionNavType)

In each of these cases, a query parameter is generated. Query parameters have a deep link URL format of ?name=value.

Here’s a summary of the different types of URL parameter:

Path and query parameters

New since alpha: Empty strings for path parameters are now supported. In the above example, if you use a deep link URL of www.hellonavigation.example.com/product// then the id field would be set to an empty string.

Testing deep links

Once you’ve set up your app’s manifest to accept incoming links, an easy way to test your deep links is to use adb. Here’s an example (note that & is escaped):

adb shell am start -a android.intent.action.VIEW -d “https://hellonavigation.example.com/product/ABC?color=red\&variants=var1\&variants=var2" com.example.hellonavigation

🐞Debugging tip: If you ever want to check the generated deep link URL format, just print the NavBackStackEntry.destination.route from your destination and it’ll appear in logcat when you navigate to that destination:

composable<Product>( … ) { backStackEntry ->
println(backStackEntry.destination.route)
}

Testing navigation

We’ve already touched on how you can test deep links using adb but let’s dive a bit deeper into how you can test your navigation code. Navigation tests are usually instrumented tests which simulate the user navigating through your app.

Here’s a simple test which verifies that when you tap on a product button, the product screen is displayed with the correct content.

@RunWith(AndroidJUnit4::class)
class NavigationTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<MainActivity>()

@Test
fun onHomeScreen_whenProductIsTapped_thenProductScreenIsDisplayed() {
composeTestRule.apply {
onNodeWithText("View details about ABC").performClick()
onNodeWithText("Product details for ABC").assertExists()
}
}
}

Essentially, you are not interacting with your navigation graph directly — instead, you are simulating user input in order to assert that your navigation routes lead to the correct content.

🐞Debugging tip: If you ever want to pause an instrumented test but still interact with the app, you can use composeTestRule.waitUntil(timeoutMillis = 3_600_000, condition = { false }). Paste this into a test right before a failure point, then poke around with the app to try to understand why the test fails (you have an hour — hopefully long enough to figure it out!). The layout inspector even works at the same time. You can also just wrap this in a single test if you want to investigate the app’s state with only the test setup code. This is particularly useful when your instrumented app uses fake data which might cause differences in behavior from your production build.

Migrating existing code

If you’re already using Jetpack Navigation and defining your navigation graph using the Kotlin DSL, you will likely want to update your existing code. Let’s look at two popular migration use cases: string-based routes and top level navigation UI.

From Strings to Any…thing

In previous releases of Navigation Compose, you needed to define your routes and navigation argument keys as strings. Here’s an example of a product route defined this way.

const val PRODUCT_ID_KEY = "id"
const val PRODUCT_BASE_ROUTE = "product/"
const val PRODUCT_ROUTE = "$PRODUCT_BASE_ROUTE{$PRODUCT_ID_KEY}"

// Inside NavHost
composable(
route = PRODUCT_ROUTE,
arguments = listOf(
navArgument(PRODUCT_ID_KEY) {
type = NavType.StringType
nullable = false
}
)
) { entry ->
val id = entry.arguments?.getString(PRODUCT_ID_KEY)
ProductScreen(id = id ?: "Not found")
}

// When navigating to Product destination
navController.navigate(route = "$PRODUCT_BASE_ROUTE$productId")

Note how the type of the id argument is defined in multiple places (NavType.StringType and getString). The new APIs allow us to remove this duplication.

To migrate this code, create a serializable class for the route (or an object if it has no arguments).

@Serializable data class Product(val id: String)

Replace any instances of the string-based route used to create destinations with the new type, and remove any arguments:

composable<Product> { … }

When obtaining arguments, use toRoute to obtain the route object or class.

composable<Product> { backStackEntry ->
val product : Product = backStackEntry.toRoute()
ProductScreen(product.id)
}

Also replace any instances of the string-based route when calling navigate:

navController.navigate(route = Product(id))

OK, we’re done! We’ve been able to remove the string constants and boilerplate code, and also introduced type safety for navigation arguments.

Incremental migration

You don’t have to migrate all your string-based routes in one go. You can use methods which accept a generic type for the route interchangeably with methods which accept a string-based route, as long as your string format matches that generated by the Navigation library from your route types.

Put another way, the following code will still work as expected after the migration above:

navController.navigate(route = “product/ABC”)

This lets you migrate your navigation code incrementally rather than being an “all or nothing” procedure.

Top level navigation UI

Most apps will have some form of navigation UI which is always displayed, allowing users to navigate to different top level destinations.

Material 3 Navigation Rail

A crucial responsibility for this navigation UI is to display which top level destination the user is currently on. This is usually done by iterating through the top-level destinations and checking whether its route is equal to any route in the current back stack.

For the following example, we’ll use NavigationSuiteScaffold which displays the correct navigation UI depending on the available window size.

const val HOME_ROUTE = "home"
const val SHOPPING_CART_ROUTE = "shopping_cart"
const val ACCOUNT_ROUTE = "account"

data class TopLevelRoute(val route: String, val icon: ImageVector)

val TOP_LEVEL_ROUTES = listOf(
TopLevelRoute(route = HOME_ROUTE, icon = Icons.Default.Home),
TopLevelRoute(route = SHOPPING_CART_ROUTE, icon = Icons.Default.ShoppingCart),
TopLevelRoute(route = ACCOUNT_ROUTE, icon = Icons.Default.AccountBox),
)

// Inside your main app layout
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination

NavigationSuiteScaffold(
navigationSuiteItems = {
TOP_LEVEL_ROUTES.forEach { topLevelRoute ->
item(
selected = currentDestination?.hierarchy?.any {
it.route == topLevelRoute.route
} == true,
icon = {
Icon(
imageVector = topLevelRoute.icon,
contentDescription = topLevelRoute.route
)
},
onClick = { navController.navigate(route = topLevelRoute.route) }
)
}
}
) {
NavHost(…)
}

In the new type safe APIs, you don’t define your top level routes as strings, so you can’t use string comparison. Instead, use the new hasRoute extension function on NavDestination to check whether a destination has a specific route class.

@Serializable data object Home
@Serializable data object ShoppingCart
@Serializable data object Account

data class TopLevelRoute<T : Any>(val route: T, val icon: ImageVector)

val TOP_LEVEL_ROUTES = listOf(
TopLevelRoute(route = Home, icon = Icons.Default.Home),
TopLevelRoute(route = ShoppingCart, icon = Icons.Default.ShoppingCart),
TopLevelRoute(route = Account, icon = Icons.Default.AccountCircle)
)

// Inside your main app layout
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination

NavigationSuiteScaffold(
navigationSuiteItems = {
TOP_LEVEL_ROUTES.forEach { topLevelRoute ->
item(
selected = currentDestination?.hierarchy?.any {
it.hasRoute(route = topLevelRoute.route::class)
} == true,
icon = {
Icon(
imageVector = topLevelRoute.icon,
contentDescription = topLevelRoute.route::class.simpleName
)
},
onClick = { navController.navigate(route = topLevelRoute.route)}
)
}
}
) {
NavHost(…)
}

Things to watch out for…

It’s easy to confuse classes and object destinations

Can you spot the problem with the following code?

@Serializable 
data class Product(val id: String)

NavHost(
navController = navController,
startDestination = Product
) { … }

It’s not immediately obvious but if you were to run it, you’d see the following error:

kotlinx.serialization.SerializationException: Serializer for class ‘Companion’ is not found.

This is because Product is not a valid destination, only an instance of Product is (e.g. Product(“ABC”)). The above error message is confusing until you realize that the serialization library is looking for the statically initialized Companion object of the Product class which isn’t defined as serializable (in fact, we didn’t define it at all, the Kotlin compiler added it for us), and hence doesn’t have a corresponding serializer.

New since alpha: A lint check was added to spot places where an incorrect type is being used for the route. When you try to use the class name instead of the class instance, you’ll receive a helpful error message: “The route should be a destination class instance or destination object.”. A similar lint check when using popBackStack will be added in the 2.8.1 release.

Don’t create duplicate destinations

Using duplicate destinations used to result in undefined behavior. This has now been fixed, and (new since alpha) navigating to a duplicate destination will now navigate to the nearest destination in the navigation graph which matches, relative to your current destination.

That said, it’s still not recommended to create duplicate destinations in your navigation graph due to the ambiguity it creates when navigating to one of those destinations. If the same content should appear in two destinations, create a separate destination class for each one and just use the same content composable.

Be careful of “null” strings (for now)

Currently, if you have a route with a String argument and its value is set to the string literal “null”, the app will crash when navigating to that destination. This issue will be fixed in 2.8.1, due in a couple of weeks.

In the meantime, if you have unsanitized input to a String route argument, perform a check for “null” first to avoid the crash.

TransactionTooLargeException

Don’t use large objects as routes as you may run into TransactionTooLargeException. When navigating, the route is saved to persist system-initiated process death and the saving mechanism is a binder transaction. Binder transactions have a 1MB buffer so large objects can easily fill this buffer.

You can avoid using large objects for routes by storing data using a storage mechanism designed for large data, such as Room or DataStore. When inserting data, obtain a unique reference, such as an ID field. You can then use this, much smaller, unique reference in the route. Use the reference to obtain the data at the destination.

Summary

That’s about it for the new type safe navigation APIs. Here’s a quick summary of the most important functions.

  • Define destinations using composable<T> (or navigation<T> for nested graphs)
  • Navigate to a destination using navigate(route = T) for object routes or navigate(route = T(…)) for class instance routes
  • Obtain a route from a NavBackStackEntry or SavedStateHandle using toRoute<T>
  • Check whether a destination was created using a given route using hasRoute(route = T::class)

You can check out a working implementation of these APIs in the Now in Android app. The migration from string-based routes happened in this pull request.

We’d love to hear your thoughts on these APIs. Feel free to leave a comment, or if you have any issues please file a bug. You can read more about how to use Jetpack Navigation in the official documentation.

The code snippets in this article have the following license:

// Copyright 2024 Google LLC. SPDX-License-Identifier: Apache-2.0

--

--