Xys
Xys

Reputation: 10809

Handling errors in Combine (Swift, iOS)

I don't know how to deal with errors in a Combine flow. I would like to be able to catch errors from a Combine function.

Could anyone help in explaining what I'm doing wrong here and how I should handle catching an error with Combine?

Note: The function below is just an example to illustrate a case where an error could be caught instead of crashing the app.

func dataFromURL<T: Decodable>(_ url: String, _ decoder: JSONDecoder = JSONDecoder()) -> AnyPublisher<T, Error> {
    // 1) Example: If the URL is not well-formatted, I would like to /create/raise/return an error (in a Combine way)

    // 2) Instead of the forced unwrapping here, I would also prefer to raise a catchable error if the creation of the request fails
    let request = URLRequest(url: URL(string:url)!)

    // 3) Any kind of example dealing with potential errors, etc

    return urlSession
        .dataTaskPublisher(for: request)
        .tryMap { result -> T in
            return try decoder.decode(T.self, from: result.data)
        }
        .receive(on: DispatchQueue.main)
        .eraseToAnyPublisher()
} 

// function in another file:
func result() {
     // I would like to be able to catch or handle errors in this function

     dataFromURL("test").print()   

    // Example : if error 1), else if error 2) etc
}

As explained in the comments, I would like to be able to catch any error outside the dataFromURL function, but in a "Combine way".

I used a URL data fetching as an example, but it could be with anything else.

What is the recommended way to raise and catch errors with the Combine flow? Is it to return a Publisher with a specific error for example? If so, how can I do it?


EDIT

Without Combine, I would just have thrown an error, added the throws keyword to the function, and would have caught the error in the result function.

But I would have expected Combine to have a simpler or more elegant way to achieve this. For example, maybe something that can be thrown at any time:

guard <url is valid> else {
    return PublisherError(URLError.urlNotValid)
}

And could have been caught like this:

dataFromURL
.print()
.onError { error in
   // handle error here
}
.sink { result in
    // no error
}

Upvotes: 3

Views: 7180

Answers (1)

rob mayoff
rob mayoff

Reputation: 385580

If the URL(string:) initializer fails (returning nil), you have to decide what error you want to turn that into. Let's say you want to turn it into a URLError. So, if URL(string:) returns nil, create the URLError and use a Fail publisher to publish it:

func jsonContents<T: Decodable>(
    ofUrl urlString: String,
    as type: T.Type,
    decodedBy decoder: JSONDecoder = JSONDecoder()
) -> AnyPublisher<T, Error> {
    guard let url = URL(string: urlString) else {
        let error = URLError(.badURL, userInfo: [NSURLErrorKey: urlString])
        return Fail(error: error).eraseToAnyPublisher()
    }

    return URLSession.shared
        .dataTaskPublisher(for: url)
        .tryMap { result -> T in
            return try decoder.decode(T.self, from: result.data)
    }
    .receive(on: DispatchQueue.main)
    .eraseToAnyPublisher()
}

But if you really want to shovel more Combine into it, you can use a Result.Publisher instead of Fail:

func jsonContents<T: Decodable>(
    ofUrl urlString: String,
    as type: T.Type,
    decodedBy decoder: JSONDecoder = JSONDecoder()
) -> AnyPublisher<T, Error> {
    return (
        URL(string: urlString)
            .map { Result.success($0) } // This is Optional.map
            ?? Result.failure(URLError(.badURL, userInfo: [NSURLErrorKey: urlString]))
        )
        .publisher
        .flatMap({
            URLSession.shared
                .dataTaskPublisher(for: $0)
                .tryMap { result -> T in
                    return try decoder.decode(T.self, from: result.data)
            }
        })
        .receive(on: DispatchQueue.main)
        .eraseToAnyPublisher()
}

But things get hard to read. We could factor out the use of Result into a new operator, unwrapOrFail(with:):

extension Publisher {
    func unwrapOrFail<Wrapped>(with error: Failure) -> Publishers.FlatMap<Result<Wrapped, Self.Failure>.Publisher, Self> where Output == Wrapped? {
        return self
            .flatMap ({
                $0
                    .map { Result.success($0).publisher }
                    ?? Result.failure(error).publisher
            })
    }
}

And then use it like this:

func jsonContents<T: Decodable>(
    ofUrl urlString: String,
    as type: T.Type,
    decodedBy decoder: JSONDecoder = JSONDecoder()
) -> AnyPublisher<T, Error> {
    return Result.success(urlString).publisher
        .map { URL(string: $0) }
        .unwrapOrFail(with: URLError(.badURL, userInfo: [NSURLErrorKey: urlString]))
        .flatMap({
            URLSession.shared
                .dataTaskPublisher(for: $0)
                .tryMap { result -> T in
                    return try decoder.decode(T.self, from: result.data)
            }
        })
        .receive(on: DispatchQueue.main)
        .eraseToAnyPublisher()
}

Note, though, that if you make any mistake along the way, you'll probably get an inscrutable error message and have to pick apart your long pipeline to get Swift to tell you what's really wrong.

Upvotes: 16

Related Questions