Type-Safe Navigation Arguments in Jetpack Compose

AndroidJetpack ComposeNavigationKotlin

The first time a QA report came back saying "the profile screen crashes when you open it from search," I already knew the shape of the bug before I opened the stack trace: a navigation argument that existed in one call site and not another, discovered at runtime because the route was a string. Type-safe navigation in Jetpack Compose exists to kill that entire class of bug. Instead of building routes like "profile/{userId}?tab={tab}" and hoping every caller assembles the URL correctly, you declare a @Serializable class, and the compiler refuses to let you navigate without the arguments it requires.

That shift — from stringly-typed routes to real Kotlin types — is the single biggest quality-of-life improvement Navigation Compose has shipped, and it's worth understanding beyond the copy-paste sample.

Why string routes rot

String routes look harmless in a demo. The problems show up at scale:

None of these are exotic. I've hit every one of them on shipping apps, and they all trace back to the same root cause: the route is data the compiler can't see into.

Declaring destinations as types

The type-safe API uses kotlinx.serialization. You apply the plugin, then model each destination as an object (no args) or a data class (with args):

@Serializable
object Home

@Serializable
data class Profile(val userId: String, val tab: ProfileTab = ProfileTab.Posts)

@Serializable
data class Article(val slug: String)

The NavHost then keys destinations off those types rather than strings:

NavHost(navController, startDestination = Home) {
    composable<Home> {
        HomeScreen(onOpenProfile = { id -> navController.navigate(Profile(userId = id)) })
    }
    composable<Profile> { backStackEntry ->
        val profile: Profile = backStackEntry.toRoute()
        ProfileScreen(userId = profile.userId, tab = profile.tab)
    }
    composable<Article> { backStackEntry ->
        val article: Article = backStackEntry.toRoute()
        ArticleScreen(slug = article.slug)
    }
}

Two things earn their keep here. navigate(Profile(userId = id)) is a constructor call, so a missing userId is a compile error, not a crash. And backStackEntry.toRoute<Profile>() gives you the deserialized instance with defaults already applied — tab falls back to ProfileTab.Posts without a null check. The whole round trip is typed end to end.

Default values and optionality that actually work

With string routes, "optional argument" meant appending a query parameter and remembering the ?arg={arg} syntax and a nullable = true in the argument list. With serializable routes, optionality is just a Kotlin default value or a nullable property. tab: ProfileTab = ProfileTab.Posts is optional because Kotlin says so, and the library encodes it as a query parameter under the hood. You stop thinking about the URL grammar entirely and think in terms of your data model, which is where the decisions actually belong.

The senior-engineer opinion I'll plant here: pass IDs, not objects. It's tempting to make an entire User @Serializable and shove it into the back stack so the detail screen doesn't refetch. Don't. The back stack gets persisted across process death, so a fat object bloats saved state and goes stale the moment the underlying data changes. Pass the userId, let the destination's ViewModel load fresh data, and treat navigation arguments as the minimum key needed to reconstruct the screen. This pairs naturally with how you already structure state in Kotlin coroutines and Flow patterns — the argument is the query key, the Flow produces the state.

Custom types and NavType

Primitives, enums, and Strings serialize without ceremony. When you genuinely need a custom type in a route — say a small value class — you register a NavType:

val ProfileTabNavType = object : NavType<ProfileTab>(isNullableAllowed = false) {
    override fun get(bundle: Bundle, key: String) =
        bundle.getString(key)?.let(ProfileTab::valueOf)
    override fun parseValue(value: String) = ProfileTab.valueOf(value)
    override fun serializeAsValue(value: ProfileTab) = value.name
    override fun put(bundle: Bundle, key: String, value: ProfileTab) =
        bundle.putString(key, value.name)
}

composable<Profile>(typeMap = mapOf(typeOf<ProfileTab>() to ProfileTabNavType)) { /* ... */ }

Enums are usually handled for you, so this is the escape hatch for the odd case. If you find yourself writing many custom NavTypes, that's a smell — it usually means you're trying to move state through navigation that should live in a repository or shared ViewModel instead.

String routes vs type-safe routes

Concern String routes Type-safe routes
Missing argument Runtime crash Compile error
Argument encoding Manual Automatic
Refactor safety Find-and-replace IDE rename
Optional args ?arg={arg} grammar Kotlin defaults
Deep links Hand-built patterns Generated from type
Learning curve Low upfront Slightly higher, pays back fast

The only honest cost is the serialization plugin and a slightly steeper first hour. Everything after that is the compiler doing work you used to do in code review.

Deep links without hand-writing patterns

Deep links were the worst part of string routes — you maintained the URI pattern in a second place and prayed it matched the route. With type-safe destinations you attach a navDeepLink and the library derives the mapping from the type, so https://app.example.com/profile/42 resolves straight into Profile(userId = "42"). Keep the argument set small here too; a deep link that requires five arguments is a deep link nobody can construct correctly from the outside.

If you're standing up a fresh navigation graph, it's worth looking at where the whole library is heading with Navigation 3 for Jetpack Compose, which leans even harder into holding the back stack as ordinary typed state you own. The mental model there is the logical extension of what type-safe routes started: navigation is data, and data should be typed.

How I'd adopt it

On a greenfield screen, start type-safe from commit one — there's no reason not to. On an existing app, convert incrementally. String and typed destinations coexist in the same NavHost, so migrate the crash-prone screens first (usually the ones with three-plus arguments), verify deep links still resolve, and delete each string route once its last caller is converted. Budget an afternoon for a medium app, not a sprint.

The payoff isn't cleaner code for its own sake. It's that an entire category of "works on my device, crashes from that entry point" bugs becomes a red squiggle in the editor. That's the trade I'll take every time.

Resources

Frequently asked questions

What is type-safe navigation in Jetpack Compose?

Type-safe navigation is an approach where destinations and their arguments are described by Kotlin objects and data classes annotated with @Serializable instead of string routes with embedded placeholders. The Navigation Compose library serializes those objects into the back stack and hands you back a strongly typed instance, so the compiler enforces that every required argument is present and correctly typed.

Do I still need NavType for custom argument types?

For primitives and Strings, the serialization-based API handles everything. For custom types you either mark the class @Serializable and pass it as a nested route object, or register a custom NavType that tells the library how to encode and decode the value. Complex objects should generally be passed by ID, not serialized whole into the back stack.

Can I migrate a string-route app incrementally?

Yes. Type-safe routes and string routes can coexist in the same NavHost during a migration. You can convert one destination at a time, keeping the old composable(route = ...) calls alongside the new composable<Route> overloads, and delete the strings once every caller is converted.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →