How to Implement Type-Safe Navigation Arguments in Decompose

Decompose achieves type-safe navigation by modeling the navigation stack as a generic StackNavigation<Config>, where a @Serializable configuration class carries all screen arguments, ensuring compile-time safety and automatic state restoration.

Decompose, created by Arkadii Ivanov, is a Kotlin Multiplatform library for breaking down apps into lifecycle-aware components. Implementing type-safe navigation arguments in Decompose relies on a strongly-typed configuration stack that eliminates runtime casting errors and survives process death through built-in serialization.

Understanding the Typed Navigation Stack

The foundation of type-safe navigation in Decompose is the StackNavigation<C> interface, defined in decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/stack/StackNavigation.kt. This interface implements StackNavigator<C>, which provides the high-level navigation functions: pushNew, pop, popTo, and navigate.

By declaring your navigation instance with a specific configuration type—such as StackNavigation<ScreenConfig>—the compiler enforces that only instances of ScreenConfig can be pushed onto the stack. This generic constraint propagates through the entire navigation flow, from the navigation source to the child component factory.

Defining Configuration Classes with Arguments

To pass arguments safely, you define a configuration class that holds all necessary data for a destination. Decompose requires this class to be @Serializable so the navigation stack can be saved and restored during configuration changes or process death.

Data Classes for Simple Arguments

For straightforward scenarios, use a data class:

@Serializable
data class ItemConfig(val itemId: Long, val category: String)

Sealed Classes for Multiple Screens

For applications with distinct screens, use a sealed class to enumerate all possible destinations:

@Serializable
sealed class ScreenConfig {
    @Serializable
    data object Home : ScreenConfig()
    
    @Serializable
    data class Details(val id: Int, val title: String) : ScreenConfig()
    
    @Serializable
    data class Edit(val itemId: String, val draft: String?) : ScreenConfig()
}

Each subclass carries its specific arguments, and the compiler ensures exhaustive when expressions in your child factory.

Wiring Navigation with childStack

The childStack function, located in decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/stack/ChildStackFactory.kt, connects your StackNavigation source to your component factory. It requires the configuration serializer to handle state restoration.

class RootComponent(
    componentContext: ComponentContext,
) : ComponentContext by componentContext {

    private val navigation = StackNavigation<ScreenConfig>()

    val stack: Value<ChildStack<*, MyScreen>> = childStack(
        source = navigation,
        serializer = ScreenConfig.serializer(),
        initialConfiguration = ScreenConfig.Home,
        childFactory = ::createScreen,
    )

    private fun createScreen(
        config: ScreenConfig,
        context: ComponentContext,
    ): MyScreen = when (config) {
        is ScreenConfig.Home -> HomeScreen(
            componentContext = context,
            onNavigateToDetails = { id, title ->
                navigation.pushNew(ScreenConfig.Details(id = id, title = title))
            }
        )
        is ScreenConfig.Details -> DetailsScreen(
            componentContext = context,
            id = config.id,
            title = config.title
        )
        is ScreenConfig.Edit -> EditScreen(
            componentContext = context,
            itemId = config.itemId,
            draft = config.draft
        )
    }
}

The createScreen function receives the exact ScreenConfig subtype, allowing direct access to arguments like config.id without casting.

Decompose provides multiple ways to manipulate the navigation stack, all maintaining type safety through the generic StackNavigator<C> interface defined in decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/stack/StackNavigator.kt.

Pushing New Screens with pushNew

The pushNew function adds a configuration to the top of the stack:

navigation.pushNew(ScreenConfig.Details(id = 42, title = "Type Safety"))

As seen in sample/shared/counters/DefaultCountersComponent.kt, this pattern propagates arguments through the stack:

// From DefaultCountersComponent.kt
private val navigation = StackNavigation<Config>()

navigation.pushNew(Config(index = config.index + 1, isBackEnabled = true))

Advanced Stack Manipulation with navigate

For complex transitions, use the navigate function, which provides the entire current stack as a List<C> and expects a new list in return. This enables reordering, filtering, or replacing multiple screens atomically.

The sample/shared/cards/DefaultCardsComponent.kt demonstrates bringing a specific card to the front:

navigation.navigate { stack ->
    val config = stack[index]          // Access typed Config safely
    listOf(config) + (stack - config) // Reorder: move to front
}

Because the lambda operates on List<Config>, accessing config.id or other properties requires no casting, and the compiler verifies that the returned list contains only Config instances.

Summary

  • Type-safe navigation arguments in Decompose rely on a generic StackNavigation<Config> where Config is a @Serializable class carrying all screen arguments.
  • The configuration class acts as the single source of truth for navigation state, enabling automatic restoration after process death or configuration changes.
  • Compile-time safety is enforced through Kotlin's type system: pushNew, pop, popTo, and navigate all operate exclusively on the declared Config type.
  • Child factories receive the exact configuration instance, allowing direct access to arguments without runtime casting or bundle key management.

Frequently Asked Questions

Can I use type-safe navigation arguments with Decompose's slot navigation?

Yes. While this guide focuses on stack navigation, Decompose also provides SlotNavigation<C> for single-child (slot) navigation. The same principles apply: define a @Serializable configuration class, create SlotNavigation<YourConfig>, and use childSlot with the serializer. The compiler enforces type safety for arguments in slot navigation exactly as it does for stacks.

How does Decompose handle argument serialization for state restoration?

Decompose uses kotlinx.serialization to persist the navigation stack. When you call childStack or childSlot, you provide a serializer parameter (e.g., serializer = Config.serializer()). During configuration changes or process death, Decompose serializes the list of configurations using this serializer and restores it upon recreation. This requires your configuration class to be marked with @Serializable and contain only serializable properties.

What happens if I try to push a different type onto the stack?

The Kotlin compiler will raise a type error. Because StackNavigation is declared with a specific generic parameter (e.g., StackNavigation<ScreenConfig>), the pushNew method accepts only instances of that exact type. Attempting to pass a String, Int, or a different configuration class will fail at compile time, preventing runtime crashes from mismatched arguments.

Can I pass complex objects or non-primitive types as navigation arguments?

Yes, provided they are serializable with kotlinx.serialization. Your configuration class can contain other @Serializable data classes, lists, maps, or enums. However, you cannot pass platform-specific types (like Android View or Context) or non-serializable objects (like lambdas or open classes) because Decompose must serialize the stack for state restoration. For complex dependencies, use dependency injection alongside the serializable configuration.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →