DEV Community

Cover image for How Sealed Classes Make Navigation Safer in Jetpack Compose
Aalaa Fahiem
Aalaa Fahiem

Posted on

How Sealed Classes Make Navigation Safer in Jetpack Compose

I spent forty minutes debugging a screen that refused to open.

The app compiled fine. No red errors anywhere. I'd tap the button, nothing would happen — no crash, no error, just silence. I checked the click listener. I checked the composable. I checked the NavHost. Everything looked right.

Then I found it.

// Where I registered the screen
composable("pokemonDetails") { ... }

// Where I was navigating to
navController.navigate("pokemondetails")
Enter fullscreen mode Exit fullscreen mode

One lowercase letter. Details vs details. Forty minutes.

And the worst part? The compiler had no idea. It saw two strings and had nothing to say about it. From Kotlin's perspective, both lines were perfectly valid code.

That was the day I stopped trusting navigation strings — and started learning about sealed classes.


The Way Everyone Starts: Raw Strings

When you first build navigation in Jetpack Compose, this feels completely natural:

NavHost(navController, startDestination = "home") {
    composable("home") { HomeScreen() }
    composable("profile") { ProfileScreen() }
    composable("settings") { SettingsScreen() }
}
Enter fullscreen mode Exit fullscreen mode

And navigating around:

navController.navigate("profile")
navController.navigate("settings")
Enter fullscreen mode Exit fullscreen mode

It works. For a small app with three screens, it's fine. You can see all the strings at a glance, they're short, and refactoring is easy enough.

But this approach has a silent flaw built into it from day one: Kotlin has no idea what "profile" means. To the compiler, it's just a String. It could be anything. A typo, a different capitalization, a slightly different spelling — the code still compiles and the screen just silently fails to open.

You won't find out until runtime. Sometimes not until a user reports it.


The First Improvement: At Least Use Constants

The first instinct most developers have is to pull the strings into constants. It's the right instinct.

object Routes {
    const val HOME = "home"
    const val PROFILE = "profile"
    const val SETTINGS = "settings"
}
Enter fullscreen mode Exit fullscreen mode

Now the NavHost looks like this:

NavHost(navController, startDestination = Routes.HOME) {
    composable(Routes.HOME) { HomeScreen() }
    composable(Routes.PROFILE) { ProfileScreen() }
    composable(Routes.SETTINGS) { SettingsScreen() }
}
Enter fullscreen mode Exit fullscreen mode

And navigation:

navController.navigate(Routes.PROFILE)
Enter fullscreen mode Exit fullscreen mode

This is already better. Typos in the string value only need to be fixed in one place. Autocomplete helps. Renaming a route is a single change instead of a search-and-replace across the whole project.

But this still has a real limitation — one that only shows up once your app starts to grow.


Where Constants Start Falling Apart: Arguments

Everything feels manageable until screens need to pass data to each other.

Say you're building a Pokédex app. You have a list screen and a detail screen. The detail screen needs to know which Pokémon to show. In Compose Navigation, that means route arguments:

composable("details/{pokemonName}") { backStackEntry ->
    val name = backStackEntry.arguments?.getString("pokemonName")
    PokemonDetailScreen(name)
}
Enter fullscreen mode Exit fullscreen mode

And navigating to it:

navController.navigate("details/pikachu")
navController.navigate("details/charizard")
navController.navigate("details/$selectedPokemon")
Enter fullscreen mode Exit fullscreen mode

Now imagine this pattern across ten screens. Profile with a userId. Post with a postId. Settings with a section parameter. You're manually building strings everywhere:

navController.navigate("profile/$userId")
navController.navigate("post/$postId")
navController.navigate("settings/$section")
Enter fullscreen mode Exit fullscreen mode

Every one of these is a hand-rolled string. Every one of them can be wrong in ways the compiler will never catch. Forget the slash, get the argument name slightly wrong, pass the wrong variable — silent failure every time.

This is the moment where raw strings, even organized into constants, start genuinely hurting you.


The Shift in Thinking: Screens Are a Known, Finite Set

Here's the idea that made sealed classes click for me.

Your app has a specific, known list of screens. It's not infinite. It's not dynamic. At any point in time, the compiler could know every valid destination — if you gave it a way to represent them.

That's exactly what a sealed class does.

A sealed class says: "only these specific types can exist." No others. The compiler enforces it.

Instead of navigation being a world of arbitrary strings where anything goes, it becomes a closed, controlled system where every valid destination is explicitly defined in one place.

That's the whole idea. Not syntax. Not Kotlin trivia. A navigation architecture that makes invalid destinations impossible to express.


Building the Screen Hierarchy

Here's what that looks like in code:

sealed class Screen(val route: String) {
    data object Home : Screen("home")
    data object Profile : Screen("profile")
    data object Settings : Screen("settings")
    data class Details(val pokemonName: String) : Screen("details/{pokemonName}") {
        fun createRoute() = "details/$pokemonName"
    }
}
Enter fullscreen mode Exit fullscreen mode

Let's slow down and read this carefully, because every word is doing something.

sealed class Screen(val route: String)
This is the parent. Every screen in your app is a Screen. The route property is what gets registered with the NavHost. Because it's a sealed class, nothing outside this file can create a new subtype — the set of screens is locked.

data object Home : Screen("home")
Home is a screen. It inherits from Screen, which means it automatically has a route property — and its value is "home". data object means it's a singleton. There's only ever one Home. It doesn't need arguments — it's just a destination.

data class Details(...) : Screen("details/{pokemonName}")
Details is also a screen, but it needs data. It takes a pokemonName and defines a createRoute() function that builds the actual navigation string safely — in one place, with the correct format, every time.

The visual hierarchy looks like this:

Screen
 ├── Home          (no arguments — just a destination)
 ├── Profile       (no arguments)
 ├── Settings      (no arguments)
 └── Details       (needs a pokemonName)
Enter fullscreen mode Exit fullscreen mode

All of them belong to the same family. All of them have a route. None of them can be misspelled into existence.


Using It in a Real Compose NavHost

Here's the NavHost with sealed classes:

@Composable
fun AppNavigation() {
    val navController = rememberNavController()

    NavHost(
        navController = navController,
        startDestination = Screen.Home.route
    ) {
        composable(Screen.Home.route) {
            HomeScreen(
                onPokemonClick = { name ->
                    navController.navigate(Screen.Details(name).createRoute())
                }
            )
        }

        composable(Screen.Profile.route) {
            ProfileScreen()
        }

        composable(Screen.Settings.route) {
            SettingsScreen()
        }

        composable(
            route = Screen.Details("").route, // "details/{pokemonName}"
            arguments = listOf(navArgument("pokemonName") { type = NavType.StringType })
        ) { backStackEntry ->
            val name = backStackEntry.arguments?.getString("pokemonName") ?: ""
            PokemonDetailScreen(pokemonName = name)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Look at what's changed. There's not a single hardcoded string anywhere in this navigation setup. Every route comes from a Screen object. And when navigating to the detail screen:

// Before — fragile, easy to get wrong
navController.navigate("details/pikachu")

// After — safe, readable, impossible to misspell
navController.navigate(Screen.Details("pikachu").createRoute())
Enter fullscreen mode Exit fullscreen mode

If you rename the screen, you change it in one place — the sealed class. Everywhere that uses Screen.Details just works.


Why createRoute() Is More Important Than It Looks

It's easy to look at createRoute() and think it's a tiny convenience method. It's not.

data class Details(val pokemonName: String) : Screen("details/{pokemonName}") {
    fun createRoute() = "details/$pokemonName"
}
Enter fullscreen mode Exit fullscreen mode

Before this existed, every screen that navigated to Details was responsible for building the string correctly. Every single one:

// In HomeScreen
navController.navigate("details/$pokemonName")

// In SearchScreen
navController.navigate("details/$selectedPokemon")

// In FavoritesScreen
navController.navigate("details/$favoriteName")
Enter fullscreen mode Exit fullscreen mode

That's three places that need to agree on the exact format of the route. Change the argument name, or add a second argument, and you're hunting through every screen that navigates to Details to update them all.

With createRoute(), there's exactly one place where the route for Details is built. Every screen calls the same function. Change the format once, it's fixed everywhere. That's encapsulation — and it's the real reason sealed classes start feeling powerful instead of just "more organized."

The Final Clean Setup (Copy-Friendly)

Here's everything together — sealed classes, NavHost, and a full navigation example using the latest versions:

// build.gradle.kts
dependencies {
    val composeBom = platform("androidx.compose:compose-bom:2026.04.01")
    implementation(composeBom)
    implementation("androidx.compose.material3:material3")
    implementation("androidx.navigation:navigation-compose:2.9.8")
}
Enter fullscreen mode Exit fullscreen mode
// Screen.kt
sealed class Screen(val route: String) {
    data object Home : Screen("home")
    data object Profile : Screen("profile")
    data object Settings : Screen("settings")
    data class Details(val pokemonName: String) : Screen("details/{pokemonName}") {
        fun createRoute() = "details/$pokemonName"
    }
}
Enter fullscreen mode Exit fullscreen mode
// NavGraph.kt
@Composable
fun AppNavigation() {
    val navController = rememberNavController()

    NavHost(
        navController = navController,
        startDestination = Screen.Home.route
    ) {
        composable(Screen.Home.route) {
            HomeScreen(
                onNavigateToProfile = { navController.navigate(Screen.Profile.route) },
                onPokemonClick = { name ->
                    navController.navigate(Screen.Details(name).createRoute())
                }
            )
        }
        composable(Screen.Profile.route) { ProfileScreen() }
        composable(Screen.Settings.route) { SettingsScreen() }
        composable(
            route = Screen.Details("").route,
            arguments = listOf(navArgument("pokemonName") { type = NavType.StringType })
        ) { backStackEntry ->
            val name = backStackEntry.arguments?.getString("pokemonName") ?: ""
            PokemonDetailScreen(pokemonName = name)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)