You may have heard that Jetpack Navigation has a new API for type-safe navigation. This new official solution brings navigation with a “destination object” instead of simple manual URL building. Previously, I developed kiwicom/navigation-compose-typed library that did the same and was an inspiration for the official solution.
The official solution makes navigation easier than before, but is it type-safe? Sadly, the new API is not compile-time type-safe. And that’s a bummer because when we talk about type-safety, we expect a compile-time type-safety.
I’m going to give you eight examples of where the compile-time safety of the API can fail you. Let’s dive in.
Custom NavTypes
Let me start with a simple navigation object with a nested object in it.
@Serializable
data class IssueNested(
val nested: Nested,
) {
data class Nested(
val id: Int,
)
}
The advantage of using KotlinX.Serialization is IDE support and compile-time support that will tell you that the nested object Nested
is missing the needed @Serializable
annotation.
![](https://hrach.dev/wp-content/uploads/sites/3/2024/06/image.png)
Compile time error: Serializer has not been found for type 'dev.hrach.navigation.demo.IssueNested.Nested'
.
So let’s add it. But that is not enough with Jetpack Navigation Compose. It will crash in runtime (during the NavGraph’s construction) because the Nested type has to have defined a custom NavType. Sadly, that is needed for every custom type and many built-in types. Only some primitives, an array, or a list of primitives are supported. Everything else is unsupported and it will crash in runtime.
java.lang.IllegalArgumentException: Cannot cast nested of type dev.hrach.navigation.demo.IssueNested.Nested to a NavType. Make sure to provide custom NavType for this argument
.
To fix this, you have to define “serialization” in your custom NavType instance. And pass a type map for the specific destination. (Let’s not discuss the implementation of NestedNavType today).
composable<Destinations.IssueNested>(
typeMap = mapOf(
typeOf<Destinations.IssueNested.Nested>() to NestedNavType()
)
) { }
Enum
Another unsupported type is Enum: a powerful language construct and a very widespread type in navigation.
@Serializable
data class IssueEnum(
val priority: Priority,
) {
enum class Priority { Top, Normal }
}
Once again, KotlinX.Serialization
is able to handle enums automatically (no @Serializable
annotation is needed, actually) but not Jetpack Navigation Compose.
java.lang.IllegalArgumentException: Cannot cast priority of type dev.hrach.navigation.demo.IssueEnum.Priority to a NavType. Make sure to provide custom NavType for this argument.
There is already a predefined EnumNavType
you may utilize to make this work:
composable<IssueEnum>(
typeMap = mapOf(
typeOf<IssueEnum.Priority>() to NavType.EnumType(IssueEnum.Priority::class.java)
)
) { }
Enums in the minified build
So enums should now work properly, right? Well, they may not. If you compile your app with R8 (a standard Android code minifier), the code will crash in production.
Why? Well, some optimizations are done and in the end, there are different Serializer instances for the enum. That does not matter much. But, because Jetpack Navigation Compose internally depends on related implementation details, it crashes.
java.lang.IllegalArgumentException: Cannot cast priority of type dev.hrach.navigation.demo.Destinations.IssueEnum.Priority to a NavType. Make sure to provide custom NavType for this argument.
“You copied a wrong exception here. We already saw that one.” Well, no. This is the actual exception in the minidifed build. I had to debug the internals of Jetpack Navigation Compose to understand what was happening.
The solution is to add @Serializable
annotation to the enum. KotlinX.Serialization compiler plugin does not require it, yet you must do it.
URL encoding
Issue 344943214 – works as intended, just kdoc will be added
The underlying implementation of a type-safe object still depends on the URL as a transfer medium. It seems that the nav type is responsible for the conversion of Any
type to string and back. But it is not the case exactly. NavType is also responsible for encoding the string to be correctly escaped in the URL.
Yes, you read it correctly, only encoding; decoding is doing another layer inside Jetpack Navigation. Do not forget to Uri.encode()
the resulting string.
@Serializable
data class IssueEncoding(
val nested: Nested,
) {
@Serializable
data class Nested(
val review: String,
)
}
class NestedNavType : NavType<IssueEncoding.Nested> {
// ...
override fun serializeAsValue(value: IssueEncoding.Nested): String {
return value.review
}
}
composable<IssueEncoding>(
typeMap = mapOf(
typeOf<IssueEncoding.Nested>() to NestedNavType(),
)
) { }
If you forget to do so, you will end up with crashes/exceptions like this:
java.lang.IllegalArgumentException: Navigation destination that matches request NavDeepLinkRequest{ uri=android-app://androidx.navigation/dev.hrach.navigation.demo.IssueEncoding/I give 1 / 5 stars. } cannot be found in the navigation graph a(0x0) startDestination={b(0xb55dbed) route=dev.hrach.navigation.demo.Home}
But, only if there is be invalid character for the URL construction/matching. So discovering this implementation bug may come quite late, e.g. after production release. Fix this way:
class NestedNavType : NavType<IssueEncoding.Nested> {
// ...
override fun serializeAsValue(value: IssueEncoding.Nested): String {
return Uri.encode(value.review)
}
}
Previously, this was not documented in NavType’s KDoc. It was fixed after my report.
Navigation with Any
From the beginning, it seemed to me to be a great idea not to have any interface for the destination object, i.e. simply allow navigation with any serializable object. (In contrast to kiwicom/navigation-compose-typed where we had a Destination interface.)
In Kotlin, we do not have any interface marking a serializable object, we miss something like Codeable
from Swift. So the API allows navigation with anything – `Any`. E.g. ints.
This kind of mistake crashes in the runtime after the navigation is executed. You should go through your whole codebase to be sure no crashes are there.
But that is not all. Let’s have this simple destination target:
@Serializable
data object IssueObjectThenClass
Button(onClick = { navController.navigate(IssueObjectThenClass) })
That works quite nicely. But, later you remember that you need to pass other values. So you go and change the data object to a data class:
@Serializable
data class IssueObjectThenClass(
val email: String,
)
The code complies, so it must be correct. You click the button and it crashes.
kotlinx.serialization.SerializationException: Serializer for class 'Companion' is not found.
Please ensure that class is marked as '@Serializable' and that the serialization compiler plugin is applied.
Sadly, navController.navigate(IssueObjectThenClass)
is still a valid code that compiles. Now it references a (non-existent?) Companion
object and that one is not serializable. That’s why you see such a strange exception message.
Empty String
Let’s have an object with a string property. This time, no NavType is needed.
@Serializable
data class IssueEmptyString(
val review: String,
)
Then simply navigate:
Button(onClick = { navigate(Destinations.IssueEmptyString("")) })
and we have another crash. The string types are put into the URL’s path, but when they’re empty, the path gets malformed and is not matched. That’s the reason kiwicom/navigation-compose-typed was considering Strings always as optional arguments.
java.lang.IllegalArgumentException: Navigation destination that matches request NavDeepLinkRequest{ uri=android-app://androidx.navigation/dev.hrach.navigation.demo.IssueEmptyString/ } cannot be found in the navigation graph ComposeNavGraph(0x0) startDestination={Destination(0x2df0a22) route=dev.hrach.navigation.demo.Home}
External non-serializable type
This issue will be fixed, but for now (2.8.0-beta02), you need to work around it.
Let’s have an external type. KotlinX.Serialization forces you to define a serializer. (A compile-time check).
import java.time.LocalDate
@Serializable
data class IssueExternalType(
@Serializable(with = CustomLocalDateSerializer::class)
val born: LocalDate,
)
class CustomLocalDateSerializer : KSerializer<LocalDate> {
override fun deserialize(decoder: Decoder): LocalDate =
LocalDate.parse(decoder.decodeString())
override fun serialize(encoder: Encoder, value: LocalDate) {
encoder.encodeString(value.toString())
}
}
But we already have learned that it is not enough, so we had to introduce also a custom NavType. But it is still not enough. The NavGraph’s initialization will crash with
kotlinx.serialization.SerializationException: Serializer for class 'LocalDate' is not found.
Please ensure that class is marked as '@Serializable' and that the serialization compiler plugin is applied.
You may workaround it with a custom wrapper (and once another custom NavType) or by simply using string instead of those types.
Double-encoding
The LocalDateNavType
from the last example could look like this
class LocalDateNavType : NavType<LocalDate>(isNullableAllowed = false) {
override fun get(bundle: Bundle, key: String): LocalDate? =
bundle.getString(key).let(LocalDate::parse)
override fun parseValue(value: String): LocalDate =
LocalDate.parse(value)
override fun put(bundle: Bundle, key: String, value: LocalDate) {
bundle.putString(key, value.toString())
}
override fun serializeAsValue(value: LocalDate): String {
return Uri.encode(value.toString())
}
}
The number of methods may raise suspicion. You would be correct. The flow of how value is encoded and decoded is the following:
- a value
- is
serializeAsValue
() to URL - then it is parsed from the URL as a string
- and converted by
parseValue()
to LocalDate, but - the data are returned through the Bundle instance, therefore
put()
is called with LocalDate; - because Bundle can carry only primitive types (or similar), we must serialize LocalDate once again
- and then the value is “extracted” to the resulting object using
get()
, where we deserialize the second time.
Conclusion
I failed hard to learn this. The new API is NOT TYPE SAFE in the way you would expect.
Please, be aware of those quirks that make the usage prone to error. It is a better situation than what we had built in previously, but from my POV, it is not comparable to the safety you got with kiwicom/navigation-compose-typed. All those issues would not be present in that library.