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")
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() }
}
And navigating around:
navController.navigate("profile")
navController.navigate("settings")
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"
}
Now the NavHost looks like this:
NavHost(navController, startDestination = Routes.HOME) {
composable(Routes.HOME) { HomeScreen() }
composable(Routes.PROFILE) { ProfileScreen() }
composable(Routes.SETTINGS) { SettingsScreen() }
}
And navigation:
navController.navigate(Routes.PROFILE)
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)
}
And navigating to it:
navController.navigate("details/pikachu")
navController.navigate("details/charizard")
navController.navigate("details/$selectedPokemon")
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")
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"
}
}
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)
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)
}
}
}
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())
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"
}
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")
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")
}
// 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"
}
}
// 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)
}
}
}
Top comments (0)