Type safety in Kotlin DSL and Navigation Compose

This page contains best practices for providing runtime type safety to the Navigation Kotlin DSL and Navigation Compose. In summary, you should map each screen of your app or navigation graph to a Navigation file on a per module basis. The resulting files should each contain all the Navigation related information for the given destination. These navigation files are also where Kotlin visibility modifiers provide runtime type safety:

  • Type safe functions are publicly exposed to the rest of the codebase.
  • Navigation specific concepts for a particular screen or navigation graph are co-located and kept private in the same file to make them inaccessible to the rest of the codebase.

Split your navigation graph

You should split your navigation graph by screen. This is essentially the same approach as when you split screens to different composable functions. Each screen should have a NavGraphBuilder extension function.

This extension function is the bridge between a stateless screen-level composable function and Navigation-specific logic. This layer can also define where the state comes from and how events are handled.

Here's a typical ConversationScreen that can be internal to its own module, so that other modules cannot access it:

// ConversationScreen.kt

@Composable
internal fun ConversationScreen(
  uiState: ConversationUiState,
  onPinConversation: () -> Unit,
  onNavigateToParticipantList: (conversationId: String) -> Unit
) { ... }

The following NavGraphBuilder extension function adds the ConversationScreen composable as a destination of that NavGraph. It also connects the screen with a ViewModel that provides the screen UI state and handles the screen-related business logic. Navigation events that cannot be handled by the ViewModel are exposed to the caller.

// ConversationNavigation.kt

private const val conversationIdArg = "conversationId"

// Adds conversation screen to `this` NavGraphBuilder
fun NavGraphBuilder.conversationScreen(
  // Navigation events are exposed to the caller to be handled at a higher level
  onNavigateToParticipantList: (conversationId: String) -> Unit
) {
  composable("conversation/{$conversationIdArg}") {
    // The ViewModel as a screen level state holder produces the screen
    // UI state and handles business logic for the ConversationScreen
    val viewModel: ConversationViewModel = hiltViewModel()
    val uiState = viewModel.uiState.collectAsStateWithLifecycle()
    ConversationScreen(
      uiState,
      ::viewModel.pinConversation,
      onNavigateToParticipantList
    )
  }
}

The ConversationNavigation.kt file separates code from the Navigation library from the destination itself. It also provides encapsulation around Navigation concepts such as routes or argument IDs that are kept private because they should never leak outside of this file. Navigation events that cannot be handled at this layer, need to be exposed to the caller so that they're handled at the right level. You will find an example of such an event with onNavigateToParticipantList in the code snippet above.

Type safe navigation

The Navigation Kotlin DSL that Navigation Compose is built on doesn't currently offer compile-time type safety of the kind Safe Args provides to navigation graphs built in navigation XML resource files. Safe Args generates code that contains type-safe classes and methods for Navigation destinations and actions. However, you can structure your Navigation code to be type safe at runtime. With it, you can avoid crashes and make sure that:

  • The arguments you provide when navigating to a destination or navigation graph are the right types and that all the required arguments are present.
  • The arguments you retrieve from SavedStateHandle are the right types.

Each destination should also expose a NavController extension function to allow other destinations to safely navigate to it.

// ConversationNavigation.kt

fun NavController.navigateToConversation(conversationId: String) {
    this.navigate("conversation/$conversationId")
}

If you want to navigate to a screen of your app with different NavOptions, such as popUpTo, savedState, restoreState, or singleTop when navigating, pass an optional parameter to the NavController extension function.

// HomeNavigation.kt

const val HomeRoute = "home"

fun NavController.navigateToHome(navOptions: NavOptions? = null) {
    this.navigate(HomeRoute, navOptions)
}

Type safe arguments wrapper

You can optionally create a type safe wrapper to extract the arguments out of a SavedStateHandle for your ViewModel and out of a NavBackStackEntry in the content of a destination to get the benefits mentioned in the introduction to this section.

// ConversationNavigation.kt

private const val conversationIdArg = "conversationId"

internal class ConversationArgs(val conversationId: String) {
  constructor(savedStateHandle: SavedStateHandle) :
    this(checkNotNull(savedStateHandle[conversationIdArg]) as String)
}

// ConversationViewModel.kt

internal class ConversationViewModel(...,
  savedStateHandle: SavedStateHandle
) : ViewModel() {
  private val conversationArgs = ConversationArgs(savedStateHandle)
}

Assemble the navigation graph

Navigation graphs use the type safe extension functions described above to add destinations and to navigate to them.

In the following example, the conversation destination together with two other destinations, home and participant list, is included in an app level NavHost as follows:

// MyApp.kt

@Composable
fun MyApp(modifier: Modifier = Modifier) {
  val navController = rememberNavController()
  NavHost(
    navController = navController,
    startDestination = HomeRoute,
    modifier = modifier
  ) {

    homeScreen(
      onNavigateToConversation = { conversationId ->
        navController.navigateToConversation(conversationId)
      }
    )

    conversationScreen(
      onNavigateToParticipantList = { conversationId ->
        navController.navigateToParticipantList(conversationId)
      }
    }

    participantListScreen()
}

Type safety in nested navigation graphs

You should choose the right visibility for modules that provide multiple screens. This is the same concept as for each method in the sections above. However, it may not make sense to expose individual screens to other modules at all. In that case, you should instead treat them as part of a larger, self contained flow.

This self-contained set of screens is called a nested navigation graph. This lets you include multiple screens into a single NavGraphBuilder extension method. This method uses those NavController extension methods in turn to link the screens within the same module together.

In the following example, the conversation destination described in previous sections appears n a nested navigation graph alongside two other destinations, conversation list and participant list:

// ConversationGraphNavigation.kt

private val ConversationGraphRoutePattern = "conversation"

fun NavController.navigateToConversationGraph(navOptions: NavOptions? = null) {
  this.navigate(ConversationGraphRoutePattern, navOptions)
}

fun NavGraphBuilder.conversationGraph(navController: NavController) {
  navigation(
    startDestination = ConversationListRoutePattern,
    route = ConversationGraphRoutePattern
  ) {
    conversationListScreen(
      onNavigateToConversation = { conversationId ->
        navController.navigateToConversation(conversationId)
      }
    )
    conversationScreen(
      onNavigateToParticipantList = { conversationId ->
        navController.navigateToParticipantList(conversationId)
      }
    )
    partipantList()
}

You can use multiple nested navigation graphs in an app level NavHost as follows:

// MyApp.kt

@Composable
fun MyApp(modifier: Modifier = Modifier) {
  val navController = rememberNavController()
  NavHost(
    navController = navController,
    startDestination = HomeGraphRoutePattern
    modifier = modifier
  ) {
    homeGraph(
      navController,
      onNavigateToConversation = {
        navController.navigateToConversationGraph()
      }
    }
    conversationGraph(navController)
}