Skip to content

Container Type

This page describes Container<T> in depth: its states, how to create and inspect containers, how to transform them, and how to work with them in Kotlin Flows.

Table of Contents

Container States

Container<T> is a sealed class with three concrete states:

sealed class Container<out T> {
    object Pending : Container<Nothing>()

    sealed class Completed<out T> : Container<T>()

    data class Success<out T>(
        val value: T,
        val metadata: ContainerMetadata,
    ) : Completed<T>()

    data class Error(
        val exception: Exception,
        val metadata: ContainerMetadata,
    ) : Completed<Nothing>()
}
  • Container.Pending - an operation is in progress; no value is available yet
  • Container.Success<T> - the operation finished successfully; the loaded value is in value
  • Container.Error - the operation failed; the reason is in exception
  • Container.Completed<T> - abstract base for Success and Error; use it when you want to match any finished state

Both Success and Error carry an optional metadata: ContainerMetadata bag that can hold additional information such as the data source, a reload function, or background-load indicators. See Subjects for details on metadata.

Creating Containers

Use the top-level factory functions:

val pending = pendingContainer()

val success = successContainer("Hello, world!")
val successWithMeta = successContainer("Hello", SourceTypeMetadata(RemoteSourceType))

val error = errorContainer(IOException("network error"))

You can also combine metadata after creation using the + operator:

val container = successContainer("data") + ReloadFunctionMetadata { reload() }

Extracting Values

Several extension functions allow you to pull data out of a container without pattern matching:

Function Returns Behaviour
getOrNull() T? Value if Success, otherwise null
unwrap() T Value if Success, throws otherwise
exceptionOrNull() Exception? Exception if Error, otherwise null
getContainerValueOrNull() ContainerValue<T>? Value + metadata if Success
getContainerExceptionOrNull() ContainerValue<Exception>? Exception + metadata if Error
unwrapContainerValue() ContainerValue<T> Value + metadata if Success, throws otherwise
val container: Container<String> = ...

val value: String? = container.getOrNull()
val error: Exception? = container.exceptionOrNull()

// throws LoadNotFinishedException if Pending, or re-throws the exception if Error
val value: String = container.unwrap()

ContainerValue<T> is a simple wrapper that gives you access to both the value and the associated metadata:

val cv: ContainerValue<String> = container.getContainerValueOrNull() ?: return
println(cv.value)
println(cv.sourceType)               // shorthand for metadata.sourceType
println(cv.backgroundLoadState)

Pattern Matching with when / fold

You can use Kotlin's when expression to pattern-match on all three states:

when (container) {
    is Container.Pending -> { /* show a progress spinner */ }
    is Container.Error   -> { /* show an error message */ }
    is Container.Success -> { /* render the data */ }
}

Alternatively, fold is the primary way to handle all three states exhaustively and return a value in one expression:

val result: String = container.fold(
    onPending = { "Loading..." },
    onError   = { exception -> "Error: ${exception.message}" },
    onSuccess = { value -> value },
)

Two convenience variants are also available:

// returns defaultValue for unhandled states:
val text = container.foldDefault(
    defaultValue = "-",
    onError      = { ex -> "Error: ${ex.message}" },
    onSuccess    = { value -> value },
)

// returns null for unhandled states:
val text: String? = container.foldNullable(
    onSuccess = { value -> value },
)

Pattern Matching with isXXX extensions

The following extensions can be used for pattern-matching:

  • isSuccess
  • isError
  • isPending
  • isCompleted

All of them are compatible with Kotlin Smartcast.

For example:

val container: Container<String> = ...
if (container.isSuccess()) {
    println(container.value)
}

Transformations

All transformation functions pass Pending through unchanged. Error is also passed through unless the specific function handles it.

map

Transform the value inside a Success container:

val container: Container<Int> = successContainer(42)
val mapped: Container<String> = container.map { it.toString() }

transform

Full control over the output container. Receives the value (on success) or exception (on error) and must return a Container:

val result: Container<String> = container.transform(
    onError   = { ex -> errorContainer(RuntimeException("Wrapped", ex)) },
    onSuccess = { value -> successContainer(value.uppercase()) },
)

catch and catchAll

Intercept exceptions and replace the Error state with a new Container:

// catch a specific exception type:
val safe: Container<List<Item>> = container.catch(NetworkException::class) { ex ->
    successContainer(emptyList())
}

// catch any exception:
val safe: Container<List<Item>> = container.catchAll { ex ->
    successContainer(emptyList())
}

mapException

Map one exception type to another without changing the Error/Success outcome:

val mapped: Container<String> = container.mapException(IOException::class) { ioEx ->
    RuntimeException("IO failure", ioEx)
}

recover

Similar to catch, but easier to use if you want to recover from an exception to a success value. The recovery lambda receives the exception and returns a new successContainer out of the box:

val result: Container<String> = container.recover(TimeoutException::class) { ex ->
    "default value"
}

Flow Extensions

All Flow<Container<T>> extension functions follow the same naming convention: they mirror the container-level functions but operate on the flow level. Pending states are always passed through unchanged by default.

containerMap and containerMapLatest

val stringFlow: Flow<Container<String>> = intFlow
    .containerMap { number -> number.toString() }

// cancels the previous transform when a new container arrives:
val stringFlow: Flow<Container<String>> = intFlow
    .containerMapLatest { number ->
        delay(100)
        number.toString()
    }

StateFlow<Container<T>> has a dedicated variant that returns a StateFlow<Container<R>>:

val stringState: StateFlow<Container<String>> = intState
    .containerStateMap { it.toString() }

containerFlatMapLatest

Transforms each Success value into a new Flow<Container<R>>, cancelling the previous inner flow when a new outer value arrives. The resulting flow emits Pending while waiting for the inner flow:

val detailsFlow: Flow<Container<Details>> = idsFlow
    .containerFlatMapLatest { id ->
        repository.getDetails(id) // Flow<Container<Details>>
    }

containerFilter and containerFilterNot

Keep or drop Success containers whose values match a predicate. Pending and Error states are always passed through:

val nonEmptyFlow = listFlow.containerFilter { list -> list.isNotEmpty() }
val nonEmptyFlow = listFlow.containerFilterNot { list -> list.isEmpty() }

containerFold Variants

Collapse a Flow<Container<T>> into a Flow<R> by mapping each container state to a value:

val textFlow: Flow<String> = containerFlow.containerFold(
    onPending = { "Loading..." },
    onError   = { ex -> "Error: ${ex.message}" },
    onSuccess = { value -> value.toString() },
)

// with a default value for unhandled cases:
val textFlow: Flow<String> = containerFlow.containerFoldDefault(
    defaultValue = "-",
    onSuccess    = { value -> value.toString() },
)

// nullable result:
val textFlow: Flow<String?> = containerFlow.containerFoldNullable(
    onSuccess = { value -> value.toString() },
)

containerTransform

Emit a different container for each incoming Success or Error. Useful when you need to replace an error with a success or vice versa:

val result: Flow<Container<String>> = containerFlow.containerTransform(
    onError   = { ex -> errorContainer(RuntimeException("Wrapped", ex)) },
    onSuccess = { value -> successContainer(value.uppercase()) },
)

containerCatch, containerCatchAll, containerRecover

Mirror of the single-container functions, applied across the flow:

// convert a specific exception type to `successContainer`:
val resultFlow = flow.containerCatch(NetworkException::class) { ex ->
    successContainer(emptyList())
}

// convert any exception to `successContainer`:
val resultFlow = flow.containerCatchAll { ex -> successContainer(emptyList()) }

// recover with a value returned from lambda wrapping it into `successContainer`:
val resultFlow = flow.containerRecover(TimeoutException::class) { ex ->
    "default"
}

// map exception type to another:
val mapped = flow.containerMapException(IOException::class) { ex ->
    RuntimeException("IO failure", ex)
}

containerUpdate

Update metadata on every container in the flow without changing the value:

val flow: Flow<Container<String>> = source
    .containerUpdate {
        reloadFunction = ::loadList
    }

Combining Container Flows

combineContainerFlows

Combine two or more Flow<Container<T>> into a single flow. The result is:

  • Container.Pending if any input is Pending
  • Container.Error (first error wins) if any input is Error
  • Container.Success only when every input is Success
val combined: Flow<Container<String>> = combineContainerFlows(
    flow1 = getUserFlow(),   // Flow<Container<User>>
    flow2 = getItemsFlow(),  // Flow<Container<List<Item>>>
) { user, items ->
    "${user.name}: ${items.size} items"
}

Up to five flows can be combined with overloads; an arbitrary number can be combined using the list overload:

val combined = combineContainerFlows(
    flows = listOf(flow1, flow2, flow3),
) { values -> values.joinToString() }

containerCombineWith

Combine a Flow<Container<T>> with one or more plain (non-container) flows. The container state is propagated; the plain flow values are simply merged in:

val combined: Flow<Container<String>> = containerFlow
    .containerCombineWith(plainFlow) { value, extra ->
        "$value ($extra)"
    }

Type Aliases

The library provides several type aliases to reduce boilerplate:

typealias ListContainer<T>     = Container<List<T>>
typealias ContainerFlow<T>     = Flow<Container<T>>
typealias ListContainerFlow<T> = Flow<Container<List<T>>>

Usage:

fun getProducts(): ListContainerFlow<Product> = ...

Metadata

Both Container.Success and Container.Error carry a metadata: ContainerMetadata bag. The standard metadata properties are:

Property Type Meaning
sourceType SourceType Where the data came from
backgroundLoadState BackgroundLoadState A state of background loading (if any)
reloadFunction ReloadFunction Call this to re-trigger the load

Access them through shorthand extension properties on the container or through the metadata bag:

val success: Container.Success<String> = ...
val source = success.sourceType  // shorthand
val source = success.metadata.sourceType  // metadata extension
val source = success.metadata.get<SourceTypeMetadata>()?.sourceType  // explicit

Container.reload(config? = null, metadata: ContainerMetadata = EmptyMetadata) re-triggers the load and merges the optional metadata into the resulting container - a convenient way to tag why a reload happened.

For detailed metadata information, including SourceType values, LoadTrigger, how to attach custom metadata, and the ContainerMetadata.OneShot marker (metadata that applies only to the load it was attached to and is not carried into subsequent reloads), see Subjects.