Type Safety in Navigation Compose

Taking Navigation to the next level

Edgar Miró
Mercadona Tech
9 min readMay 8, 2024

--

The latest version of Navigation, 2.8.0-alpha08, brings a new way to declare navigation routes just by defining them as objects or classes. This approach ends with the need to work with strings in the route definition, interpolating strings, and encoding URLs.

I strongly recommend reading Ian Lake’s article: https://medium.com/androiddevelopers/navigation-compose-meet-type-safety-e081fb3cf2f8, where you can find the motivations behind this change.

We’re gonna create a project from zero and check if this approach is simplifying the future of Compose Navigation (As we expect).

You can check the sample project here: https://github.com/edgarmiro/compose-navigation-type-safety

Let’s take a look at it!

So, first things first, let’s create a new project by selecting the Empty Activity template.

Secondly, we leave the default project settings:

Android Studio will take some time downloading dependencies, syncing and indexing files and building Gradle stuff. After that we’ll be able to start.

Now that we have the project ready, we add the navigation dependency by adding the following lines to the libs.versions.toml file:

[versions]
...
navigationCompose = "2.8.0-alpha08"

[libraries]
...
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref="navigationCompose"}

And then we add the dependency in the build.gradle.kts (app) file:

dependencies {
...
implementation(libs.androidx.navigation.compose)

We’re totally ready to use it in our code. We’re going to start by creating the data. We create the Book model:

data class Book(val id: Int, val title: String)

And then we create some sample data to play with:

object SampleData {
val books = (0..25).map {
Book(it, "Book $it")
}

fun getBook(id: Int) = books.first { it.id == id }
}

Now, we create the first screen: a list of books. It’s a LazyColumn and a simple cell to represent each book. Clicking on a book will hoist that event to the caller:

@Composable
fun ListOfBooksScreen(
modifier: Modifier = Modifier,
onBookClick: (Book) -> Unit,
) {
LazyColumn(
modifier = modifier.fillMaxSize(),
) {
items(SampleData.books) {
BookCell(
modifier = Modifier.clickable(onClick = { onBookClick(it) }),
book = it,
)
}
}
}

@Composable
fun BookCell(book: Book, modifier: Modifier = Modifier) {
Text(
modifier = modifier.fillMaxWidth().padding(16.dp),
text = book.title,
)
}

And, of course, we create the book detail screen. Given an id, we recover the information of the book and show it in a composable:

@Composable
fun BookDetailScreen(
modifier: Modifier = Modifier,
bookId: Int,
) {
val book by remember { mutableStateOf(SampleData.getBook(bookId)) }

Column(
modifier = modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("Id: ${book.id}")
Text("Title: ${book.title}")
}
}

We have the model, sample data, and two screens. Now, we need the glue to join them all. Let’s add the Navigation composable. A click on a book in the list will open the detail screen with the book’s ID.

Old approach

Let’s go with the old approach first (Using strings):

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

NavHost(navController, startDestination = "list-of-books") {
composable(route = "list-of-books") {
ListOfBooksScreen(
modifier = modifier,
onBookClick = { navController.navigate("book-detail/${it.id}") },
)
}

composable(
route = "book-detail/{bookId}",
arguments = listOf(
navArgument("bookId") {
type = NavType.IntType
}
),
) { backStackEntry ->
val bookId = requireNotNull(backStackEntry.arguments?.getInt("bookId"))

BookDetailScreen(
modifier = modifier,
bookId = bookId,
)
}
}
}

The only thing left is to start using the Navigation component in the MainActivity.

setContent {
ComposeNavigationTypeSafetyTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Navigation(modifier = Modifier.padding(innerPadding))
}
}
}

If we ran the application we’d see how it works and shows the book detail properly:

It isn’t the most beautiful app in the world but, speaking about trying the new navigation, who cares? ;)

Notice, even though we’re just passing the book ID to the detail screen, we are retrieving the whole book info with this line in BookDetailScreen component:

val book by remember { mutableStateOf(SampleData.getBook(bookId)) }

New approach

So far, so good, but it was the old-navigation-compose way.Let’s explore the new way with type safety. For that reason, we add the kotlinx-serialization dependency:

[libraries]
...
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version = "1.6.3" }


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


dependencies {
...
implementation(libs.kotlinx.serialization.json)
}

We’re going to create a new Navigation component to try the new API:

@Serializable
object ListOfBooks

@Serializable
data class BookDetail(val id: Int)

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

NavHost(navController, startDestination = ListOfBooks) {
composable<ListOfBooks> {
ListOfBooksScreen(
modifier = modifier,
onBookClick = { navController.navigate(BookDetail(it.id)) },
)
}

composable<BookDetail> { backStackEntry ->
val bookDetail = backStackEntry.toRoute<BookDetail>()

BookDetailScreen(
modifier = modifier,
bookId = bookDetail.id,
)
}
}
}

Key points:

  • Notice the @Serializable entities, which represent the routes on each screen and can contain the navigation arguments.
  • startDestination uses the object ListOfBooks directly.
  • The navController.navigate method creates a new BookDetail using the id of the selected book.
  • The backStackEntry.toRoute method retrieves the BookDetail created previously.

Let’s use this component in the MainActivity to replace the old Navigation:

setContent {
ComposeNavigationTypeSafetyTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
TypeSafetyNavigation(modifier = Modifier.padding(innerPadding))
}
}
}

If we launch the application, we’ll see how everything works as expected while we’ve simplified the navigation code a lot.

Custom types

It’s such a significant improvement, but something bothers me: why do we need to pass just an ID and retrieve the whole model information in the detail screen if we have it in the list?

Of course, this is totally discouraged when using complex and huge models but we’re using an integer and a string. Would it be possible to pass the whole book as an argument? Let’s see.

Instead of using the id in the BookDetail data class, let’s add a book:

@Serializable
data class BookDetail(val book: Book)

Now we’re getting this compilation error:

Serializer has not been found for type 'Book'. To use context serializer as fallback, explicitly annotate type or property with @Contextual

This is due to the fact that the Book model is not annotated with @Serializable so let’s fix it:

@Serializable
data class Book(val id: Int, val title: String)

We also have to change a couple of things in the TypeSafetyNavigation component.

Now we pass the whole book:

onBookClick = { navController.navigate(BookDetail(it)) },

And change the BookDetail navigation:

composable<BookDetail> { backStackEntry ->
val bookDetail = backStackEntry.toRoute<BookDetail>()

BookDetailScreen(
modifier = modifier,
bookId = bookDetail.book,
)
}

Previously we were using just the id, but now that we already have the selected book, let’s change BookDetailScreen:

@Composable
fun BookDetailScreen(
modifier: Modifier = Modifier,
book: Book,
) {
Column(
modifier = modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("Id: ${book.id}")
Text("Title: ${book.title}")
}
}
  • We’ve replaced the bookId: Int param with book: Book.
  • We’ve removed the book retrievement.

Ok, everything compiles so let’s run the app and… OOPS!!!

java.lang.IllegalArgumentException: Cannot cast book of type com.example.composenavigation.typesafety.Book to a NavType. Make sure to provide custom NavType for this argument.

Having it working in such a simple way, as compose-navigation now requires Kotlin serialization, would have been amazing, right? Sadly, it doesn’t work in that way.

Anyway, this was just a demonstration that we can’t do that. In the article, they say we have to perform some changes to achieve it. Let’s do it:

First of all, we added the parcelize plugin to the build.gradle(app) file:

plugins {
...
id("kotlin-parcelize")
}

Then, we make our model parcelable by annotating the Book class with @Parcelize and implementing the Parcelable interface:

@Serializable
@Parcelize
data class Book(val id: Int, val title: String): Parcelable

Now, we’re going to create the custom NavType for our Book class:

val BookType = object : NavType<Book>(
isNullableAllowed = false
) {
override fun get(bundle: Bundle, key: String): Book? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
bundle.getParcelable(key, Book::class.java)
} else {
@Suppress("DEPRECATION")
bundle.getParcelable(key)
}
}

override fun parseValue(value: String): Book {
return Json.decodeFromString<Book>(value)
}

override fun serializeAsValue(value: Book): String {
return Json.encodeToString(value)
}

override fun put(bundle: Bundle, key: String, value: Book) {
bundle.putParcelable(key, value)
}
}

An essential point here is overriding the method serializeAsValue which has a default implementation in the NavType abstract class, hence it’s not mandatory to implement it. Without it, when navigating to the destination, the following exception will be raised:

java.lang.IllegalArgumentException: Navigation destination that matches request NavDeepLinkRequest

This is something not explained neither in the article nor in the documentation but fortunately, a kind person in Google helped by answering this reported bug. (Thanks again!)

Now, we use our custom BookType in the navigation route by setting typeMap:

composable<BookDetail>(
typeMap = mapOf(typeOf<Book>() to BookType)
) { backStackEntry ->
val book = backStackEntry.toRoute<BookDetail>().book

...

And voilà! Now it’s working. Could we simplify the code a little bit? Let’s try creating a builder function to hide the boilerplate:

inline fun <reified T : Parcelable> parcelableType(
isNullableAllowed: Boolean = false,
json: Json = Json,
) = object : NavType<T>(isNullableAllowed = isNullableAllowed) {
override fun get(bundle: Bundle, key: String) =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
bundle.getParcelable(key, T::class.java)
} else {
@Suppress("DEPRECATION")
bundle.getParcelable(key)
}

override fun parseValue(value: String): T = json.decodeFromString(value)

override fun serializeAsValue(value: T): String = json.encodeToString(value)

override fun put(bundle: Bundle, key: String, value: T) = bundle.putParcelable(key, value)
}

Now, we just have to use it in the navigation component:

composable<BookDetail>(
typeMap = mapOf(typeOf<Book>() to parcelableType<Book>())
) {
...

Retrieve data in the ViewModel

But, what if I wanted to get the selected book directly in the ViewModel? No worries, there is an extension method to get the nav args from SavedStateHandle.

I’m going to omit the whole Hilt set up but feel free to check the commit which adds it to our repository.

So this is the resulting ViewModel. Notice we can use the toRoute method to retrieve the navigation arguments:

@HiltViewModel
class BookDetailViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
) : ViewModel() {

private val _book = MutableStateFlow(savedStateHandle.toRoute<BookDetail>().book)
val book = _book.asStateFlow()
}

That way we can remove the retrievement in the navigation composable:

composable<BookDetail>(
typeMap = mapOf(typeOf<Book>() to parcelableType<Book>())
) {
BookDetailScreen(modifier = modifier)
}

And of course, observe the state it in our beloved screen:

@Composable
fun BookDetailScreen(
modifier: Modifier = Modifier,
viewModel: BookDetailViewModel = hiltViewModel(),
) {
val book by viewModel.book.collectAsStateWithLifecycle()

Column(
modifier = modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("Id: ${book.id}")
Text("Title: ${book.title}")
}
}

“Yeah, ok. It’s working but I don’t like Parcelables. Why do we need to work with Serializable and Parcelable at the same time”.

No problem here we have another builder function for you, to work just with @Serializable:

inline fun <reified T : Any> serializableType(
isNullableAllowed: Boolean = false,
json: Json = Json,
) = object : NavType<T>(isNullableAllowed = isNullableAllowed) {
override fun get(bundle: Bundle, key: String) =
bundle.getString(key)?.let<String, T>(json::decodeFromString)

override fun parseValue(value: String): T = json.decodeFromString(value)

override fun serializeAsValue(value: T): String = json.encodeToString(value)

override fun put(bundle: Bundle, key: String, value: T) {
bundle.putString(key, json.encodeToString(value))
}
}

Again, just replace use it in the navigation component:

composable<BookDetail>(
typeMap = mapOf(typeOf<Book>() to serializableType<Book>())
) {
...

We run the app but… again OOPS!!! Another runtime error when navigating to the book detail:

java.lang.ClassCastException: java.lang.String cannot be cast to com.example.composenavigation.typesafety.Book

After reporting the bug, it seems like there’s an error with the savedStateHandle.toRoute method and our beloved-kind-unknown-google-person is addressing it. (Thanks again!)

Update about this bug: It was fixed and it’s available from navigation 2.8.0-beta01.

Applying the new version (With the fix)

To use custom navTypes in the toRoute method, we’ll apply some changes:

In our navigation-args data class, we’ll add this companion object:

@Serializable
data class BookDetail(val book: Book) {
companion object {
val typeMap = mapOf(typeOf<Book>() to serializableType<Book>())

fun from(savedStateHandle: SavedStateHandle) =
savedStateHandle.toRoute<BookDetail>(typeMap)
}
}

Therefore, we can simplify our navigation code:

composable<BookDetail>(
typeMap = BookDetail.typeMap
) {
BookDetailScreen(modifier = modifier)
}

And now, in our ViewModel, we retrieve the navigation-args:

private val bookDetail = BookDetail.from(savedStateHandle)

private val _book = MutableStateFlow(bookDetail.book)
val book = _book.asStateFlow()

Conclusions

  • Jetpack Navigation Compose is changing. And this change is what Compose needs.
  • The new way uses kotlinx.serialization to handle the navigation routes as well as nav arguments.
  • Using complex data as nav args doesn’t work so straightforwardly. We have to extend the NavType class to handle it.
  • It’s an alpha version. Of course, the API could change before going stable, so use it responsibly.

--

--