7
\$\begingroup\$

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:

  1. 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?
  2. Are my doc comments clear?
  3. Could the method and class names be improved?
  4. Do all the methods seem useful? I added some that I don't use but I thought might be useful to other people.
  5. 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 arrived
  • Failure for when the data failed to arrive
  • Data for when the data has arrived and is present
  • Empty for when the data has arrived but is null or an empty collection

For 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 (the Empty 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 progress
  • Failure for when creating a widget has failed
  • Data 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. The Data for an EventState is named Complete to indicate that the action has been successfully completed.

Note that the Empty type and the Loading 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 an EventState, the loading state is the in progress state and the empty state is the idle state.

This library is inspired by Java Optional and Kotlin Result. 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.

\$\endgroup\$
2
  • \$\begingroup\$ Please add imports \$\endgroup\$
    – K.H.
    Mar 1, 2023 at 8:05
  • 1
    \$\begingroup\$ There are no imports \$\endgroup\$
    – Eva
    Mar 1, 2023 at 8:14

1 Answer 1

2
\$\begingroup\$

1. Would this be helpful as its own library?

Small libraries are good.

Libraries without dependencies are very good: they prevent all kinds of dependency clutter and maintenance headaches. Here it doesn't even come at the usual cost of reinventing any wheel, so you didn't lose anything: it's a win-win.

2. Documentation

Documentation is very strong throughout the technical side. My advice would be to use the Class documentation to explain in more details the mindset behind, or the use-cases for, this library. At the moment there is only the first line, then it gets to the technical bits. I found confusing what the use-cases were beyond what kotlin's own Result class brings.

At the moment this seems to be stacking expanding a Result with a special case of Empty, and a hint of a Future.

3. Could the method and class names be improved?

Yes.

The LoadState class

LoadState isn't very telling, in particular as I didn't see any loading or saving process state being described. It is mostly an enhanced Result

The maybe method

The maybe method name is too timid. I wouldn't know what it does without looking at the docs, and doesn't look good once written:

myBusinessFunction().maybe(defaultResult) { doSomethingWith(it) }

I believe you can reuse the map with suffix orDefault:

inline fun <T : Any, R : Any> LoadState<T>.mapOrDefault(default: R, transform: (T) -> R) = .....

But then it gets confusing, as to why one covers Empty, Loading or Failure scenarios, while the other doesn't? Before we go on, let's notice the behaviour of map() assigns to Empty to empty (itself), Loading to Loading (itself), but Failure is being unwrapped & wrapped anew. So, to simplify both I would refactor both map & maybe into:

inline fun <T : Any, R : Any> LoadState<T>.map(transform: (T) -> R) = when (this) {
    is LoadState.Data -> LoadState.Data(transform(value))
    else -> this
}

inline fun <T : Any, R : Any> LoadState<T>.mapOrDefault(default: LoadState<T> = this, transform: (T) -> R) = when (this) {
    is LoadState.Data -> LoadState.Data(transform(value))
    else -> default
}

And the same change shall be applied to mapCatching with a MapCatchingOrDefault:

inline fun <T : Any, R : Any> LoadState<T>.mapCatchingOrDefault(default: 
    LoadState<T> = this, transform: (T) -> R) = try {
    map(default, transform)
} catch (e: Throwable) {
    LoadState.Failure(e)
}

The flatMap method

flatMap is very badly named. The convention is to use flat- prefix to specify behavior when working with collections (or collections of collections) which isn't the case here.

4. Do all the methods seem useful?

No.

The EventState alias

I didn't see the point of the EventState type alias. I only see it being used as documentation for the Complete companion value, which means your API doesn't help much in using it. I suggest either dropping events, or alternatively fully covering them (as in helping define events, firing them, defining, triggers, maybe use of some Future or even coroutines).

The Complete value

The Complete itself is also confusing me, since the user can mimic it with a an Empty, while gaining some expressiveness because Empty is more richly tooled in your API. The point of this value is unclear.

The flatMap method

Coming back to this, I see no added value over map; it should go.

The filter method

Filtering is classically done on a Collection. Again you do not have a collection here; rather you have a superposition of objects of various types. So maybe you could rather offer the opposite as dropIf:

inline fun <T : Any> LoadState<T>.dropIf(predicate: (T) -> Boolean) =
if (this !is LoadState.Data || !predicate(value)) this else LoadState.Empty

The Loading object

I saw no special handling of this. It is treated as Empty. It even drops the type parameter, meaning whatever I was loading, I don't know its type any more. If & when you decide to add events, then you can do something with this.

5. Are there any more methods that would be useful to add?

Yes.

Interop with Result

I thought adding an extension to Kotlin's Result might be nice:

fun Result<Any>.toLoadState() : LoadState<Any> = LoadState.fromResult(this)

Add failure recovery:

To recover from a failure, and continue the flow of instructions:

inline fun <T: Any> LoadState<T>.recover(recovery: (Throwable) -> T) =
    if (this is LoadState.Failure) LoadState.fromNullable(recovery(cause)) else this

This would allow such data pipeline:

myBusinessFunc()
    .mapCatching { doSomethingRiskyWith(it) }
    .recover { (it as MySpecialException).findAlternative() }
    ....

6. Style

Code style is spot on: clean & very easy to read. Good job.

7. API Design

Treatment of Empty

You're apologizing for your API design in your docs:

 * 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.

You should really be providing a better support for Empty things. You decided Empty would be a first-class citizen, so you need to tool it accordingly.

In the same way as Result or Optional APIs try to avoid null-handling and opinionatedly force the user to handle nulls, I believe so should your API with respect to empty collections. So, do apply filterNotEmpty() everywhere there is a transform, but you can hide that method from the user. Then the user will know it never has an empty collection, only Empty objects. Same for null handling.

Treatment of Failure

You force the user to use a Failure object which can only carry an optional Throwable, yet you acknowledge that such throwables are not even necessary to end up with a Failure state (it is nullable).

I find this rather disappointing, because I will lose all context as to what failed exactly. Where is my error code? What object was I loading, that failed? Particularly with chained pipelines of transformations, it would be impossible to tell.

I would force a Failure to carry an dedicated state/context object, but:

  1. With generics that could become unwieldy.
  2. Without generics it would prove too weak (unable to make anything of an Object without casting).

Losing handles on objects

Loading and Empty are objects, so they should be singletons. If I'm handling multiple loading processes, and some are loading, they all end up as the same reference to Loading, and I won't know which is which. They should be a class, with multiple instances.

Losing type information

I hate losing type details.

Your Failure could as easily say Failure<MyExpectedResult>; same for Empty and Loading.

A Loading<Nothing> doesn't help me get the object I want, and prevents me from writing a getStatus method on my process:

val myProcess: Process<MyObj> = myInitProcess()
val loading: Loading = myProcess.status() // should return LoadState<Nothing>
await()
val complete: Loading = myProcess.status() // should return LoadState<MyObj>

General advice

I would have a good look at Result & Future. I believe you could build upon those (rather than next to them) to bring a pipeline builder.

Since you're handling Empty as a special case, I would look at maybe interoperating with Streams coming from collections.

\$\endgroup\$
1
  • \$\begingroup\$ Thank you for the detailed answer! I have edited my question clarifying things \$\endgroup\$
    – Eva
    May 11, 2023 at 18:16

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.