MyFamilyTracker
Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.
Discover how to replace string‑based navigation in Jetpack Compose with sealed‑class routes, achieving compile‑time safety, better IDE support, and cleaner architecture.
On this page
I used to chase navigation bugs in Android until I discovered type‑safe routes. They turn navigation from a runtime nightmare into a compile‑time guarantee. The moment I switched, my crash logs dropped by half and my refactoring time vanished.
Traditional Android navigation relies on string constants for destinations. A typo in a destination string silently crashes at runtime, and the IDE cannot warn you until you run the app. Type‑safe navigation flips this script by representing each route as a Kotlin sealed class or data class. The compiler validates every navigation call, and autocomplete guides you to the correct destination. The result is a safer codebase that scales with complex app flows.
[!TIP] Use sealed classes for your
hierarchy. They give you pattern matching, exhaustiveness checks, and a natural place to attach arguments.codeRoute
When you centralize routes, you also simplify refactoring. Rename a destination or add a new argument, and the compiler will surface every place that needs updating. This aligns perfectly with clean architecture principles: the navigation layer becomes a stable contract between the UI and the business logic.
Below is the minimal boilerplate I keep in every project. It defines a sealed
RouteComposeRouter// routes.kt
sealed class Route(val path: String) {
object Home : Route("home")
object Detail : Route("detail/{id}")
object Settings : Route("settings")
}
// extensions for safe navigation
fun NavController.navigateTo(route: Route) {
when (route) {
is Route.Home -> navigate(route.path) { launchSingleTop = true }
is Route.Detail -> navigate("${route.path}/${route.id}") // id is a property you add
is Route.Settings -> navigate(route.path)
}
}In the above snippet, each route carries its own path. If you later change the path for
DetailnavigateToNavControllerNow integrate this into a
NavHostStartDestinationRoute.HomeComposablenavController: NavControllernavController.navigateTo(Route.Detail)@Composable
fun MyApp() {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = Route.Home.path
) {
composable(Route.Home) {
HomeScreen(navController)
}
composable(Route.Detail) { backStackEntry ->
val id = backStackEntry.arguments?.getString("id") ?: return@composable
DetailScreen(id, navController)
}
composable(Route.Settings) {
SettingsScreen(navController)
}
}
}Notice that
Route.Home.pathstartDestinationRoute.HomenullNavController.navigate("home")In a typical MVVM setup, the ViewModel often needs to trigger navigation. With type‑safe routes, you can expose a
NavigationEventRoute// NavigationEvent.kt
sealed class NavigationEvent {
data class OpenRoute(val route: Route) : NavigationEvent()
object PopBack : NavigationEvent()
}
// MyViewModel.kt
class MyViewModel @AssistedInject constructor(
private val router: RouterUseCase
) : ViewModel() {
private val _navigationEvents = MutableStateFlow<NavigationEvent?>(null)
val navigationEvents: StateFlow<NavigationEvent?> = _navigationEvents.asStateFlow()
fun onItemClicked(id: String) {
_navigationEvents.value = NavigationEvent.OpenRoute(Route.Detail)
}
}The
RouterUseCaseNavControllerRouterUseCase@NavigationQualifierNavController@Qualifier
@Retention(AnnotationRetention.RUNTIME)
annotation class MainNavController
@Provides
@MainNavController
fun provideNavController(activity: ComponentActivity): NavController =
NavHostActivity.findNavController(activity)
@Provides
fun provideRouterUseCase(@MainNavController navController: NavController): RouterUseCase =
DefaultRouterUseCase(navController)The
DefaultRouterUseCase// DefaultRouterUseCase.kt
class DefaultRouterUseCase(private val navController: NavController) : RouterUseCase {
override fun navigate(route: Route) {
navController.navigateTo(route)
}
override fun popBack() {
navController.popBackStack()
}
}With this separation, unit tests can mock
RouterUseCaseRouteNavControllerTesting navigation in Compose used to require instrumentation tests that launch activities. With type‑safe routes, you can write lightweight unit tests that verify the navigation flow using
NavigationTesterComposeTestingBelow is a simple test that ensures clicking an item opens the detail screen with the correct ID.
@RunWith(AndroidJUnit4::class)
class NavigationTest {
@get:Rule
val composeRule = ComposeTestRule()
@Test
fun clickItem_navigatesToDetailWithId() {
// Arrange
val testId = "42"
val scenario = composeRule.setContent {
TestNavigationHost()
}
// Act
composeRule.onNodeWithTag("item_$testId").performClick()
// Assert
val navController = LocalNavController.current
assertThat(navController.currentDestination?.route)
.contains("detail/42")
}
}The test uses a custom
TestNavigationHostNavControllerLocalNavController| Aspect | Traditional String Navigation | Type‑Safe Navigation |
|---|---|---|
| Compile‑time safety | ❌ No validation | ✅ Exhaustive checks |
| IDE autocomplete | ❌ Manual typing | ✅ Auto‑complete |
| Refactoring effort | High – search & replace | Low – sealed updates |
| Runtime crashes | Common (typos) | Rare (type errors) |
| Test complexity | Requires activity launch | Pure unit tests |
[!WARNING] Over‑engineering your navigation can add unnecessary boilerplate. Keep the sealed hierarchy small and only add routes when you see repeated patterns.
[!IMPORTANT] Store all route definitions in a single file or module. This prevents drift and makes the navigation contract visible to the whole team.
RouteRouterUseCaseSudarshan Chaudhari
AI Systems Builder / Product Engineer
Bangkok, Thailand
Solo Android developer with 13+ years in QA, building Android apps, AI automation systems, and developer tools at SudarshanTechLabs.
Related Posts
Related Apps
Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.
Building something? Available for Android dev and QA consulting.
Work with meComments — powered by Giscus
Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.
ReadPrivate dream journal — structured entry capture, pattern tagging, and optional Claude-powered insight generation. All data stays on-device by default.
ReadWorkout tracker — exercise logging with set/rep/weight history, goal progression, and local Room DB persistence. No account, no cloud sync required.
Read