Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// Copyright 2026 Nacho Lopez
// SPDX-License-Identifier: MIT
package io.nlopez.asyncresult

import kotlin.coroutines.cancellation.CancellationException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.flow

/**
* Scope for the [resultFlow] builder. Provides explicit emission functions for each [AsyncResult]
* state: [success], [error], [loading], and [notStarted].
*
* All functions return [Unit] and can be called multiple times to emit multiple values.
*/
public interface ResultFlowScope<in T> {
/** Emits a [Success] wrapping [value]. */
public suspend fun success(value: T)

/** Emits the given [Error]. */
public suspend fun error(error: Error)

/** Emits [Loading]. */
public suspend fun loading()

/** Emits [NotStarted]. */
public suspend fun notStarted()
}

/** Emits an [Error] wrapping the given [throwable] and optional [errorId]. */
public suspend fun <T> ResultFlowScope<T>.error(
throwable: Throwable,
errorId: ErrorId? = null,
): Unit = error(Error(throwable, errorId = errorId))

/** Emits an [Error] carrying typed [metadata] and optional [errorId]. */
public suspend fun <T> ResultFlowScope<T>.errorWithMetadata(
metadata: Any,
errorId: ErrorId? = null,
): Unit = error(ErrorWithMetadata(metadata, errorId))

/**
* Creates a [Flow] of [AsyncResult] with a DSL that provides explicit
* [success][ResultFlowScope.success], [error][ResultFlowScope.error],
* [loading][ResultFlowScope.loading], and [notStarted][ResultFlowScope.notStarted] emissions.
*
* Unlike the [result] DSL which produces a single [AsyncResult], this builder produces a [Flow]
* that can emit multiple [AsyncResult] values over time.
*
* Uncaught exceptions (except [CancellationException]) are caught and emitted as [Error].
*
* Example:
* ```kotlin
* val userFlow: Flow<AsyncResult<User>> = resultFlow {
* loading()
* val user = fetchUser(id)
* success(user)
* }
* ```
*
* @param block The builder block that emits [AsyncResult] values via the [ResultFlowScope].
* @return A [Flow] of [AsyncResult] values.
*/
public fun <T> resultFlow(
block: suspend ResultFlowScope<T>.() -> Unit,
): Flow<AsyncResult<T>> = flow {
try {
ResultFlowScopeImpl(this).block()
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
emit(Error(e))
}
}

internal class ResultFlowScopeImpl<T>(
private val collector: FlowCollector<AsyncResult<T>>,
) : ResultFlowScope<T> {
override suspend fun success(value: T) {
collector.emit(Success(value))
}

override suspend fun error(error: Error) {
collector.emit(error)
}

override suspend fun loading() {
collector.emit(Loading)
}

override suspend fun notStarted() {
collector.emit(NotStarted)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
// Copyright 2026 Nacho Lopez
// SPDX-License-Identifier: MIT
package io.nlopez.asyncresult

import assertk.assertThat
import assertk.assertions.containsExactly
import assertk.assertions.isEqualTo
import assertk.assertions.isInstanceOf
import assertk.assertions.isNotNull
import kotlin.test.Test
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.runTest

class AsyncResultResultFlowTest {

@Test
fun `success emits Success value`() = runTest {
val results = resultFlow<Int> { success(42) }.toList()
assertThat(results).containsExactly(Success(42))
}

@Test
fun `error emits Error instance`() = runTest {
val err = Error(Throwable("boom"))
val results = resultFlow<Int> { error(err) }.toList()
assertThat(results).containsExactly(err)
}

@Test
fun `loading emits Loading`() = runTest {
val results = resultFlow<Int> { loading() }.toList()
assertThat(results).containsExactly(Loading)
}

@Test
fun `notStarted emits NotStarted`() = runTest {
val results = resultFlow<Int> { notStarted() }.toList()
assertThat(results).containsExactly(NotStarted)
}

@Test
fun `multiple emissions in sequence`() = runTest {
val results =
resultFlow<Int> {
loading()
success(1)
success(2)
}
.toList()
assertThat(results).containsExactly(Loading, Success(1), Success(2))
}

@Test
fun `error with Throwable and errorId`() = runTest {
val throwable = RuntimeException("fail")
val errorId = ErrorId("err-1")
val results = resultFlow<Int> { error(throwable, errorId) }.toList()

assertThat(results.size).isEqualTo(1)
val err = results[0] as Error
assertThat(err.throwable).isEqualTo(throwable)
assertThat(err.errorId).isEqualTo(errorId)
}

@Test
fun `error with Throwable only`() = runTest {
val throwable = RuntimeException("fail")
val results = resultFlow<Int> { error(throwable) }.toList()

assertThat(results).containsExactly(Error(throwable))
}

@Test
fun `errorWithMetadata emits Error with typed metadata`() = runTest {
data class MyMeta(val code: Int)
val results = resultFlow<Int> { errorWithMetadata(MyMeta(404)) }.toList()

assertThat(results.size).isEqualTo(1)
val err = results[0] as Error
assertThat(err.metadataOrNull<MyMeta>()).isEqualTo(MyMeta(404))
}

@Test
fun `errorWithMetadata with errorId`() = runTest {
data class MyMeta(val code: Int)
val errorId = ErrorId("not-found")
val results = resultFlow<Int> { errorWithMetadata(MyMeta(404), errorId) }.toList()

assertThat(results.size).isEqualTo(1)
val err = results[0] as Error
assertThat(err.metadataOrNull<MyMeta>()).isEqualTo(MyMeta(404))
assertThat(err.errorId).isEqualTo(errorId)
}

@Test
fun `uncaught exception emitted as Error`() = runTest {
val exception = IllegalStateException("unexpected")
val results = resultFlow<Int> { throw exception }.toList()

assertThat(results.size).isEqualTo(1)
val err = results[0] as Error
assertThat(err.throwable).isNotNull()
assertThat(err.throwable).isEqualTo(exception)
}

@Test
fun `CancellationException propagates and is not caught`() = runTest {
var caught = false
try {
resultFlow<Int> { throw kotlinx.coroutines.CancellationException("cancelled") }.toList()
} catch (e: kotlinx.coroutines.CancellationException) {
caught = true
assertThat(e.message).isEqualTo("cancelled")
}
assertThat(caught).isEqualTo(true)
}

@Test
fun `empty block emits nothing`() = runTest {
val results = resultFlow<Int> {}.toList()
assertThat(results).containsExactly()
}

@Test
fun `uncaught exception after partial emissions preserves earlier values`() = runTest {
val exception = IllegalStateException("mid-stream failure")
val results =
resultFlow<Int> {
loading()
success(42)
throw exception
}
.toList()

assertThat(results.size).isEqualTo(3)
assertThat(results[0]).isEqualTo(Loading)
assertThat(results[1]).isEqualTo(Success(42))
val err = results[2] as Error
assertThat(err.throwable).isEqualTo(exception)
}

@Test
fun `mixed emissions loading then error then success`() = runTest {
val results =
resultFlow<Int> {
loading()
error(Error(Throwable("oops")))
success(99)
}
.toList()
assertThat(results.size).isEqualTo(3)
assertThat(results[0]).isEqualTo(Loading)
assertThat(results[1]).isInstanceOf<Error>()
assertThat(results[2]).isEqualTo(Success(99))
}
}