kylehughes/swift-async-result
`Result`, extended with an in-progress case for asynchronous operations, e.g.
About
AsyncResult adds an .inProgress case to Result for representing loading, success, and failure states.
AsyncResult has no dependencies. Tests cover 100% of lines and functions.
Capabilities
- Combinators:
map,flatMap,mapError,flatMapError,tryMap,merge,zip,collect. - Typed throws support across all throwing APIs.
recoverwith type-level proof (AsyncResult<Success, Never>) that recovery occurred.Failure == Neverspecialization withvalue,setFailureType(to:).- Optional interop:
init(optional:or:),unwrap(or:). - Sync and async overloads for all combinators.
- Swift 6 language mode support with strict concurrency.
Supported Platforms
- iOS 13.0+
- macOS 10.15+
- tvOS 13.0+
- visionOS 1.0+
- watchOS 6.0+
Requirements
- Swift 6.2+
- Xcode 26.0+
Documentation
Installation
Swift Package Manager
dependencies: [
.package(url: "https://github.com/kylehughes/swift-async-result.git", .upToNextMajor(from: "1.0.0")),
]Getting Started
AsyncResult? serves as view model state, where nil means idle:
import AsyncResult
@Observable
final class UserViewModel {
var user: AsyncResult<User, any Error>?
func load() async {
user = .inProgress
user = await AsyncResult { try await api.fetchUser() }
}
}Chain transformations, including throwing ones, on the result:
let displayName = user
.map(\.profile)
.tryMap { try JSONDecoder().decode(Profile.self, from: $0) }
.map(\.displayName)Recover from errors with type-level proof:
let safeResult: AsyncResult<String, Never> = user
.map(\.name)
.recover { _ in "Unknown" }
// nil only when in progress; failure is impossible
let name = safeResult.valueCombine multiple in-flight requests:
let combined = profileResult.zip(with: settingsResult)
// or collect a whole array:
let all = AsyncResult.collect(itemResults)Usage
State Modeling
AsyncResult has two cases: .inProgress and .completed(Result<Success, Failure>). Use AsyncResult? where nil represents idle, before any operation has been initiated.
@State private var result: AsyncResult<[Item], any Error>?
var body: some View {
switch result {
case nil: ContentUnavailableView("Tap to load", ...)
case .inProgress: ProgressView()
case .completed(.success(let items)): ItemListView(items: items)
case .completed(.failure(let error)): ErrorView(error: error)
}
}Throwing Transforms
tryMap transforms the success value with a closure that can fail. It follows the same overload pattern as init(catching:):
// Typed throws: the closure throws the Failure type directly
result.tryMap { (data: Data) throws(APIError) -> User in
try decoder.decode(User.self, from: data)
}
// Untyped throws with error mapping
result.tryMap(
{ try JSONDecoder().decode(User.self, from: $0) },
mapError: { _ in .decodingFailed }
)Combining Results
merge, zip, and collect all use the same priority: failure > inProgress > success.
// Zip two results into a tuple
let combined = profileResult.zip(with: avatarResult)
// Merge with a custom transform
let summary = nameResult.merge(with: ageResult) { "\($0), \($1)" }
// Collect an array of results
let allItems = AsyncResult.collect(itemResults) // AsyncResult<[Item], any Error>Recovery and Never
recover transforms failures into successes and returns AsyncResult<Success, Never>:
let safe = result.recover { _ in fallbackValue }
safe.value // nil only means in-progresssetFailureType(to:) composes infallible results with fallible ones:
let infallible = AsyncResult<Int, Never>(42)
let fallible = AsyncResult<String, MyError>.completed(.success("hello"))
let zipped = infallible.setFailureType(to: MyError.self).zip(with: fallible)Optional Interop
// Create from an optional
let result = AsyncResult(optional: cachedUser, or: CacheError.miss)
// Unwrap an optional success value
let unwrapped: AsyncResult<User, any Error> = result.unwrap(or: APIError.notFound)Important Behavior
AsyncResultdoes not have an idle case. UseAsyncResult?wherenilrepresents the state before any operation
has been initiated.
merge,zip, andcollectuse failure > inProgress > success priority. A failure in any position is never hidden
by an in-progress state elsewhere.
recoverreturnsAsyncResult<Success, Never>, which proves at the type level that error handling has occurred. The
value property on Never-failure results returns nil only for in-progress, never for failure.
Contributions
AsyncResult is not accepting source contributions at this time. Bug reports will be considered.
License
AsyncResult is available under the MIT license.
See LICENSE for details.
Package Metadata
Repository: kylehughes/swift-async-result
Default branch: main
README: README.md