Does Jetpack Navigation meet Type Safety?

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.

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.

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

Issue 346475493 – reported

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.

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

Issue #346505952 – reported

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.

“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:

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

Issue 346475487 – reported

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.

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

Issue 339481310 – assigned

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.

External non-serializable type

Issue 341319151 – assigned

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

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.