I became more careful with API completion handlers after debugging a catalog screen that could finish with both values set to nil. The request technically completed, but the caller could not tell whether the server had returned an empty response, decoding had failed, or someone had simply forgotten to pass an error.
I replaced that ambiguous callback with Swift’s Result type. The useful part was not reducing a few lines of code. It was giving every request exactly two states and keeping the failure reason until the application reached the layer that knew how to present it.
The Catalog Request That Had Too Many States
The project scenario in the downloadable example is a small product catalog. A transport returns bytes and an HTTP status. The API client validates the status, decodes products, rejects an empty catalog, and hands the result to code that creates a screen state.
A common older callback for this job accepts an optional value and an optional error. That signature permits four combinations: a value, an error, neither, or both. Only two of those combinations make sense. Documentation can describe the intended rule, but it cannot stop an implementation from producing one of the invalid combinations.
func loadProducts(
completion: ([Product]?, Error?) -> Void
)
Result<Success, Failure> removes the invalid states. A value is either .success with the expected data or .failure with an error. The compiler then requires the producer to choose one case, while a switch lets the consumer handle both visibly.
Start with Errors the Caller Can Act On
I avoid returning a plain string as the failure value. Strings are convenient to print, but they are difficult to compare in tests and encourage UI wording to leak into the networking layer. The sample uses an enum that keeps the category and any useful associated value:
enum APIError: Error, Equatable {
case transport(TransportError)
case unacceptableStatus(Int)
case invalidPayload
case emptyCatalog
}
The HTTP status is preserved because it can help logging, testing, or retry decisions. A decoding problem is deliberately converted to invalidPayload at this boundary. The catalog feature does not need to know the exact internal path of a decoder error, although a production client could record that underlying error in diagnostics before mapping it.
Error design should follow decisions the application needs to make. If two cases always receive the same handling and offer no diagnostic value, keeping them separate may only add noise. If one case needs a retry and another requires a sign-in screen, combining them would throw away information too early.
Keep the Transport Small and Replaceable
The example defines a transport protocol with one responsibility. It accepts a path and returns an HTTP-shaped response or a transport error:
protocol Transport {
func send(path: String) -> Result<HTTPResponse, TransportError>
}
The runnable package injects a fixture transport instead of contacting a live server. This makes every example repeatable: the success case always receives the same two products, and the failure cases can intentionally return status 503, malformed JSON, an empty array, or an offline error.
In an application, the concrete transport could wrap URLSession. The catalog client would not need to change because it depends on the small contract rather than the networking implementation. This boundary also makes tests quick and avoids depending on an API account just to verify result handling.
Build the Pipeline One Decision at a Time
The catalog client turns a low-level result into a feature-level result through a short pipeline:
network.send(path: "/products")
.mapError(APIError.transport)
.flatMap(validate)
.flatMap(decode)
.flatMap(requireProducts)
mapError changes the failure type without touching a successful response. The first flatMap checks that the status is in the 200 range and returns the body. The next decodes the JSON. The final step applies a business rule: this screen treats an empty catalog as a distinct state rather than successful content.
I use map when a successful value can be transformed directly and the transformation cannot fail. I use flatMap when the next operation produces another Result. If any stage fails, the remaining success transformations are skipped and the same failure travels to the end.
A chain is helpful while each operation has a clear name. When the pipeline grows conditional retries, authentication refresh, pagination, and cancellation, forcing everything into one expression becomes harder to follow. At that point I split the work into named functions or use structured concurrency with async throws.
Result and async throws Solve Different Boundaries
Result is not a replacement for every throwing function. With current Swift concurrency, an asynchronous request often reads naturally as an async throws function. Callers can use try await, cancellation participates in the task model, and sequential operations look like ordinary control flow.
I still find Result useful when an outcome needs to be stored, passed through a callback API, returned by a synchronous test double, or transformed as a value. It is also the type received by a continuation or completion boundary in many integrations.
The two styles interoperate. Result { try operation() } captures a throwing synchronous operation. Calling get() returns the success value or throws the stored failure. I choose based on the surrounding API instead of wrapping every throwing call merely to say that an operation can fail.
Convert Technical Failures at the Screen Boundary
The view should not decide what HTTP 503 means or expose a decoder message to a customer. The sample converts the API result into a small catalog state:
enum CatalogViewState: Equatable {
case content(names: [String])
case message(String)
}
A successful result becomes product names. An offline failure becomes “Check your connection and try again.” A server failure gets a temporary-unavailability message, malformed data gets a different message, and an empty catalog becomes an intentional empty state.
This mapping is a practical boundary rather than a rule that every view model must follow. A retry button may still need the error category, and analytics may need a non-sensitive reason code. The important part is that presentation choices live close to presentation code while the API layer retains useful technical meaning.
Test the Failure Paths, Not Only Success
Most demonstrations stop after decoding one valid response. The downloadable executable runs four groups of behavior and prints seven checks. It verifies two decoded products, the content state, preservation of status 503, the matching UI message, malformed JSON handling, the empty-catalog rule, and an offline transport failure.
Those cases caught more design mistakes than the happy path while I was writing the example. If a failure is difficult to create in a test, the dependency boundary is probably doing too much. Injecting the transport makes each condition a small value rather than a network setup exercise.
I also check that associated values survive mapping. Returning only a generic “server error” would make the screen work, but it would prevent a caller from distinguishing 401, 404, and 503 later. Tests make that loss visible before it becomes part of the API.
Mistakes I Avoid in Result-Based APIs
- Do not put optional success and error values inside a
Result; that recreates the ambiguity it should remove. - Do not erase every error to a string at the network boundary.
- Do not use
try!for responses controlled by a server or fixture file. - Do not create one enormous error enum shared by unrelated features.
- Do not chain transformations after their names stop explaining the workflow.
- Do not assume every failure deserves the same user message or retry behavior.
- Do not wrap a clear
async throwsAPI inResultwithout a concrete reason.
For this catalog, Result works because the outcome moves through a synchronous, injectable pipeline and is then converted into screen state. In a larger application I might use async throws inside the live transport and create a Result only where the outcome needs to become a stored value.
Download and Run the Example
The complete project is a Swift Package for macOS and requires Swift 5.9 or later. It uses only the standard library and Foundation, so there are no third-party dependencies or API credentials.
git clone https://github.com/2252408699/swift-result-api-pipeline.git
cd swift-result-api-pipeline
swift run
A successful run prints seven PASS lines followed by All Result pipeline checks passed. You can change the fixtures in main.swift to add authentication, rate-limit, or validation cases without contacting a real service.
Complete source code: swift-result-api-pipeline on GitHub
