Core API¶
The asyncresult artifact contains the core types and utilities for modeling asynchronous operations.
Types¶
The library provides a sealed class hierarchy to represent the different states:
NotStarted- Idle state before an operation begins. Useful for user-initiated actions that haven't started yet.Loading- The operation is in-flight.Success<T>- The operation completed successfully, containing the result value.Error- The operation failed. Can optionally contain aThrowable, typed metadata, and anErrorIdfor tracking.Incomplete- A marker interface implemented by bothNotStartedandLoading, useful for bundling them in exhaustivewhenstatements.
val result: AsyncResult<User> = Loading
// Error with metadata
val error = ErrorWithMetadata(NetworkFailure("timeout"))
// Error with ID for tracking
val error = ErrorWithId(ErrorId("req-123"))
// Error with both
val error = Error(
throwable = IOException(),
metadata = NetworkFailure("timeout"),
errorId = ErrorId("req-123")
)
Checking state¶
Query the current state of an AsyncResult:
val result: AsyncResult<User> = fetchUser()
// Simple state checks
result.isSuccess // true if Success
result.isError // true if Error
result.isIncomplete // true if Loading or NotStarted
Conditional checks¶
Inspired by Rust's is_ok_and and is_err_and:
// Check if Success and the value matches a predicate
result.isSuccessAnd { it.isAdmin } // true if Success AND user is admin
// Check if Success contains a specific value
result.contains(expectedUser) // true if Success AND value == expectedUser
// Check if Error and the throwable matches a predicate
result.isErrorAnd { it is IOException } // true if Error AND throwable is IOException
// Check if Error with typed metadata matching a predicate
result.isErrorWithMetadataAnd<User, NetworkFailure> { throwable, metadata ->
metadata?.isRetryable == true
}
Transforming values¶
Mapping¶
Transform the success value while preserving the result state:
val userName: AsyncResult<String> = userResult.mapSuccess { it.name }
Transform errors:
val enrichedResult = result.mapError { error ->
error.withMetadata(NetworkFailure)
}
// Transform both at once
val transformed = result.bimap(
success = { it.name },
error = { it.withMetadata("Failed to load") }
)
Folding¶
Generate a value from any state:
val message = result.fold(
ifNotStarted = { "Waiting" },
ifLoading = { "Loading..." },
ifSuccess = { "Hello ${it.name}" },
ifError = { "Error: ${it.throwable?.message}" },
)
FlatMap and chaining¶
Chain operations that return AsyncResult:
val profile: AsyncResult<Profile> = userResult.flatMap { user ->
fetchProfile(user.id)
}
// Unwrap nested AsyncResult
val unwrapped: AsyncResult<User> = nestedResult.flatten()
Use andThen for explicit chaining:
val result = fetchUser().andThen { user ->
fetchPermissions(user.id)
}
Filtering and casting¶
Filter values or convert to errors:
val admin: AsyncResult<User> = userResult.filterOrError { it.isAdmin }
val typed: AsyncResult<Admin> = result.castOrError<Admin>()
Handle nullable values:
val nonNull: AsyncResult<User> = nullableResult.orError()
Validation¶
Convert success to error based on predicates:
// Convert to error if predicate is true
val validated = result.toErrorIf(
predicate = { it.age < 18 },
error = { Error(IllegalArgumentException("Must be 18+")) }
)
// Convert to error unless predicate is true
val validated = result.toErrorUnless(
predicate = { it.isValid() },
error = { Error.Empty }
)
Getting values¶
Extract the underlying value in different ways:
val value: User? = result.getOrNull()
val value: User = result.getOrDefault(defaultUser)
val value: User = result.getOrElse { fallbackUser }
val value: User = result.getOrThrow() // throws if not Success
// For collections
val items: List<Item> = listResult.getOrEmpty()
Extract error information:
val error: Error? = result.errorOrNull()
val throwable: Throwable? = result.throwableOrNull()
val metadata: MyError? = result.errorWithMetadataOrNull<MyError>()
val errorId: ErrorId? = result.errorIdOrNull()
Side effects¶
Run code based on the current state:
result
.onNotStarted { showPlaceholder() }
.onLoading { showSpinner() }
.onIncomplete { showPlaceholder() } // runs for both Loading and NotStarted
.onSuccess { user -> render(user) }
.onError { showError(it) }
.onErrorWithMetadata<NetworkError> { throwable, metadata ->
showNetworkError(metadata)
}
Unwrapping (Rust-style)¶
For when you're certain about the state and want to extract values directly:
val user: User = result.unwrap() // throws UnwrapException if not Success
val error: Error = result.unwrapError()
val throwable: Throwable = result.unwrapThrowable()
val metadata: MyError = result.unwrapMetadata<MyError>()
val errorId: ErrorId = result.unwrapErrorId()
With custom error messages:
val user = result.expect { "User should be loaded by now" }
val error = result.expectError { "Expected failure" }
val throwable = result.expectThrowable { "Expected throwable" }
val metadata = result.expectMetadata<MyError> { "Expected metadata" }
val errorId = result.expectErrorId { "Expected error ID" }
Recovery¶
Convert errors back to success values:
// Recover from any error
val recovered = result.recover { error ->
User.guest()
}
// Recover only from specific error types
val recovered = result.recoverIf<User, NotFoundError> { error ->
User.guest() // only recovers from NotFoundError
}
Alternative results¶
Provide fallback results when errors occur:
// Eager evaluation
val result = primaryResult.or(fallbackResult)
// Lazy evaluation
val result = primaryResult.orElse { error ->
if (error.throwable is NetworkException) {
fetchFromCache()
} else {
Success(defaultValue)
}
}
Monad comprehension DSL: result { ... }¶
AsyncResult includes an inline comprehension DSL inspired by Arrow Raise. It lets you write sequential code and short-circuit on the first non-success state.
val userSummary: AsyncResult<String> = result {
val user = fetchUser().bind()
val permissions = fetchPermissions(user.id).bind()
ensure(permissions.canViewProfile) { IllegalStateException("Unauthorized") }
"${user.name} (${permissions.role})"
}
DSL primitives¶
Inside the result function you can use:
bind()- Extracts fromSuccess, short-circuits onError,Loading, orNotStartederror(Throwable)- Short-circuits withErrorloading()- Short-circuits withLoadingensure(condition) { throwable }- Validates a condition or short-circuits withErrorensureNotNull(value) { throwable }- Validates nullability or short-circuits withError
Why inline (not suspend)¶
The builder is intentionally inline and not suspend so it works in both contexts:
- In non-suspend code, you get regular synchronous composition
- In suspend code, the inlined block inherits the caller's suspend context automatically
// Non-suspend usage
val parsed = result { parse(input).bind() }
// Suspend usage
suspend fun load(): AsyncResult<Data> = result {
val token = loadToken().bind()
api.fetch(token).bind()
}
Combining results¶
Zipping¶
Combine multiple results into one:
val combined = zip(
{ userResult },
{ permissionsResult }
) { user, permissions ->
UserWithPermissions(user, permissions)
}
Or using the zipWith extension:
val combined = userResult.zipWith(permissionsResult) { user, permissions ->
UserWithPermissions(user, permissions)
}
Supports up to 4 results.
Spreading¶
Split a result containing a Pair or Triple:
val (userResult, settingsResult) = pairResult.spread()
Combining collections¶
Convert a list of results into a result of a list:
val results: List<AsyncResult<Int>> = listOf(
Success(1),
Success(2),
Success(3)
)
val combined: AsyncResult<List<Int>> = results.combine() // Success(listOf(1, 2, 3))
// Also works as sequence()
val sequenced: AsyncResult<List<Int>> = results.sequence()
Behavior:
- Returns first Error if any result is an error
- Returns Loading if any result is loading
- Returns NotStarted if any result is not started
- Returns Success<List<T>> only if all results are successful
Working with collections¶
Utilities for handling multiple results:
val results: List<AsyncResult<Int>> = listOf(result1, result2, result3)
// Extract specific states
val errors: List<Error> = results.errors()
val successes: List<Int> = results.successes()
val throwables: List<Throwable> = results.throwables()
val incompletes: List<Incomplete> = results.incompletes()
// Extract typed metadata from errors
val metadata: List<NetworkError> = results.metadata<NetworkError>()
// Check states
val isAnyLoading: Boolean = results.anyLoading()
val isAnyIncomplete: Boolean = results.anyIncomplete()
// Split into successes and errors (incomplete items are ignored)
val (values, errors) = results.partition()
Standalone functions:
val hasError = anyError(result1, result2, result3)
val hasLoading = anyLoading(result1, result2, result3)
val hasIncomplete = anyIncomplete(result1, result2, result3)
val errors = errorsFrom(result1, result2, result3)
Flow helpers¶
Converting to AsyncResult¶
Transform a regular Flow<T> into Flow<AsyncResult<T>>:
// Basic conversion with automatic loading state
flowOf(1, 2, 3)
.asAsyncResult()
.collect { result ->
// Emits: Loading, Success(1), Success(2), Success(3)
}
// Without initial loading state
fetchDataFlow()
.asAsyncResult(startWithLoading = false)
.collect { result ->
// Emits: Success(data1), Success(data2), ...
}
// Errors are automatically wrapped
flow {
emit(42)
throw IOException("Network error")
}.asAsyncResult()
.collect { result ->
// Emits: Loading, Success(42), Error(IOException)
}
The asAsyncResult() extension:
- Wraps each emitted value in Success
- Catches exceptions (except CancellationException) and wraps them in Error
- Optionally starts with a Loading emission (default: true)
- Preserves coroutine cancellation by rethrowing CancellationException
Side effects¶
Extensions for Flow<AsyncResult<T>>:
flowOf(result)
.onLoading { showSpinner() }
.onIncomplete { showPlaceholder() }
.onSuccess { render(it) }
.onError { showError(it) }
Extract values from flows:
val value: User = flow.getOrThrow()
val value: User? = flow.getOrNull()
val value: User = flow.getOrElse { fallbackUser }
Filtering¶
Skip loading states entirely:
flow.skipWhileLoading()
.collect { result ->
// Only receives NotStarted, Success, or Error
}
// Alias
flow.filterNotLoading()
Caching¶
Cache the latest success value and emit it during reloads:
flow.cacheLatestSuccess()
.collect { result ->
// During reload: shows cached Success instead of Loading
}
This is useful for "stale-while-revalidate" patterns where you want to show existing data while fetching updates.
Timeout¶
Convert slow operations to errors:
flow.timeoutToError(5.seconds) {
Error(TimeoutException("Request timed out"))
}
If no Success or Error is emitted within the timeout, an Error with the provided throwable is emitted.
Retry¶
Automatically retry on errors:
// Basic retry
flow.retryOnError(
maxRetries = 3,
delay = 1.seconds,
predicate = { error ->
error.throwable is IOException // Only retry network errors
}
)
// Retry based on typed metadata
flow.retryOnErrorWithMetadata<_, NetworkError>(
maxRetries = 3,
delay = 1.seconds
) { networkError ->
networkError.isRetryable // Only retry specific error types
}
The flow will restart from the beginning on each retry attempt.