Jetpack Navigation Compose in 2026: Type-Safe Navigation and Interview Questions

Master Jetpack Navigation Compose with type-safe routes using Kotlin Serialization. Learn Navigation 2.10, predictive back, argument passing, and common interview questions.

Jetpack Navigation Compose type-safe navigation Android development

Jetpack Navigation Compose 2.10 brings compile-time type safety to Android navigation through Kotlin Serialization. This approach eliminates runtime crashes from mismatched arguments and simplifies refactoring across large codebases.

Navigation 2.10 Stable

Navigation Compose 2.10.0, released August 2026, requires minimum SDK 24. Type-safe routes use @Serializable data classes instead of string-based destinations, catching navigation errors at compile time.

Setting Up Type-Safe Navigation in Navigation 2.10

The type-safe navigation API requires Kotlin Serialization. Add these dependencies to your module-level build.gradle.kts:

build.gradle.ktskotlin
plugins {
    id("org.jetbrains.kotlin.plugin.serialization") version "2.1.0"
}

dependencies {
    implementation("androidx.navigation:navigation-compose:2.10.0")
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
}

Define destinations as serializable types. Use object for screens without arguments and data class for screens that receive parameters:

Routes.ktkotlin
import kotlinx.serialization.Serializable

@Serializable
object Home

@Serializable
object ProductList

@Serializable
data class ProductDetail(val productId: String)

@Serializable
data class Checkout(val cartId: String, val promoCode: String? = null)

The compiler enforces that all route classes are annotated with @Serializable. Missing annotations trigger lint errors in Navigation 2.9+.

Building a NavHost with Serializable Routes

The NavHost composable accepts serializable types directly. Each composable<T> block handles a specific destination type:

AppNavigation.ktkotlin
@Composable
fun AppNavigation(
    navController: NavHostController = rememberNavController()
) {
    NavHost(
        navController = navController,
        startDestination = Home
    ) {
        composable<Home> {
            HomeScreen(
                onNavigateToProducts = { navController.navigate(ProductList) }
            )
        }

        composable<ProductList> {
            ProductListScreen(
                onProductClick = { productId ->
                    navController.navigate(ProductDetail(productId))
                }
            )
        }

        composable<ProductDetail> { backStackEntry ->
            // Extract typed route from back stack entry
            val route: ProductDetail = backStackEntry.toRoute()
            ProductDetailScreen(
                productId = route.productId,
                onCheckout = { cartId ->
                    navController.navigate(Checkout(cartId))
                }
            )
        }

        composable<Checkout> { backStackEntry ->
            val route: Checkout = backStackEntry.toRoute()
            CheckoutScreen(
                cartId = route.cartId,
                promoCode = route.promoCode
            )
        }
    }
}

The toRoute<T>() extension returns a fully typed instance of the destination. Renaming a property in the data class updates all usages automatically through IDE refactoring.

Nested Navigation Graphs for Feature Modules

Large applications benefit from nested navigation graphs. Each feature module defines its own graph, and the root graph composes them:

AuthNavigation.ktkotlin
@Serializable
object AuthGraph  // Graph identifier

@Serializable
object Login

@Serializable
object Register

@Serializable
data class PasswordReset(val email: String)

fun NavGraphBuilder.authNavGraph(navController: NavHostController) {
    navigation<AuthGraph>(startDestination = Login) {
        composable<Login> {
            LoginScreen(
                onRegisterClick = { navController.navigate(Register) },
                onForgotPassword = { email ->
                    navController.navigate(PasswordReset(email))
                },
                onLoginSuccess = {
                    navController.navigate(Home) {
                        popUpTo<AuthGraph> { inclusive = true }
                    }
                }
            )
        }

        composable<Register> {
            RegisterScreen(
                onRegistrationComplete = {
                    navController.navigate(Home) {
                        popUpTo<AuthGraph> { inclusive = true }
                    }
                }
            )
        }

        composable<PasswordReset> { backStackEntry ->
            val route: PasswordReset = backStackEntry.toRoute()
            PasswordResetScreen(email = route.email)
        }
    }
}

The popUpTo<AuthGraph> with inclusive = true clears the entire auth flow from the back stack after successful login, preventing users from navigating back to login screens.

Ready to ace your Android interviews?

Practice with our interactive simulators, flashcards, and technical tests.

Value Classes as Route Arguments

Navigation 2.9.0-alpha03 added support for value classes in routes. This pattern enforces type safety for IDs and prevents mixing up parameters of the same primitive type:

Domain.ktkotlin
@JvmInline
@Serializable
value class ProductId(val value: String)

@JvmInline
@Serializable
value class UserId(val value: String)

@Serializable
data class ProductDetail(val productId: ProductId)

@Serializable
data class UserProfile(val userId: UserId)

Now the compiler prevents passing a UserId where a ProductId is expected, a common bug in string-based navigation.

Deep links map external URIs to serializable routes. Define the pattern in the composable block:

AppNavigation.ktkotlin
composable<ProductDetail>(
    deepLinks = listOf(
        navDeepLink {
            uriPattern = "https://example.com/products/{productId}"
        }
    )
) { backStackEntry ->
    val route: ProductDetail = backStackEntry.toRoute()
    ProductDetailScreen(productId = route.productId)
}

The {productId} placeholder maps directly to the productId property in the ProductDetail data class. Navigation extracts and deserializes the argument automatically.

Register deep links in the AndroidManifest.xml:

xml
<!-- AndroidManifest.xml -->
<activity android:name=".MainActivity">
    <intent-filter>
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data
            android:scheme="https"
            android:host="example.com"
            android:pathPrefix="/products" />
    </intent-filter>
</activity>

Predictive Back Gesture with Navigation Compose

Android 14+ supports predictive back gestures where users preview the previous screen before committing. Navigation Compose 2.10 integrates with this through SeekableTransitionState:

AppNavigation.ktkotlin
NavHost(
    navController = navController,
    startDestination = Home,
    enterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Start) },
    exitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Start) },
    popEnterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.End) },
    popExitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.End) }
) {
    // destinations
}

The predictive back gesture shows the previous destination in real-time as the user swipes. Releasing the gesture before the threshold cancels navigation and returns to the current screen.

Common Interview Questions on Navigation Compose

Senior Android interviews often probe navigation architecture decisions. Here are questions that distinguish experienced candidates.

Why use type-safe navigation over string-based routes?

String-based routes like "product/{productId}" cause runtime crashes when arguments are misspelled or have wrong types. Type-safe routes catch these errors at compile time. Refactoring a route parameter updates all usages through IDE tooling, while string-based routes require manual find-and-replace across the codebase.

How do you share data between destinations without passing large objects?

Avoid Large Route Arguments

Routes serialize into saved instance state. Passing large objects like product catalogs risks TransactionTooLargeException. Pass an ID and load data from a repository or shared ViewModel instead.

The pattern for sharing data involves scoped ViewModels:

ProductViewModel.ktkotlin
@HiltViewModel
class ProductViewModel @Inject constructor(
    private val repository: ProductRepository,
    savedStateHandle: SavedStateHandle
) : ViewModel() {
    // Extract typed route from SavedStateHandle
    private val productId: String = savedStateHandle.toRoute<ProductDetail>().productId

    val product = repository.getProduct(productId)
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
}

How do you test navigation in Compose?

Navigation testing verifies that user actions trigger correct destination changes:

NavigationTest.ktkotlin
class NavigationTest {
    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun clickingProduct_navigatesToDetail() {
        val navController = TestNavHostController(ApplicationProvider.getApplicationContext())
        navController.navigatorProvider.addNavigator(ComposeNavigator())

        composeTestRule.setContent {
            AppNavigation(navController = navController)
        }

        // Navigate to product list
        composeTestRule.onNodeWithText("View Products").performClick()

        // Click first product
        composeTestRule.onNodeWithTag("product_item_0").performClick()

        // Verify current destination
        val currentRoute = navController.currentBackStackEntry?.toRoute<ProductDetail>()
        assertThat(currentRoute).isNotNull()
    }
}

What happens when a NavController is used after being destroyed?

Navigation 2.9.0-alpha01 changed this behavior. Using a NavController after the hosting Activity or Fragment is destroyed now throws IllegalStateException. Previous versions silently failed or produced undefined behavior. This change surfaces lifecycle bugs earlier in development.

How do you handle conditional navigation based on auth state?

A common pattern observes auth state and redirects accordingly:

MainScreen.ktkotlin
@Composable
fun MainScreen(
    authState: AuthState,
    navController: NavHostController = rememberNavController()
) {
    LaunchedEffect(authState) {
        when (authState) {
            is AuthState.Authenticated -> {
                navController.navigate(Home) {
                    popUpTo(0) { inclusive = true }
                }
            }
            is AuthState.Unauthenticated -> {
                navController.navigate(AuthGraph) {
                    popUpTo(0) { inclusive = true }
                }
            }
            else -> { /* loading, do nothing */ }
        }
    }

    NavHost(
        navController = navController,
        startDestination = if (authState is AuthState.Authenticated) Home else AuthGraph
    ) {
        authNavGraph(navController)
        composable<Home> { HomeScreen() }
    }
}

The LaunchedEffect reacts to auth state changes and clears the entire back stack with popUpTo(0) { inclusive = true } to prevent back navigation to the wrong flow.

Start practicing!

Test your knowledge with our interview simulators and technical tests.

Migrating from String Routes to Type-Safe Navigation

Existing projects can migrate incrementally. Both string routes and type-safe routes coexist in the same NavHost:

AppNavigation.ktkotlin
NavHost(
    navController = navController,
    startDestination = "home"  // Legacy string route
) {
    // Legacy string-based destination
    composable("home") {
        HomeScreen(onNavigateToProduct = { id ->
            navController.navigate(ProductDetail(id))  // New type-safe navigation
        })
    }

    // New type-safe destination
    composable<ProductDetail> { backStackEntry ->
        val route: ProductDetail = backStackEntry.toRoute()
        ProductDetailScreen(productId = route.productId)
    }
}

This approach allows migrating one screen at a time without rewriting the entire navigation graph.

Key Takeaways for Jetpack Navigation Compose in 2026

  • Navigation 2.10.0 is the current stable version with minimum SDK 24
  • Type-safe routes use @Serializable data classes instead of string destinations
  • Value classes provide additional type safety for IDs and prevent parameter mix-ups
  • Nested navigation graphs organize feature modules with independent back stacks
  • Deep links map URI patterns directly to route properties
  • Predictive back gestures integrate automatically with standard transitions
  • Route arguments should stay lightweight: pass IDs, load data from repositories
  • Migration from string routes can happen incrementally with mixed navigation graphs
  • Android interview preparation covers these patterns in depth
  • See also Jetpack Compose interview questions for related UI concepts
Daily challenge

Can you spot the bug in Android?

One real snippet, one hidden bug, one attempt a day. No account needed to try.

Anthony Fillion-Maillet

Written by

Anthony Fillion-Maillet

Founder of SharpSkill

Full-stack developer for over 10 years. Runs SharpSkill and answers for everything published here.

Updated on September 1, 2026

Tags

#android
#jetpack-compose
#navigation
#kotlin
#type-safe

Share

Related articles