Navigation typée dans Jetpack Compose : les routes comme données, pas comme chaînes de caractères
Comment remplacer les routes Compose Navigation basées sur des chaînes par des objets Kotlin sérialisables — arguments typés, graphes imbriqués, et deep links qui survivent à un refactoring.
Pendant les premières années de Compose Navigation, chaque route était une chaîne de caractères. Vous construisiez "item/{itemId}", vous la faisiez correspondre dans un NavHost, puis vous récupériez itemId depuis un NavBackStackEntry avec getString("itemId") — en espérant que la clé tapée à l’aller correspondait à celle tapée au retour. Le compilateur ne pouvait rien pour vous. Une faute de frappe dans l’une ou l’autre chaîne compilait sans problème et plantait à l’exécution, généralement sur un écran que vous ne regardiez pas au moment où vous avez fait la modification. Les routes typées de Navigation Compose corrigent cela en faisant d’une route un objet Kotlin plutôt qu’une chaîne, et après avoir migré chaque écran d’une application en production vers cette approche, toute la classe de bugs liée aux fautes de frappe dans les arguments a purement et simplement disparu — le compilateur l’attrape avant même que le build ne se termine.
Les routes comme hiérarchie scellée
L’idée centrale est simple : définissez vos destinations comme une sealed interface, annotez chacune d’elles avec @Serializable, et laissez kotlinx.serialization se charger de les transformer en route, et inversement.
sealed interface Screen {
@Serializable
data object Home : Screen
@Serializable
data class ItemDetail(val itemId: Long) : Screen
@Serializable
data class EditItem(val itemId: Long, val fromDetail: Boolean = false) : Screen
}
Pas de template de chaîne, pas de nom de clé à synchroniser — les arguments sont les propriétés de la classe. Naviguer devient un appel de fonction avec de vrais types :
navController.navigate(Screen.ItemDetail(itemId = item.id))
Si itemId était renommé, ou si son type passait de Long à String quelque part en amont, chaque site d’appel échouerait à la compilation au lieu d’échouer au parsing à l’exécution.
Câbler le NavHost
Côté NavHost, le miroir de cette approche est la surcharge composable<T>(), qui prend le type comme paramètre réifié plutôt que comme motif de chaîne :
NavHost(navController = navController, startDestination = Screen.Home) {
composable<Screen.Home> {
HomeScreen(onItemClick = { id -> navController.navigate(Screen.ItemDetail(id)) })
}
composable<Screen.ItemDetail> { backStackEntry ->
val args: Screen.ItemDetail = backStackEntry.toRoute()
ItemDetailScreen(itemId = args.itemId)
}
composable<Screen.EditItem> { backStackEntry ->
val args: Screen.EditItem = backStackEntry.toRoute()
EditItemScreen(itemId = args.itemId, cameFromDetail = args.fromDetail)
}
}
toRoute() désérialise l’entrée de la pile de retour directement vers le type de destination. Il n’y a aucune clé de Bundle à mal orthographier, ni aucune vérification manuelle de nullité pour un argument manquant — une propriété requise absente ne compile tout simplement pas, et une propriété optionnelle reçoit sa valeur par défaut déclarée, exactement comme n’importe quel autre argument de fonction Kotlin.
Les graphes imbriqués restent eux aussi typés
Le même schéma s’étend aux graphes de navigation imbriqués, c’est précisément là que les routes en chaîne devenaient vraiment pénibles — une faute de frappe dans la destination de départ d’un graphe imbriqué échoue silencieusement et affiche simplement un écran vide. Avec des routes scellées, le graphe imbriqué est son propre type :
sealed interface OnboardingGraph : Screen {
@Serializable data object Welcome : OnboardingGraph
@Serializable data object Permissions : OnboardingGraph
@Serializable data class Done(val skippedPermissions: Boolean) : OnboardingGraph
}
navigation<OnboardingGraph>(startDestination = OnboardingGraph.Welcome) {
composable<OnboardingGraph.Welcome> { /* ... */ }
composable<OnboardingGraph.Permissions> { /* ... */ }
composable<OnboardingGraph.Done> { entry ->
val args: OnboardingGraph.Done = entry.toRoute()
// ...
}
}
Comme OnboardingGraph étend Screen, le code ailleurs dans l’application qui navigue contre le graphe extérieur n’a pas besoin de savoir que le graphe imbriqué existe — il appelle simplement navigate(OnboardingGraph.Welcome), et la frontière du graphe est résolue structurellement, et non par correspondance de préfixe de chaîne.
Des deep links sans template d’URI à rater
Les deep links s’attachent aux mêmes destinations typées, si bien qu’une URI entrante se résout toujours en un véritable objet plutôt qu’en un sac de chaînes à analyser à la main :
composable<Screen.ItemDetail>(
deepLinks = listOf(navDeepLink<Screen.ItemDetail>(basePath = "myapp://item"))
) { backStackEntry ->
val args: Screen.ItemDetail = backStackEntry.toRoute()
ItemDetailScreen(itemId = args.itemId)
}
myapp://item/42 et un navigate(Screen.ItemDetail(42)) déclenché depuis l’application atterrissent tous deux dans le même composable, avec le même argument typé — il n’existe qu’un seul endroit qui définit à quoi ressemble une destination ItemDetail, qu’elle ait été atteinte via une notification, un widget, ou un bouton dans l’application.
Deux choses à savoir avant de migrer
Les types personnalisés dans une route ont besoin de leur propre NavType. Un argument Long ou String fonctionne d’emblée, mais une route contenant un enum ou une value class a besoin d’une petite implémentation de NavType<T> enregistrée sur l’appel composable, car la pile de retour finit par stocker la route sous forme de chaîne sérialisée et doit savoir comment encoder et décoder votre type dans cette chaîne. Ce sont quelques lignes, mais ce n’est pas gratuit — prévoyez ce coût dès le premier écran qui a besoin d’un argument non primitif.
Migrez en partant des feuilles, pas de la racine. Convertir les écrans les plus internes avant les graphes qui les hébergent vous permet de faire cohabiter routes en chaîne et routes typées pendant la migration, plutôt qu’une réécriture big-bang où rien ne compile tant que toutes les routes ne sont pas terminées. J’ai migré un flux à la fois — l’onboarding, puis le détail d’un article, puis les réglages — en vérifiant la navigation manuellement après chacun, sans jamais avoir plus qu’une poignée de fichiers dans un état incohérent.
Le résultat, ce n’est pas un code de navigation plus rapide, c’est un code de navigation où la classe de bug qui survivait autrefois à une revue de code — un argument renommé qui continue de se parser avec le mauvais type — n’existe tout simplement plus. Pour une application maintenue en solo, sans second relecteur pour attraper la faute de frappe, c’est l’argument qui compte le plus.
// À lire aussi
D’autres notes du journal
L'API In-App Review d'Android : demander une note sans être pénible
Un guide pratique de l'API In-App Review de Google — comment elle fonctionne réellement, quand la déclencher, et pourquoi la popup classique « notez-nous » nuit discrètement à votre note sur le Play Store.
Raccourcis d'application et tuiles de Paramètres rapides sur Android : enregistrer de l'eau sans ouvrir l'application
Un guide pratique de l'API dynamique ShortcutManager et de TileService sur Android — comment permettre à une action en un tap de contourner complètement l'application, avec les pièges qui font trébucher la plupart des implémentations.
Les types de services au premier plan sur Android en 2026 : choisir celui auquel votre fonctionnalité correspond vraiment
Un guide pratique des restrictions de types de services au premier plan d'Android — dataSync, mediaPlayback, specialUse, shortService — et comment choisir le bon sans se faire tuer ou rejeter.