Type Safety に Navigation Compose を使う場合、SavedStateHandle 経由で NavArg を受け取るときは typeMap を渡そう

# 結論

**Type Safety に Navigation Compose を使う場合、SavedStateHandle 経由で NavArg を受け取るときは `typeMap` を渡しましょう！**

Type Safe な Navigation Compose に関するドキュメントはこちら 👇
[Type safety in Kotlin DSL and Navigation Compose](https://developer.android.com/guide/navigation/design/type-safety)

# ハマっていたこと

NavGraph を定義するとき、`typeMap` を渡しているのにアプリがランタイムで落ちる事象が発生しました。

具体的には次のエラーメッセージを確認しました。

```text
java.lang.IllegalArgumentException: Cannot cast page of type XXX to a NavType. Make sure to provide custom NavType for this argument.
```

サンプルコードは次のとおりです。

<details>
<summary>Arg.kt</summary>

```Arg.kt
enum class Arg {
  X,
  Y,
}
```

</details>

<details>
<summary>Route.kt</summary>

```Route.kt
sealed interface Route {
  data object Screen1 : Route

  data class Screen2(
    val arg: Arg,
  ) : Route
}
```

</details>

<details>
<summary>Screen1.kt</summary>

```Screen1.kt
fun NavGraphBuilder.screen1(
  onClick: () -> Unit,
) {
  composable<Route.Screen1> {
    Screen1(onClick = onClick)
  }
}

@Composable
fun Screen1(
  onClick: () -> Unit,
) {
  Button(onClick = onClick) {
    Text(text = "Screen2 に遷移")
  }
}
```

</details>

<details>
<summary>Screen2ViewModel.kt</summary>

```Screen2ViewModel.kt
@HiltViewModel
class Screen2ViewModel @Inject constructor(
  savedStateHandle: SavedStateHandle,
) : ViewModel() {
  // 🚨 ここでも typeMap を渡す必要がある
  val navArg = savedStateHandle.toRoute<Route.Screen2>()
}
```

</details>

<details>
<summary>Screen2.kt</summary>

```Screen2.kt
fun NavGraphBuilder.screen2() {
  // 🚨 ここで typeMap を渡していたから OK だと思っていた
  composable<Route.Screen2>(
    typeMap = mapOf(typeOf<Arg>() to NavType.EnumType(Arg::class.java)),
  ) {
    Screen2()
  }
}

@Composable
fun Screen2(
  viewModel: Screen2ViewModel = hiltViewModel(),
) {
  Text(text = viewModel.navArg.arg.name)
}
```

</details>

<details>
<summary>App.kt</summary>

```App.kt
@Composable
fun App() {
  val navController = rememberNavController()

  NavHost(
    navController = navController,
    startDestination = Route.Screen1,
  ) {
    screen1(
      onClick = {
        val route = Route.Screen2(arg = Arg.X)
        navController.navigate(route)
      },
    )

    screen2()
  }
}
```

</details>

# 解決方法

SavedStateHandle 経由で NavArg を受け取るときにも `typeMap` を渡す必要があります。

今回の例では次の修正が必要です。

```diff:Screen2ViewModel.kt
　@HiltViewModel
class Screen2ViewModel @Inject constructor(
  savedStateHandle: SavedStateHandle,
) : ViewModel() {
-   val navArg = savedStateHandle.toRoute<Route.Screen2>()
+   val navArg = savedStateHandle.toRoute<Route.Screen2>(
+     typeMap = mapOf(typeOf<Arg>() to NavType.EnumType(Arg::class.java)),
+   )
}
```

# ハマってしまった原因

Type Safety を使用しないケースでは次のように、Bundle 経由で NavArg を受け取れていました。

※ Navigation with Compose | Jetpack Compose | Android Developers の [Retrieve complex data when navigating セクション](https://developer.android.com/develop/ui/compose/navigation#retrieving-complex-data) から転載

```kotlin
class UserViewModel(
    savedStateHandle: SavedStateHandle,
    private val userInfoRepository: UserInfoRepository
) : ViewModel() {

    private val userId: String = checkNotNull(savedStateHandle["userId"])

    // Fetch the relevant user information from the data layer,
    // ie. userInfoRepository, based on the passed userId argument
    private val userInfo: Flow<UserInfo> = userInfoRepository.getUserInfo(userId)

// …

}
```

そのため、このタイミングで KType と NavType のマッピングを考える必要があるとは思ってもみませんでした。

また `SavedStateHandle#toRoute` の `typeMap` にはデフォルト引数として `emptyMap` が渡されていることもあり、`typeMap` の存在を完全に見落としていました。

ちなみに `SavedStateHandle#toRoute` から `typeMap` の行方を追っていくと次のコードに行き着きます。

※ [AndroidX Tech: Source Code for RouteSerializer.kt](https://androidx.tech/artifacts/navigation/navigation-common/2.8.0-beta01-source/androidx/navigation/serialization/RouteSerializer.kt.html) から転載

```RouteSerializer.kt
@Suppress("UNCHECKED_CAST")
private fun SerialDescriptor.computeNavType(
    name: String,
    typeMap: Map<KType, NavType<*>>
): NavType<Any?> {
    val customType =
        typeMap.keys.find { kType -> matchKType(kType) }?.let { typeMap[it] } as? NavType<Any?>
    val result = customType ?: getNavType()
    if (result == UNKNOWN) {
        throw IllegalArgumentException(
            "Cannot cast $name of type $serialName to a NavType. Make sure " +
                "to provide custom NavType for this argument."
        )
    }
    return result as NavType<Any?>
}
```

このメソッドは KType に該当する NavType がない場合、 `"Cannot cast..."` というエラーメッセージを吐いてくれます。
[ハマっていたこと](#ハマっていたこと)で示したエラーメッセージです。

実はこのメソッドは `SavedStateHandle#toRoute` のタイミングのみならず、 NavGraph が作成されるときも呼び出されます。（[AndroidX Tech: Source Code for NavDestinationBuilder.kt](https://androidx.tech/artifacts/navigation/navigation-common/2.8.0-alpha05-source/androidx/navigation/NavDestinationBuilder.kt.html) 参照）

今回の例でブレイクポイントを張ってみると、 `SerialDescriptor#computeNavType` が2回以上呼び出されることが確認できます。
NavGraph の定義が実行されたときは `typeMap` が渡ってきており、`SavedStateHandle#toRoute` から呼び出されたときは `emptyMap` が使用されていました。

[ハマってしまった原因](#ハマってしまった原因)で考えていたように、`SavedStateHandle#toRoute` でも KType と NavType のマッピングを考える必要があるとは思ってもみなかったことと、NavGraph を定義するときに `typeMap` を渡していたことから、`emptyMap` が使用されている事実に数時間悩まれました。

今考えると当然なのですが…
**なぜ `typeMap` を渡しているのに `emptyMap` が使用されることがあるのかと！**

# まとめ

**Type Safety に Navigation Compose を使う場合、SavedStateHandle 経由で NavArg を受け取るときは `typeMap` を渡しましょう！**

思い込みから抜け出すことは難しいですね。
久しぶりに、コードを書いていて発狂しました。

話は逸れますが、光が粒子と波の二重性をもつと気づいた人は本当にすごいですよね。
わたしも常識を疑っていきます。