Engineering

A route is a string until you make it a type

String routes fail at runtime on a typo or a missing argument. Serializable types move both into the compiler.

MobileByteSensei 8 min read
composenavigationandroid

TL;DR

A string route is a parser and a formatter written twice, by hand, at every call site. "detail/$id" and "detail/{id}" have to agree, and nothing checks that they do.

So a typo is a runtime crash, a missing argument is a runtime crash, a renamed argument compiles fine and crashes on navigation, and an optional argument's absence is a null you have to remember to handle. We shipped three of those four.

Type-safe navigation makes the route a @Serializable object. The destination declaration and the navigation call share one type, so the compiler does the agreement check — and a new required argument becomes a compile error at every call site instead of a crash on one path.

The case this post is about

A graph with four destinations, the string way:

NavHost(nav, startDestination = "list") {
    composable("list") { ListRoute(onItem = { id -> nav.navigate("detail/$id") }) }

    composable("detail/{itemId}") { entry ->
        val itemId = entry.arguments?.getString("itemId")!!      // parse, by hand
        DetailRoute(itemId)
    }

    composable("edit/{itemId}?draft={draft}") { entry ->
        val itemId = entry.arguments?.getString("itemId")!!
        val draft = entry.arguments?.getString("draft")          // optional: nullable
        EditRoute(itemId, draft)
    }

    composable("settings") { SettingsRoute() }
}

Fourteen navigate calls across the app, each composing a string.

The symptom

A typo that compiled:

nav.navigate("detials/$id")        // transposed
java.lang.IllegalArgumentException: Navigation destination that matches request
  NavDeepLinkRequest{ uri=android-app://androidx.navigation/detials/88214 }
  cannot be found in the navigation graph

Found by a user, in a flow reachable only from a long-press menu that no test covered.

A missing argument, also compiling:

nav.navigate("edit/")              // the id was an empty string from a stale state
NullPointerException at EditRoute(EditScreen.kt:28)
  entry.arguments?.getString("itemId")!!

A rename that broke silently. Someone renamed the argument in the declaration and missed one call site:

composable("detail/{id}") { … }           // was {itemId}
nav.navigate("detail/$itemId")            // still works — positional!
entry.arguments?.getString("itemId")      // now null → NPE

The route pattern matched because the argument is positional in the path; only the lookup broke. That one took an hour to find.

And an unencoded argument:

nav.navigate("search/${query}")           // query = "shoes/size 10"

The slash created a path segment, so the route did not match. Nobody had thought about encoding, because a string template does not invite you to.

crashes attributed to navigation, 90 days:  41
  destination not found (typos):            12
  argument null (missing/misnamed):          22
  malformed route (encoding):                 7

Forty-one crashes from a class the compiler could have prevented entirely.

Why the obvious fix failed

"Put the routes in constants." We did, and it fixed the typos and nothing else:

object Routes {
    const val DETAIL = "detail/{itemId}"
    fun detail(id: String) = "detail/$id"
}

Better — and the builder function and the pattern still have to agree by hand, the argument lookup is still a stringly-typed getString("itemId"), and encoding is still forgotten.

"Add a test that navigates to every destination." Helpful and it enumerates destinations, not call sites. The typo was in one of fourteen call sites, in a flow the test did not reach.

"Use a sealed class of route objects, with a path property." Closer, and the version I built first still formatted a string in the middle:

sealed class Route(val path: String) {
    data class Detail(val id: String) : Route("detail/$id")      // still a string, still unencoded
}

It centralises the formatting and leaves the parse side — getString — completely untouched.

"Generate the routes with KSP." This is what some libraries do and it works. We did not need to build it, because the navigation library gained type-safe routes.

The actual mechanism

A string route is two hand-written halves of one contract:

formatter:  "detail/$id"            at the navigate call site
parser:     "detail/{itemId}"       at the destination declaration
            + getString("itemId")   the extraction

Nothing relates them. The compiler sees three unrelated string expressions, so:

A typo in either half is valid Kotlin. "detials/$id" is a well-formed string.

The argument name is only checked at runtime, by a map lookup that returns null on a miss.

Positional matching hides a rename. A path pattern matches on structure, so the pattern keeps working while the lookup breaks — which is why that bug was the slowest to find.

And encoding is the caller's job with no reminder, because string interpolation does not distinguish data from structure. This is the same shape as SQL injection: data concatenated into a structured string.

Type-safe routes collapse the two halves into one declaration:

@Serializable data class Detail(val id: String)
  → the destination declares it      composable<Detail> { … }
  → the navigation call passes it    nav.navigate(Detail(id))
  → the extraction is typed          entry.toRoute<Detail>()

One type, three uses, and the compiler checks all three agree.

A string route as two hand-written halves of one contract — a formatter at each navigate call site and a pattern plus a map lookup at the destination — with nothing relating them, so a typo is valid Kotlin, an argument name is checked only at runtime, a rename keeps matching positionally while the lookup breaks, and encoding has no reminder; against one serializable type used by the declaration, the call and the extraction

A route is a structured value, and writing it as a string means hand-writing a parser and a formatter that nothing checks against each other. Making it a type hands both to the compiler.

The fix

@Serializable route types, with the graph declared in terms of them.

// one declaration per destination. These ARE the routes.
@Serializable data object ListRoute
@Serializable data class DetailRoute(val itemId: String)
@Serializable data class EditRoute(val itemId: String, val draft: String? = null)
@Serializable data object SettingsRoute

The sub-case that converts a compile-time win back into a runtime crash: these types still have to survive a URI. The compiler checks that the declaration, the call and the extraction agree on DetailRoute — it does not check that the navigation library knows how to put a DetailRoute into a back-stack entry. Primitives and enums it handles; anything else needs a NavType you supply:

@Serializable data class FilterRoute(val tags: List<String>)   // compiles fine

composable<FilterRoute>(
    typeMap = mapOf(typeOf<List<String>>() to TagsNavType),    // without this: crash at navigate
) { … }

Miss the typeMap and you get an IllegalArgumentException the first time that destination is reached — later than a compile error and earlier than the string version's silent null, which is the honest summary of what type safety bought here. The rule of thumb: a route field that is not a primitive, an enum or a @Serializable type with a declared NavType is a field the URI layer cannot represent, and keeping route types flat is usually cheaper than writing the converter.

NavHost(nav, startDestination = ListRoute) {

    composable<ListRoute> {
        ListScreen(onItem = { id -> nav.navigate(DetailRoute(itemId = id)) })
    }

    composable<DetailRoute> { entry ->
        val route: DetailRoute = entry.toRoute()          // typed. no getString, no !!
        DetailScreen(route.itemId, onEdit = { nav.navigate(EditRoute(route.itemId)) })
    }

    composable<EditRoute> { entry ->
        val route: EditRoute = entry.toRoute()
        EditScreen(route.itemId, route.draft)             // draft is String?, honestly
    }

    composable<SettingsRoute> { SettingsScreen() }
}

Five things this fixes, each mapping to one of the four symptoms:

Typos are compile errors. nav.navigate(DetialRoute(id)) does not resolve.

Arguments cannot be missing. DetailRoute() without itemId does not compile — a required constructor parameter is a required argument.

Renames propagate. Rename itemId and every call site and every usage fails to compile, with the IDE's rename refactoring handling it in one step.

Encoding is the library's job. The route is serialised, so "shoes/size 10" is encoded correctly without the caller thinking about it.

And optionality is in the type. draft: String? = null means the destination's route.draft is genuinely nullable and the compiler enforces handling it — rather than a getString that returns null for both "absent" and "misnamed".

Two further details worth having. Custom argument types need a NavType:

@Serializable data class FilterRoute(val filter: Filter)       // Filter is @Serializable

composable<FilterRoute>(
    typeMap = mapOf(typeOf<Filter>() to serializableNavType<Filter>()),
) { entry -> FilterScreen(entry.toRoute<FilterRoute>().filter) }

And deep links keep a declared pattern, which is the one place a string remains — correctly, since the external URL format is a published contract:

composable<DetailRoute>(
    deepLinks = listOf(navDeepLink<DetailRoute>(basePath = "https://example.com/item")),
) { … }

How we proved it

That each symptom is now a compile error, which is the whole claim:

// 1. the typo
nav.navigate(DetialRoute(id))
  e: Unresolved reference: DetialRoute

// 2. the missing argument
nav.navigate(DetailRoute())
  e: No value passed for parameter 'itemId'

// 3. the rename: rename itemId → id in DetailRoute
  e: No value passed for parameter 'id'          (at 3 call sites)
  e: Unresolved reference: itemId                (at 2 usages)

Three bugs, three compile errors, found by the compiler rather than by users.

Encoding, verified rather than assumed:

@Test
fun argumentsWithSlashesRoundTrip() {
    composeTestRule.setContent { AppNav(nav) }
    nav.navigate(SearchRoute(query = "shoes/size 10"))
    composeTestRule.waitForIdle()
    assertEquals("shoes/size 10", nav.currentBackStackEntry!!.toRoute<SearchRoute>().query)
}
string routes:  FAILED — destination not found
typed routes:   PASSED

And the production outcome over 90 days:

navigation crashes
  before:  41
  after:    0

  destination not found:   12 → 0
  argument null:           22 → 0
  malformed route:          7 → 0

The migration cost, since it touched the whole graph:

destinations:                    18
navigate call sites:             47
files changed:                   31
compile errors during migration: 83   ← each a place that had been stringly-typed
of those, actual latent bugs:     4   (2 wrong argument names, 2 unencoded)
time:                           1 day

Four latent bugs surfaced by the migration, in code nobody had reported yet.

One limit: type-safe navigation requires the kotlinx.serialization plugin and a recent Navigation-Compose version, and the custom-NavType story for non-primitive arguments is more verbose than it should be — we have three typeMap entries and they are the least pleasant part of the code. For primitives and strings, which is most routes, it is strictly better.

What this generalizes to

A stringly-typed structure is a hand-written parser and formatter that nothing checks. Routes, feature-flag keys, cache keys, event names, column names, environment variable names. Each pair of "write it" and "read it" sites has to agree, and the compiler is not involved.

Positional matching hides renames. Any format where position carries meaning — a path, a CSV, a positional argument list — keeps working after a rename while the named lookup breaks. That asymmetry makes the bug slow to diagnose.

Encoding is forgotten whenever data is interpolated into structure. String templates make "detail/$id" look like concatenation rather than construction. The same reasoning that produces parameterised SQL produces typed routes.

A migration that produces compile errors is a bug list. Eighty-three errors, four of them latent bugs. Moving a runtime contract into the type system does not just prevent future mistakes — it enumerates the current ones.

Keep reading

This is part of 100 Days of Compose — the mechanisms underneath the API, measured rather than guessed. Follow MobileByteSensei for the rest.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS