Compact and flat maps

8 minute read

Although Swift 4.1 brought many improvements to Swift, it was intended to be source compatible with Swift 4.0, so most improvements where purely additive. But one notable exception was the rename from flatMap to compactMap. I always thought Swift’s old flatMap on arrays and other sequences was somewhat of an annoyance, as it never really was a proper flat map.

But this obviously wasn’t clear to everyone because the other day a colleague of mine asked if I had meant compactMap when I tried to map a String? to a URL? using flatMap.

— “No”, I said, “flat map is correct here!”

So why is flatMap sometimes right, but not in other cases?

A little back story on map

As many probably know by now, map will turn an array of Foos into an array of Bars using a transform function. That is, it maps from [Foo] -> [Bar], by applying the same function that maps from Foo -> Bar on each and every element of the array:

let numbers = [1, 2, 3, 4]
let strings = numbers.map { "\($0)" } // ["1", "2", "3", "4"]

You probably also know that this works not only on arrays, but also on any collection or sequence. However, a map isn’t only for collections of things. It will actually turn every container over a type into the same container over a different contained type. That is, it will turn any Wrapped<Foo> into a Wrapped<Bar.

And since String? is really just Optional<String>, there’s nothing stopping us from mapping it into a Optional<URL> using a transform. Optional.map will turn an non-nil value into a new non-nil value, and just map nil to itself. So to turn an optional string into an optional URL, we can write:

let urlString: String? = "http://example.com"
let url = urlString.map { URL(string: $0) }
// url should now be of type URL? but is it?

But what happens if we try an invalid url string?

let urlString: String? = "not a valid url"
let url = urlString.map { URL(string: $0) }
// url should now be nil. But What type does it have? URL?

It turns out that since URL(string:) is failable and may return nil, the map above actually maps from String? to URL??, aka. Optional<Optional<URL>>. That’s probably not what you want.

The proper flat map

Where the map transforms a Box<Foo> -> Box<Bar>, using a function Foo -> Bar, the flat map will also transform a boxed foo to a boxed bar, but will do so even if the transform function itself is of type Foo -> Box<Bar>. That is, it will first transform from Box<Foo> into a Box<Box<Bar>> and then flatten the result into a Box<Bar> by “removing a layer”. This works with any kind of “box” that can hold some wrapped inner value(s).

By using it on an Optional<String> with a function that returns an Optional<URL>, it will flatten the otherwise doubly wrapped <Optional<Optional<URL>> to a simple URL?:

let urlString: String? = "not a valid url"
let mappedUrl = urlString.map { URL(string: $0) }
// This is now of type URL?? since both urlString can be nil, and the conversion can return nil
let flatMappedUrl = urlString.flatMap { URL(string: $0) }
// Ah! That's better! This is now of type URL?

This also works on array. If you have an array of some type, and function that takes elements of that type and return another array of some (possibly other) type, the flatMap would flatten the double array [[Foo]] to a simple array [Foo]:

let words: String = "hello world".split(separator: " ") // ["hello", "world"]
let characters = words.map { $0.characters }
// [['h', 'e', 'l', 'l', 'o'], ['w', 'o', 'r', 'l', 'd']]
let flatCharacters = words.flatMap { $0.characters }
// ['h', 'e', 'l', 'l', 'o', 'w', 'o', 'r', 'l', 'd']

Flat maps in Swift

The Swift standard library had (prior to Swift 4.1) three distinct overloads of flatMap:

Sequence.flatMap<S>(_: (Element) -> S) -> [S.Element] where S: Sequence
Optional.flatMap<U>(_: (Wrapped) -> U?) -> U?
Sequence.flatMap<U>(_: (Element) -> U?) -> [U]

It’s the third one that’s the controversial one:

  • The first takes a sequence and a function that also returns a sequence and flattens the resulting sequence of sequences into a simple flat sequence.
  • The second takes an optional value and function that also returns an optional value, and flattens the resulting double optional value into a simle flat optional value.

However the third, takes an array and a function that returns an optional and compacts the resulting array of optionals, into an array on non-optionals by removing the nil-values.

By renaming this one specific overload to use a different name instead, we remove this ambiguity and also allows for better discoverability of the proper flat map on sequences.

Why it matters

The proper flat map is very useful in programming since it typically works with promises, result enums and all kinds of generic data structures. Imagine an asynchronous function that returns a Promise<User> and once the promise is fulfilled, you want to take that user’s profile image URL, and fetch it asynchronously. The image fetch code may return a Promise<UIImage>. By simply mapping one to the other you’d end up with a promise of a promise of an image. And if you also wanted to asynchronously scale the image, you could end up with a promise of a promise of a promise of a (scaled) image.

In many cases you don’t really care who broke the promise, you just want the result, or to know if either of these promises was broken somewhere along the line. A flat map could then turn this into a single promise, which would fail or succeed.

func getUser(userId: Int) -> Promise<User> {
  return networkService
    .get("\(baseUrl)/users/\(userId)")
    .map { data in JSONDecode().decode(User.self, from: data) }
}

func fetchImage(url: URL) -> Promise<UIImage> {
  return networkService
    .get(url)
    .map { data in UIImage(data: data) }
}

func scaleImage(_ image: UIImage, size: CGSize) -> Promise<UIImage> {
  return imageService.scale(image: image, size: size)
}

func getProfileImageForUser(userId: Int) -> Promise<UIImage> {
  return getUser(userId: userId)
    .map { user in fetchImage(url: user.profileImageUrl) }
    .map { image in scaleImage(image, size: .init(width: 100, height: 100)) }
  // Cannot convert return expression of type 'Promise<Promise<Promise<UIImage>>>'
  // to return type 'Promise<UIImage>' 😠
}

By using flatMap instead, we avoid the issue

func getProfileImageForUser(userId: Int) -> Promise<UIImage> {
  return getUser(userId: userId)
    .flatMap { user in fetchImage(url: user.profileImageUrl) }
    .flatMap { image in scaleImage(image, size: .init(width: 100, height: 100)) }
  // 😄
}

Tags:

Updated:

Leave a Comment