Combining Network Requests with Combine and Swift

Written by LearnAppMaking on May 27 2021 in App Development, iOS

Combining Network Requests with Combine and Swift

What if a HTTP network request depends on the response of another? In this tutorial, we’ll discuss how you can parallelize and/or serialize networking tasks with Combine.

Here’s what we’ll get into:

  • Combining multiple Combine publishers in Swift
  • Working with Publishers.Zip and flatMap()
  • How subscribers and publishers work in Combine
  • Getting to know Combine’s generics and types
  • Working with JSON and Codable with Combine
  • Making network requests with Combine and URLSession
  • Getting to know various Combine operators, like map()
  • Practical use cases for combining and chaining publishers

Ready? Let’s go.

  1. Why Combine Multiple HTTP Requests?
  2. Working with Combine in Swift
  3. Combining Publishers with Publishers.Zip
  4. Chaining Publishers with flatMap()
  5. Wrapping Up
  6. Further Reading

Why Combine Multiple HTTP Requests?

Why would you need to combine multiple HTTP networking requests? At first glance it sounds odd, until you realize juggling many HTTP networking requests is an incredibly common task in everyday iOS development.

A few examples:

  • You want to retrieve a private resource, like a list of tweets, after a user has logged into your app. You need to wait for the /auth request to complete, before you can make a call to /tweets/{uid}.
  • You’re displaying a combined list of multiple objects in your app: locally and remotely stored favorite movies, or movies and books. You can only display the list when both networking requests are completed.

Based on the above, we can identify 2 distinct scenarios:

  1. Serial: One request depends on the response of another. Both requests need to be executed serially.
  2. Parallel: Two requests need to run in parallel, but you need the response of both at the same time, to continue.

In these 2 scenarios you need access to the response data of both requests. You can’t run them independently; it’s not a matter of queueing, but of getting the data at the right place, in the right order.

Here’s some pseudo-code to combine 2 networking requests into one with Alamofire, a popular HTTP networking library similar to URLSession:

AF.request("https://example.com/auth").response { response in

    // Grab some data from `response`, like a user ID...
    let uid = response.data ···

    // Get favorite movies for this user
    AF.request("https://example.com/movies.json?uid=\(uid)").response { response in
        ···
    }
}

The above code leads to a pyramid of doom. It quickly becomes hard to read and debug, because you’re nesting completion handlers. It’s also complicated to deal with errors that happen anywhere in the serial chain.

What about combining the results of 2 HTTP requests, and waiting for both of them to complete? That’s downright horrible…

var completedA = false
var completedB = false
var movies = [Movie]()
var books = [Book]()

let completionHandler = {
    if completedA && completedB {
        // Do something with `movies` and `books`...
    }
}

AF.request("https://example.com/movies.json").response { response in
    movies += [response.data as ···]
    completedA = true
    completionHandler()
}

AF.request("https://example.com/books.json").response { response in
    books += [response.data as ···]
    completedB = true
    completionHandler()
}

In the above code, we’ve built primitive semaphores around the 2 pending requests. The code that should be executed when both requests have completed is run when either of them completes, but it’ll only continue if both requests have finished. Just as before, it kinda works, but it’ll only get worse if you try to expand or debug the code.

At this point you could be compelled to try something like promises with PromiseKit, with when() and then(), but since a year or two there’s a new kid on the block: Combine. Let’s use that!

Working with Combine in Swift

Combine is a declarative framework for processing events and data asynchronously. It’s like NotificationCenter and PromiseKit on steroids, with the elegance of declarative programming. Combine plays extremely well together with SwiftUI.

Working with Combine, roughly speaking, involves 3 important concepts:

  1. Publishers: A publisher emits values over time. You can, for example, get a publisher for simple integers, for UI controls and elements, or for complete URLSession data tasks.
  2. Subscribers: A subscriber acts on the values that have been emitted by a subscriber. You connect a subscriber to a publisher, and off you go. The most common subscribers are sink() and assign().
  3. Operators: Publishers have operators that can transform the data they emit, and republish that data. They’re the actions and steps between input and output.

You can imagine working with Combine as building a Rube Goldberg-style pizza baking machine. At one point the dough enters the machine (publisher), and veggies, salami and cheese is added, the pizzas are put in the oven (operators), and out comes a pizza ready for eating (subscriber). Neat!

In the next few sections, we’re going to discuss 2 use cases:

  1. Parallel: Combining 2 HTTP networking requests with Publishers.Zip, and taking action when both requests have completed.
  2. Serial: Chaining 2 HTTP networking requests with flatMap(), and using the response from the first request as input for the second.

It’s worth noting that Combine is so successful because it’s widely supported in the iOS SDK, APIs and 3rd-party libraries. That support is growing with each iteration of iOS.

Components like Timer, URLSession, NotificationCenter and even UIControl can expose Combine publishers. Once a component has been integrated with Combine, you can “combine” (pun intended) it with the rest of the Combine universe. That’s super powerful!

In this tutorial, we’re going to talk about “parallel” and “serial” operations. We’re talking about their everyday meaning here, i.e. 2 request executed at the same time (parallel) and one after the other (serial). This is different than concurrency and Grand Central Dispatch, which are specifically designed to queue operations on a CPU or thread. It could well happen that 2 HTTP networking requests are executed after each other on one thread, but the order of operations, as declared by our Combine code, is parallel.

Combining Publishers with Publishers.Zip

We’re going to start by combining 2 HTTP networking requests into one publisher. Both requests are started at the same time, but we want to do something with the result of both. So we’ll need to wait for both of them to finish.

You’ll need 2 web-based resources in this section:

You don’t have to download these JSON files; we’re going to work with just the URLs. You can copy them with a right-click on the above links, then choose Copy Link.

Getting Started

Next up, make sure to start a new project or playground in Xcode. You’ll need to do an import Combine at the top of the file.

Then, add the following code:

protocol Item {
    var title: String { get }
}

struct Book: Item, Codable {
    var title: String
    var author: String
}

struct Movie: Item, Codable {
    var title: String
    var year: Int
}

The Movie and Book structs represent the movies and books we’re going to use in our code. If you look at the JSON files (see above), you’ll notice that the structure of the JSON data and the Book and Movie structs is the same. We’re using Codable to make parsing these JSON objects easier.

The Item protocol helps us define what both a Book and a Movie have in common. It’ll make them easier to process with Combine, later on. We’re going to sort both movies and books in the same array, and to that end, it’s helpful if they have a sortable property in common.

Finally, add the following class to your code:

class API
{
    var cancellables = Set<AnyCancellable>()

}

This API class is going to house the Combine code we’re about to write. We’ve already added a cancellables collection to the API. This Set is going to hold onto cancellables; they’re references to subscribers that can be cancelled. They’ll also prevent the deallocation of pending Combine chains.

Fetching Books

We’re going to fetch books and movies with Combine – that much is clear. The goal is to get both collections with books and movies at the same time, so we can “merge” and process them together.

The next step is coding a sensible API around this. We can throw all of our Combine code on one big pile, but that would be hard to read and debug. Instead, we’ll abstract the fetching of movies and books away in a function. These functions will produce a publisher.

First, add the following function to the API class:

func fetchBooks() -> AnyPublisher<[Book], Never>
{

}

You can already obtain so much information about Combine, and our code, by only looking at the signature of the above fetchBooks() function. What’s the return type?

This function will return a value of type AnyPublisher, which is a generic. We’re defining 2 concrete types for its generic placeholders: [Book] for Output and Never for Failure. In other words, this publisher will output an array of Book objects, and doesn’t produce errors (if any).

In Combine, publishers and subscribers have associated types for Input, Output and Failure. A subscriber that’s connected to a publisher must have the same Input and Output, as well as have the same Failure types. This doesn’t mean that individual (re)publishers cannot have different types though!

Next up, add the following code inside the fetchBooks() function:

let url = URL(string: "··· books.json")!

return URLSession.shared.dataTaskPublisher(for: url)
    .map { $0.data }
    .decode(type: [Book].self, decoder: JSONDecoder())
    .replaceError(with: [Book]())
    .eraseToAnyPublisher()

Make sure to copy the URL for books.json, from the top of this section, into the URL initializer in the above code!

You’re looking at a Combine chain of publishers and operators. What’s going on in the code? Let’s start at the top.

Data Task Publisher

The first publisher we’re using is URLSession.shared.dataTaskPublisher(for: url). This is essentially a data task from URLSession, which produces a Combine publisher instead of a plain data task object. It takes a URL as input, and returns a publisher. Once this publisher is connected to a subscriber, it’ll make a HTTP networking request and passes the response to the next operator in the chain.

Working with map()

That next operator is map(_:). You may already know the standard higher-order map(_:) function from Swift.

.map { $0.data }

The standard Swift map(_:) function, typically called on a collection (i.e., an array), iterates over the collection and applies a closure to each of the collection’s items. This closure transforms the item and returns it. The transformed items result in a new collection, i.e. “map all values from this to that”.

Compared to Swift’s standard map(_:) function, the map(_:) function used in Combine is somewhat counter-intuitive. It’s called on a publisher and it returns a publisher, effectively transforming and republishing the values that are emitted.

In the above code, we’re transforming the data emitted by the DataTaskPublisher – a tuple of Data and URLResponse – to a Publisher.Map publisher that emits Data values.

It looks like we’re picking off the .data property, and we are, but it’s important to note that we’re returning a publisher that emits that data, and not the data itself. We’ll discuss this some more when we get to flatMap(_:), later on.

Working with decode()

The next operator looks surprisingly familiar if you’ve worked with Codable before. This one:

.decode(type: [Book].self, decoder: JSONDecoder())

Just like the decode(_:from:) function on Codable’s JSONDecoder, the decode(type:decoder:) operator will transform JSON to native Swift objects. You provide the type of the objects you want to decode, and a decoder, and Combine takes care of the rest.

In the above code, we’re indicating that the type of objects we’re expecting is [Book], or array of Book objects. The decoder that’s used is JSONDecoder, because the response data of the URL request was JSON.

It’s worth discussing what Swift types exactly flow through these operators. We started with a data task publisher, which was mapped to a publisher that emits Data objects.

The “input” type for decode() is Data, its “output” type is [Book]. Differently said, its upstream publisher emits Data values and decode(···) produces a publisher that emits [Book] values. But how can we tell?

Check out the function signature of decode(type:decoder:):

func decode<Item, Coder>(type: Item.Type, decoder: Coder) -> Publishers.Decode<Self, Item, Coder> where Item : Decodable, Coder : TopLevelDecoder, Self.Output == Coder.Input

Here’s that same type as it appears in our code, a bit more concretized:

func decode<Item, Coder>(type: Item.Type, decoder: Coder) -> Publishers.Decode<Publishers.Map<URLSession.DataTaskPublisher, JSONDecoder.Input>, Item, Coder> where Item : Decodable, Coder : TopLevelDecoder, Data == Coder.Input

This generic decode() function has 2 placeholders, Item and Coder. It returns a value of type Publishers.Decode. We already know that Item has concrete type [Book], because that’s what we put into the function. You can see that Item must conform to the Decodable protocol.

We also know that Coder.Input is Data, because Coder has concrete type JSONDecoder whose associated type Input is Data (via TopLevelDecoder). It’s only logical that the input for the decoder is Data.

What’s confusing here is that the output of the upstream publisher is called Self.Output. Note that in the above 2 signatures, Self is Publishers.Map, whose Output is Data and not “itself” as in Publishers.Decode.

Back to what the code does: transform Data into [Book], via Codable, and pass those objects downstream.

Quick Tip: You can find out more about the types of publishers, and generics, placeholders and concrete types, by clicking on a function (or type) in Xcode while holding the Option key. A gizmo pops out, giving you more information and documentation about the types involved. Neat!

Working with replaceError()

OK, let’s move on to the next operator. This one:

.replaceError(with: [Book]())

The replaceError(with:) operator will replace errors from upstream with something else downstream. Like its name implies, we’re “hiding” errors by replacing them with another value. In this case, when errors occur, they’ll get replaced by an empty array of Book objects.

Take another look at the publisher we’re returning from the fetchBooks() function: AnyPublisher<[Book], Never>. That second type Never refers to the type of error AnyPublisher can emit, which, naturally, is an error that never happens.

It’s a matter of semantics, though. The replaceError(with:) operator merely replaces errors with another value. In case of errors upstream, the replaceError(with:) operator emits its error replacement value downstream. The type of that value needs to be the same as the output of the upstream publisher.

We won’t spend much time on error handling with Combine in this tutorial. In case you’re interested, these tutorials come recommended:

Working with eraseToAnyPublisher()

The last operation we’re adding to the chain is this one:

.eraseToAnyPublisher()

It looks simple enough, but what happens with eraseToAnyPublisher() is actually quite complex. In short, this operator will erase the type of the upstream publisher. You can think of it as Swift’s Any type, but for Combine.

We’ve already seen that Combine publishers have complex types. Each time you add an operator, the return type gets more complex. On top of that, Combine makes heavy use of (nested) generics and placeholders.

This is the type of our publisher so far:

Publishers.ReplaceError<Publishers.Decode<Publishers.Map<URLSession.DataTaskPublisher, JSONDecoder.Input>, [Book], JSONDecoder>>

This is that same publisher type after eraseToAnyPublisher():

AnyPublisher<[Book], Never>

See how the first type is a data task publisher wrapped in Map, Decode, and ReplaceError? The second type is just AnyPublisher, with an array of Book objects that never emits errors. What’s going on?

You use type erasure to hide the concrete return type of a function from the code that calls that function. Just as with some and Any, we’re telling the Xcode compiler that a value’s type is something else. A simpler, lesser, more stable, and more robust type.

Why, though? Thanks to type erasure, the APIs, functions and abstractions that you create remain stable when their underlying implementation changes. Imagine that this happens:

  1. You publish your fetchBooks() function on GitHub, as a library
  2. Another developer adds your code as a dependency, and uses it
  3. You publish a v2.0 of fetchBooks() that changes the publisher chain
  4. As a result, the return type of fetchBooks() changes
  5. That other developer has a problem now, because the function returns another type than they expect – they’ll have to update their code, too

The solution is type erasure with .eraseToAnyPublisher(). The return type of fetchBooks() remains stable, and you keep the ability to change its type and implementation later on.

This may sound like you’re throwing away type information, but same as with type casting, the actual concrete return type doesn’t change. In fact, you’re dealing with generics, so concrete types are an implementation detail anyway.

Type erasure also helps you, yourself! When you’ve specified a return type, and the chain changes, you don’t have to update your own code as much as you would have, had you not used type erasure. Sure, you could have updated your code, and erasing all types isn’t helpful either, but erasing some type information here and there makes you more productive.

Fetching Movies

Awesome! Here’s the code we got so far:

func fetchBooks() -> AnyPublisher<[Book], Never>
{
    let url = URL(string: "··· books.json")!

    return URLSession.shared.dataTaskPublisher(for: url)
        .map { $0.data }
        .decode(type: [Book].self, decoder: JSONDecoder())
        .replaceError(with: [Book]())
        .eraseToAnyPublisher()
}

Next up, we’re going to code a similar fetchMovies() function. They’re the publishers we’re running in parallel. Add the following function to the API class:

func fetchMovies() -> AnyPublisher<[Movie], Never>
{
    let url = URL(string: "··· movies.json")!

    return URLSession.shared.dataTaskPublisher(for: url)
        .map { $0.data }
        .decode(type: [Movie].self, decoder: JSONDecoder())
        .replaceError(with: [Movie]())
        .eraseToAnyPublisher()
}

Looks familiar, right? Make sure you replace the URL string on the first line with the movies.json URL at the beginning of this section.

Just as before, we’re creating a data task publisher, map that to the response’s data property and decode that to an array of Movie objects. Any errors that might happen are replaced with an empty array, and just as before, the type of the publisher is erased to AnyPublisher<[Movie], Never>.

Combining Movies and Books with Zip

Alright, the next step is combining the fetchBooks() and fetchMovies() publishers into one. It’s worth noting here that both publishers returned from those functions are inert; they don’t do anything yet. Until we attach a subscriber, the publishers won’t emit data.

First off, add the following function to the API class:

func fetchItems()
{

}

Next up, add the following code inside the fetchItems() function:

let combinedPublisher = Publishers.Zip(fetchBooks(), fetchMovies())

The Publishers.Zip code (a struct, actually) creates a new publisher by combining other publishers. The new publisher emits the latest values from both individual publishers in tandem.

The data tasks (and friends) from fetchBooks() and fetchMovies() are linked together, and Zip emits a value – a tuple of ([Book], [Movie]) – when both publishers have emitted a value.

You’ll find that most publishers (and subjects) in Combine have their own distinct cadence. You can imagine when combining 2 publishers, the resulting publisher can emit values when either individual publisher emits a value, or when both do. A publisher can also choose to repeat old values, for example, when either of the initial publishers hasn’t emitted anything yet.

In case of Zip, here’s how that works:

  • Zip, and Zip3, Zip4, etc., emits a value when both individual publishers have emitted a value
  • Both values are combined in a tuple; they’re linked; you get A1 and A2, B1 and B2, C1 and C2, and so on
  • You’ll always get the oldest unconsumed value from the individual publishers, which typically corresponds to the latest value of both

In terms of Promises/A+ and PromiseKit, Zip corresponds to when(fulfilled:) or all().

Sorting Books and Movies

The next step we’re going to take is transforming the values emitted by Zip. Add the following code below Publishers.Zip:

.map { items -> [Item] in
    return (items.0 + items.1).sorted { $0.title < $1.title }
}

The entire function now looks like this:

func fetchItems2()
{
    let combinedPublisher = Publishers.Zip(fetchBooks(), fetchMovies())
        .map { items -> [Item] in
            return (items.0 + items.1).sorted { $0.title < $1.title }
        }
}

What’s going on with that .map() function? Just as before, we’re transforming the values emitted by a publisher into a publisher that emits those transformed values. Keep in mind that map(_:) does transform values, but it’ll output a publisher.

The type of the closure for map(_:) is ([Book], [Movie]) -> [Item]. In other words, we’re getting a 2-tuple of arrays of books and movies, and we’re going to return an array of objects conforming to Item from the closure.

This Item type is a protocol, that both Book and Movie objects conform to. It’s a nicety we’ve added to our API, because it makes dealing with the disparate Book and Movie types easier. The Item protocol defines what they both have in common: a title attribute.

Inside the closure, this happens:

return (items.0 + items.1).sorted { $0.title < $1.title }

We’re combining the first and second member of the items tuple. They are the arrays of Book and Movie objects, merged together with the + operator. Swift lets us merge these two arrays even though they have different types, because the resulting array type can be expressed as [Item].

In the second part of that line we’re calling sorted(by:), which sorts elements of the combined array alphabetically based on their title property.

Subscribing with sink()

Pfew! We’ve done a lot so far: fetching books and movies, and zipping and sorting them. The last step is subscribing to the publisher, and doing something with the values that are emitted.

Add the following code to the fetchItems() function:

combinedPublisher.sink { items in

    for item in items {

        if let book = item as? Book {
            print("\(book.title) - \(book.author)")
        } else if let movie = item as? Movie {
            print("\(movie.title) - \(movie.year)")
        }
    }
}
.store(in: &cancellables)

At the top-level, we’re calling sink() on combinedPublisher, and store() on the result. Here’s what they do:

  • sink() is a subscriber, so it’ll receive values emitted by a publisher, and executes a closure for each value emitted. The closure for this sink does not return a value; it’s the end of the chain.
  • store(in:) will assign the AnyCancellable returned by sink() in the cancellables set. This ensures that the cancellable – a task, really – does not get deallocated in-flight.

It’s worth noting here that we’re using the sink(receiveValue:) variant here, which can only be used if the publisher chain cannot fail. Its error type must be Never.

If your chain can fail, you can use sink(receiveCompletion:receiveValue:). This subscriber also passes errors, which means you can respond to both the success and failure cases of the upstream publisher.

What’s happening inside sink(), by the way? The closure gets passed an array of Item objects, that really are both Book and Movie objects. We’re looping over the array, and for each object we’re checking if it’s a book or a movie with type casting. Depending on the type, we’re printing out the item’s title, and year or author.

If you want to see your code in action, make sure to execute fetchItems(). Like this:

var api = API()
api.fetchItems()

That should output both movies and books. Note how they’re sorted by title!

Altered Carbon - Richard K. Morgan
Animal Farm - George Orwell
Blade Runner - 1982
Blade Runner 2049 - 2017
Blindness - José Saramago
Brave New World - Aldous Huxley
District 9 - 2009
Elysium - 2013
···

And… that’s it! You’ve seen how to combine multiple publishers into one with Publishers.Zip. Neat!

Author’s Note: You don’t have to store the publisher returned by Publishers.Zip into a temporary constant like combinedPublisher. You could chain .sink() right below .map(). I prefer to keep publishers and subscribers separate, to clearly see when I’m switching from the publishing to the subscribing/handling paradigm.

Chaining Publishers with flatMap()

So far we’ve looked at combining multiple HTTP network requests into one publisher, but what if you need to use the results from one request as input for the next? That’s where flatMap() comes in.

Here’s a common scenario:

  • A user wants to request a private resource, such as a list of tweets or followers, so they’ll have to log into their account with a password
  • You need the User ID returned by the authentication request, to get that list of tweets for the user

In this section, we’re going to first make a HTTP network request to get user information, and use that information to make a subsequent request for favorite movies. Let’s get to it!

Getting Started

We’re going to reuse some of the code from the previous section, namely the fetchMovies() function, and the Movie type. Also, add the following User struct to your project:

struct User: Codable {
    var uid: Int
    var username: String
}

You’ll need this URL too:

Just as before, this JSON file contains data that we’re converting to native Swift types. You’ll notice that the User struct and auth.json file contain similar information.

Authenticating the User

The first step in the two-step chain is authenticating the user. It’s a function that receives a username and a password, and “uses” that to receive an authenticated User object. For the sake of this example project, the returned User object is always the same.

Add the following code to the API class:

func authenticate(username: String, password: String) -> AnyPublisher<User, Never>
{
    print("Authenticating user '\(username)'...")

    let url = URL(string: "··· auth.json")!

    return URLSession.shared.dataTaskPublisher(for: url)
        .map { $0.data }
        .decode(type: User.self, decoder: JSONDecoder())
        .replaceError(with: User(uid: 0, username: ""))
        .eraseToAnyPublisher()
}

Make sure to change the URL string to the auth.json URL, found at the beginning of this section.

What’s going on in the code? It looks similar to what we built before. Just like fetchMovies() and fetchBooks(), the authenticate() function returns a publisher of type AnyPublisher<User, Never>. The returned publisher emits a value of type User, and doesn’t produce errors.

Here’s what happens in the publisher chain:

  1. Create a publisher for a data task, for the JSON URL
  2. Map its response to the data property, creating a publisher for that
  3. Decode the Data object as an object of type User, the Codable struct
  4. Replace any errors with an “empty” User object, with ID = 0
  5. Type erase the resulting publisher, so we’re returning a stable type

In a real-world app, this code would look similar but different. For example, with authentication, you must handle errors. Right now, any error results in a User object with a user ID that’s 0 – which is hardly helpful.

You could, for example, catch and handle any errors in the chain with catch(). You could also create a uniform Error type, that’ll inform the user about various errors that might have popped up: no internet connection, wrong password, and so on.

Fetching Favorite Movies

Next up, add the following function to the API class:

func fetchMovies(for user: User) -> AnyPublisher<[Movie], Never>
{
    print("Fetching movies for user ID = '\(user.uid)'...")

    return fetchMovies()
}

This is an abstraction that’ll simply print out some text, and then returns the value from the fetchMovies() function.

The important concept here is that a User object must be provided to fetchMovies, simulating fetching the favorite movies of a user. It’s not about fetching the actual favorite movies, but rather, about using the User object to do so.

Chaining Auth and Favorite Movies with flatMap

Awesome! We’ve got the fetchMovies(for:) and authenticate(username:password:) functions. They’re publishers, actually, which are returned from functions.

Unlike before, we’re not going to combine and zip their results, but instead use the output of the authenticate() publisher as input for the fetchMovies(for:) publisher.

First, add the following function to the API class:

func fetchFavorites()
{

}

Then, add this line of code inside the function:

authenticate(username: "reinder42", password: "abcd1234")

So far so good – we’ve started with our first publisher. The call to authenticate(···) returns a publisher that emits a value of type User, and (unrealistically) never errors.

Next, add the following code below the authenticate() call:

.flatMap { user in
    return self.fetchMovies(for: user)
}

What’s going on here!?

First things first. Just like the map(_:) operator, the flatMap(_:) operator transforms a publisher by calling a closure for each of the values it emits. The flatMap(_:) operator produces a publisher that emits those transformed values, just like map(_:).

There’s a problem, though! Inside the closure for flatMap(_:) we’re not returning a value, but we’re returning a publisher. We’ve effectively created a publisher that emits publishers… Oops!

Don’t worry! Just as Swift’s standard flatMap(_:) function, Combine’s flatMap(_:) operator also flattens its resulting output. Differently said, the the flatMap(_:) operator will emit the values emitted by the publisher that’s returned by fetchMovies(for:). It flattens the nested values.

Also note that the single argument of the closure for flatMap() is user of type User. This is the value that’s emitted by the publisher returned by authenticate(···), the one with the auth.json data task!

That’s what we’re here for, right? We wanted to first authenticate the user, get a User object, and then use that object to get the favorite movies of the user. Neat!

Subscribing with sink()

Finally, add the following code below the previous ones:

.sink { movies in
    print(movies.map { $0.title }.joined(separator: ", "))
}
.store(in: &cancellables)

The entire fetchFavorites() function now looks like this:

func fetchFavorites()
{
    authenticate(username: "reinder42", password: "abcd1234")
        .flatMap { user in
            return self.fetchMovies(for: user)
        }
        .sink { movies in
            print(movies.map { $0.title }.joined(separator: ", "))
        }
        .store(in: &cancellables)
}

Just as before, we’re subscribing to the values emitted by the publisher returned by flatMap() with sink(receiveValue:). The publisher doesn’t produce errors, so we can safely get the array of Movie objects. In the above code, the movies’ titles are printed out.

If you want to see your code in action, make sure to call fetchFavorites(). Like this:

var api = API()
api.fetchFavorites()

That produces the following output:

Authenticating user 'reinder42'...
Fetching movies for user ID = '42'...
Blade Runner, Tron, RoboCop 2, The Matrix, Minority Report, ···

Note that the user.id is printed out in fetchMovies(for:), which is evidence that the previously unknown user ID from auth.json is passed all the way through flatMap { user in ··· } and fetchMovies(for:). Awesome!

Author’s Note: Why is sink() called “sink”? First off, it’s got nothing to do with “everything but the kitchen sink”. In software development, a sink is a function that’s designed to receive incoming events from another object or function. You can think of them as callbacks, listeners or consumers. Whatever floats your boat – I like to think of them as the sarlacc sand monster / sinkhole creature from 1983’s Star Wars Return of the Jedi. No getting out!

Wrapping Up

And that, dear Swifterati, how to combine and chain publishers in Combine. We’ve looked at 2 scenarios:

  1. Parallel: Combining the values emitted by 2 publishers with Publishers.Zip, and acting on them when we’ve got results from both publishers.
  2. Serial: Chaining one publisher to the next with flatMap(), so we can use the value emitted by the first publisher as input for the second, and acting on them in between, and when the entire chain completes.

In the meantime, we’ve spent some time with publishers, subscribers, operators, generics, types, type erasure, JSON, Codable, URLSession, and much more. Awesome!

Further Reading

Combine seems so easy and simple, and at the same time it’s so powerful. It’s worth it to investigate its components further, including:

  • Error handling with catch(), and error types
  • Publishers.Zip vs. Publishers.Merge vs. Publishers.CombineLatest
  • Working with the assign(to:) subscriber
  • PassthroughSubject and CurrentValueSubject
  • map(), flatMap() and compactMap()

Many developers from the iOS community do exceptional work around Combine, including:

Want to learn more? Check out these resources:

LearnAppMaking

LearnAppMaking

At LearnAppMaking.com, app developers learn how to build and launch awesome iOS apps.