I have a sealed interface that I use in pretty much every one of my Android projects. It implements the loading-content-error pattern, often called a Resource
, Response
, or Result
. (I chose LoadState
instead as a name since I've found it generally has fewer naming conflicts.) My class is different in that it adds a 4th state: Empty
, which I hope I explain well enough in the docs.
In addition to general code health review, I have a few questions:
- Would this be helpful as its own library? I haven't seen any Kotlin libraries that are this small. If it were JavaScript, I'd know it'd be ok. Is a library this small bad practice for Kotlin?
- Are my doc comments clear?
- Could the method and class names be improved?
- Do all the methods seem useful? I added some that I don't use but I thought might be useful to other people.
- Are there any more methods that would be useful to add?
/**
* Represents fetched data from an external resource, such as the network or disk.
*
* An implementation of the loading-content-error pattern with an additional state of [Empty]. The
* [Empty] state can be used as a null object pattern, a representation of an empty collection, or
* the idle state of a remote operation (e.g. a save or a delete). Typically, UI code will handle
* these special cases differently from the [Data] case, so it is helpful to represent it as an
* entirely different type.
*
* Example:
*
* ```
* // Repository
*
* fun getLoadStateFlow(): Flow<LoadState<Foo>> =
* callNetwork().map { LoadState.fromNullable(it) }.catch { emit(LoadState.Failure(it)) }
*
* // ViewModel
*
* val loadStateFlow =
* repository.getLoadStateFlow()
* .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), LoadState.Loading)
*
* // Fragment/Composable
*
* when (loadState) {
* is LoadState.Data -> showContent(loadState.value)
* is LoadState.Empty -> showEmptyScreen()
* is LoadState.Loading -> showLoadingIndicator()
* is LoadState.Failure -> showErrorMessage(loadState.cause)
* }
* ```
*
* @param T The type of the encapsulated value if it has been successfully fetched and is non-null
* and non-empty, i.e. the type is [Data]
*/
sealed interface LoadState<out T : Any> {
/**
* Returns the encapsulated value if present, otherwise `null`.
*
* @return The encapsulated value if this type is [Data]
*/
fun getOrNull() = (this as? Data)?.value
companion object {
/** Represents an [EventState] that has been completed successfully. */
val Complete = Data(Unit)
/**
* Returns a [Data] containing the specified [value] if it is present (i.e. non-null and
* non-empty), otherwise [Empty].
*
* @param value The possibly null, possibly empty value
* @return A [Data] containing [value] if it is non-null or a non-empty collection,
* otherwise [Empty]
*/
fun <T : Any> fromNullable(value: T?) =
if (value == null || value is Collection<*> && value.isEmpty()) {
Empty
} else {
Data(value)
}
/**
* Converts [result] into a [LoadState] using the success value and the failure exception.
*
* @param result The `Result` to convert
* @return If [result] is success and the value is not an empty collection, then a [Data]
* with the success value. If it is success and the value is an empty collection, then
* [Empty]. Otherwise if it is failure, then [Failure] with the exception as its cause.
*/
fun <T : Any> fromResult(result: Result<T>) =
result.fold(
{ value -> if (value is Collection<*> && value.isEmpty()) Empty else Data(value) },
{ cause -> Failure(cause) },
)
}
/**
* Represents successfully fetched data.
*
* The constructor does not force the [value] to be non-empty if it is a collection. Use
* [LoadState.fromNullable] instead if you need to ensure that condition.
*
* @property value The value fetched
*/
data class Data<out T : Any>(val value: T) : LoadState<T>
/**
* Represents the empty state of fetched data.
*
* This state can be used as a null object pattern, a representation of an empty collection, or
* the idle state of a remote operation (e.g. a save or a delete). Typically, UI code will
* handle these special cases differently from the [Data] case, so it is helpful to represent it
* as an entirely different type.
*/
object Empty : LoadState<Nothing> {
override fun toString(): String {
return "Empty"
}
}
/**
* Represents the loading state of fetched data.
*
* Can also be used to represent the in-progress state of a remote operation (e.g. a save or a
* delete).
*/
object Loading : LoadState<Nothing> {
override fun toString(): String {
return "Loading"
}
}
/**
* Represents the error state of fetched data.
*
* @property cause The exception thrown to cause this state. Not every failure state comes from
* throwing an exception, so `cause` may be null.
*/
data class Failure(val cause: Throwable? = null) : LoadState<Nothing>
}
/**
* A [LoadState] representing a remote operation that can be completed, such as a save or a delete.
*
* @see LoadState.Complete
*/
typealias EventState = LoadState<Unit>
/**
* Returns the encapsulated value if present, otherwise [default].
*
* This function is a shorthand for `getOrElse { default }`.
*
* @param default The value to be returned if the receiver is not a [LoadState.Data]
* @return The encapsulated value if the receiver is a [LoadState.Data], otherwise [default]
* @see getOrElse
*/
fun <T : Any> LoadState<T>.getOrDefault(default: T) = getOrElse { default }
/**
* Returns the encapsulated value if present, otherwise the result of invoking [default].
*
* May be used instead of [getOrDefault] if the default value is expensive to calculate.
*
* Note this function rethrows any [Throwable] exceptions thrown by [default].
*
* @param default A thunk whose result is returned if the receiver is not a [LoadState.Data]
* @return The encapsulated value if the receiver is a [LoadState.Data], otherwise the result of
* invoking [default]
*/
inline fun <T : Any> LoadState<T>.getOrElse(default: () -> T) = getOrNull() ?: default()
/**
* Applies [transform] to the encapsulated value if present, otherwise returns [default].
*
* Note this function rethrows any [Throwable] exceptions thrown by [transform].
*
* @param default The value to be returned if the receiver is not a [LoadState.Data]
* @param transform A function to apply to the encapsulated value if the receiver is a
* [LoadState.Data]
* @return The result of applying [transform] to the encapsulated value if the receiver is a
* [LoadState.Data], otherwise [default]
*/
inline fun <T : Any, R : Any> LoadState<T>.maybe(default: R, transform: (T) -> R) =
if (this is LoadState.Data) transform(value) else default
/**
* Invokes [consumer] on the encapsulated value if present, otherwise does nothing.
*
* Note this function rethrows any [Throwable] exceptions thrown by [consumer].
*
* @param consumer The block executed on the encapsulated value if the receiver is a
* [LoadState.Data]
*/
inline fun <T : Any> LoadState<T>.ifPresent(consumer: (T) -> Unit) {
if (this is LoadState.Data) {
consumer(value)
}
}
/**
* Returns the encapsulated result of applying [transform] to the encapsulated value if present,
* otherwise a [LoadState] of the original type.
*
* Note this function rethrows any [Throwable] exceptions thrown by [transform]. See [mapCatching]
* for an alternative that encapsulates exceptions.
*
* Note if [transform] returns an empty collection, this function will still return a
* [LoadState.Data]. To force a `Data` result to always be non-empty, call [filterNotEmpty] on the
* result of this function.
*
* @param transform A mapper function to apply to the encapsulated value if the receiver is a
* [LoadState.Data]
* @return The encapsulated result of applying [transform] to the encapsulated value if the receiver
* is a [LoadState.Data], otherwise a [LoadState] of the original type. If the receiver is a
* [LoadState.Failure], the original cause will be preserved.
*/
inline fun <T : Any, R : Any> LoadState<T>.map(transform: (T) -> R) = when (this) {
is LoadState.Data -> LoadState.Data(transform(value))
is LoadState.Empty -> LoadState.Empty
is LoadState.Loading -> LoadState.Loading
is LoadState.Failure -> LoadState.Failure(cause)
}
/**
* Returns the encapsulated result of applying [transform] to the encapsulated value if present,
* otherwise a [LoadState] of the original type.
*
* Note this function catches any [Throwable] exception thrown by [transform] and encapsulates it in
* a [LoadState.Failure]. See [map] for an alternative that rethrows exceptions.
*
* Note if [transform] returns an empty collection, this function will still return a
* [LoadState.Data]. To force a `Data` result to always be non-empty, call [filterNotEmpty] on the
* result of this function.
*
* @param transform A mapper function to apply to the encapsulated value if the receiver is a
* [LoadState.Data]
* @return The encapsulated result of applying [transform] to the encapsulated value if the receiver
* is a [LoadState.Data], otherwise a [LoadState] of the original type. If the receiver is a
* [LoadState.Failure], the original cause will be preserved.
*/
inline fun <T : Any, R : Any> LoadState<T>.mapCatching(transform: (T) -> R) = try {
map(transform)
} catch (e: Throwable) {
LoadState.Failure(e)
}
/**
* Returns the result of applying [transform] to the encapsulated value if present, otherwise a
* [LoadState] of the original type.
*
* This function is similar to [map], but the provided mapper function already returns a
* [LoadState], and if invoked, `flatMap` does not wrap it with an additional `LoadState`.
*
* Note this function rethrows any [Throwable] exceptions thrown by [transform]. See
* [flatMapCatching] for an alternative that encapsulates exceptions.
*
* @param transform A mapper function to apply to the encapsulated value if the receiver is a
* [LoadState.Data]
* @return The result of applying [transform] to the encapsulated value if the receiver is a
* [LoadState.Data], otherwise a [LoadState] of the original type. If the receiver is a
* [LoadState.Failure], the original cause will be preserved.
*/
inline fun <T : Any, R : Any> LoadState<T>.flatMap(transform: (T) -> LoadState<R>) = when (this) {
is LoadState.Data -> transform(value)
is LoadState.Empty -> LoadState.Empty
is LoadState.Loading -> LoadState.Loading
is LoadState.Failure -> LoadState.Failure(cause)
}
/**
* Returns the result of applying [transform] to the encapsulated value if present, otherwise a
* [LoadState] of the original type.
*
* This function is similar to [mapCatching], but the provided mapper function already returns a
* [LoadState], and if invoked, `flatMapCatching` does not wrap it with an additional `LoadState`.
*
* Note this function catches any [Throwable] exception thrown by [transform] and encapsulates it in
* a [LoadState.Failure]. See [flatMap] for an alternative that rethrows exceptions.
*
* @param transform A mapper function to apply to the encapsulated value if the receiver is a
* [LoadState.Data]
* @return The result of applying [transform] to the encapsulated value if the receiver is a
* [LoadState.Data], otherwise a [LoadState] of the original type. If the receiver is a
* [LoadState.Failure], the original cause will be preserved.
*/
inline fun <T : Any, R : Any> LoadState<T>.flatMapCatching(transform: (T) -> LoadState<R>) = try {
flatMap(transform)
} catch (e: Throwable) {
LoadState.Failure(e)
}
/**
* Returns the receiver if the encapsulated value is either not present or the value matches the
* specified [predicate], otherwise [LoadState.Empty].
*
* @param predicate A predicate to apply to the encapsulated value if the receiver is a
* [LoadState.Data]
* @return The receiver if either it is not a [LoadState.Data] or if applying [predicate] to the
* encapsulated value returns `true`, otherwise [LoadState.Empty]
*/
inline fun <T : Any> LoadState<T>.filter(predicate: (T) -> Boolean) =
if (this !is LoadState.Data || predicate(value)) this else LoadState.Empty
/**
* Returns the receiver if the encapsulated value is either not present or is a non-empty
* collection, otherwise [LoadState.Empty].
*
* This function is a shorthand for `filter { it.isNotEmpty() }`.
*
* This function is useful to call after [map] or [mapCatching] if the transform function might
* return an empty collection, in order to preserve the condition that [LoadState.Data] only
* contains non-empty values.
*
* @return The receiver if either it is not a [LoadState.Data] or if the encapsulated value is
* non-empty, otherwise [LoadState.Empty]
* @see filter
*/
fun <T : Any, C : Collection<T>> LoadState<C>.filterNotEmpty() = filter { it.isNotEmpty() }
EDIT
One possible improvement I can think of is adding a mapNotEmpty
function. On the one hand, it jives with my attempt to make it easy to maintain the condition that the value of LoadState.Data
is never an empty list. The way it is now, you have to remember to call filterNotEmpty
after map
if it's possible your transform
function will return an empty list. Also it parallels to List.mapNotNull
. On the other hand, it might be a useless function. Tbh my implementation would just be map(transform).filterNotEmpty()
. I've never needed that usecase. I can't think of a real-world example where I would return an empty list from map
. What do you think?
EDIT
Clarifying some things from MrBrushy's answer:
Documentation
I like having the KDoc remain technical. I think motivation docs would be better in the project README. Maybe something like this:
Fetching data asynchronously from a remote source presents some complications beyond a normal synchronous function call. The data might take some time to load, it might fail to arrive, or the request could complete successfully but return null data or an empty collection. Typically the UI layer will handle these 3 states differently from the normal success state.
To capture all this information in one type, we need a sealed interface with 4 subtypes:
Loading
for when the data has not yet arrivedFailure
for when the data failed to arriveData
for when the data has arrived and is presentEmpty
for when the data has arrived but is null or an empty collectionFor example, say you want to load and display a list of widgets on your screen. You would first start the screen displaying the loading indicator. Then you would trigger the load. If the load failed, you would display the error message. If the load succeed but there were no widgets, you would display a message saying there were no widgets. If the load succeeded and there were widgets, then you would display the list of widgets.
Most implementations of this pattern fail to differentiate between a successful load containing data (the
Data
type) and a successful load that does not contain data (theEmpty
type). This is despite the UI layer typically having a completely different screen for an empty message than from the present data. This library makes that distinction explicit.A side benefit of having 4 states is that it makes representing the state of remote actions very straightforward. So for example, if the user presses a button to create a new widget, you could store the state of that action like so:
Loading
for when the request to create the widget is in progressFailure
for when creating a widget has failedData
for when the widget was successfully created. Since there is no value, the value can just be Unit.Empty
for when the button has not been pressed yet. Basically an idle state.This library reifies that concept using the
EventState
. TheData
for anEventState
is namedComplete
to indicate that the action has been successfully completed.Note that the
Empty
type and theLoading
type have almost the same implementation, but represent two entirely different states that the UI layer will handle completely differently. The loading state exists before a load is fulfilled, while the empty state exists after a load has been fulfilled. Or alternatively, for anEventState
, the loading state is the in progress state and the empty state is the idle state.This library is inspired by Java
Optional
and KotlinResult
. It combines both concepts into a single type that is useful for representing the state of data loaded from an external resource.
Naming
I call it a load state because it's the state of loadable data. I don't want to use Resource, Response, or Result because I frequently ran into naming conflicts when trying those out. This one has a conflict with androidx.paging.LoadState
but I've found it to be a minor inconvenience for the most part. If there's a better name that is both expressive and not heavily overloaded then that would be the way to go.
I think I will need to rework both the name and the docs of the maybe
method because you seem to have misunderstood it. I took the name from the Haskell maybe
function, which is probably why it feels out of place. (Haskell calls their Optional
a Maybe
.) maybe
isn't the same as mapOrDefault
. map
returns a LoadState
, but maybe
returns an unwrapped value. loadState.map(transform).getOrDefault(default) == loadState.maybe(default, transform)
. It's both map AND get, not just map.
map
is implemented the way it is to ensure the correct return type. If I implement it the way you show, it would return a LoadState<Any>
instead of a LoadState<R>
and I would lose type information.
Actually flatMap
is a perfectly normal name for any type that is monad-ish, not just collections. Java uses flatMap
in its Optional type. If a type is a computational context where the inner type can be transformed (mapped) and the type supports removing multiple levels of wrapping (flattening), then it can be flattened and mapped in one go (flat map).
Useless methods
EventState
and Complete
I actually use regularly in my app code. I think I just need to explain why they're helpful better in the docs. Maybe EventState
should be renamed to ActionState
?
I haven't actually used flatMap
so I can drop that. I just added it because Java Optional
has it.
I like the renaming of filter
. You're right, that is a collection method name. I will replace it with keepIf
and dropIf
.
Keeping the 4 states separate is important. Enums all have the same implementation, but represent different concepts. A sealed interface is more or less an enum but you can (optionally) put different implementations.
Useful methods
All of those are really good ideas thanks.
API Design
That's a good point about Empty
. I'll get rid of filterNotEmpty
and be opinionated.
None of the methods in this library drop the cause of the Failure
. For a normal Kotlin Throwable
, the cause
is nullable. In most of my app code, I handle all load failures by displaying the same error message. I like having the additional failure data be optional.
I will change Loading
and Empty
to classes and get rid of Nothing
. That's a good point.