Map, FlatMap and CompactMap
It might not seem like it at first, but a lot of the code that we write as app developers is about transforming data and values into different shapes and forms. For example, we might transform a URL into a piece of data by performing a network request, and then transform that data into an array of models, which we then finally transform into a list-based UI.
One way of performing such value transformations is by mapping a collection of values into an array of new values, using a transform. The Swift standard library offers three main APIs for that kind of mapping — map
, flatMap
and compactMap
. Let’s take a look at how they work.
Let’s say that we’ve written a function that extracts any #hashtags
that appear within a string, by splitting that string up into words, and then filtering those words to only include strings that start with the #
character — like this:
func hashtags(in string: String) -> [String] {
let words = string.components(
separatedBy: .whitespacesAndNewlines
)
// Filter lets us remove any elements that don't fit a
// given requirement, in this case those that don't start
// with a leading hash character:
return words.filter { $0.starts(with: "#") }
}
Calling the above function would then look something like this:
let tags = hashtags(in: "#Swift by Sundell #Basics")
print(tags) // ["#Swift", "#Basics"]
Now let’s say that we want to normalize the hashtags that our function produces, so that they’re always lowercased. One way to do so would be to create a new array, then use a for
loop to iterate over all tags, and then insert a lowercased version of each tag into that new array:
var lowercasedTags = [String]()
for tag in tags {
lowercasedTags.append(tag.lowercased())
}
However, what we’ve essentially done above is to write a manual implementation of map
— which when called on a sequence of values (such as Array
), lets us apply a closure-based transform to each element to produce a new array — so let’s use that instead:
func hashtags(in string: String) -> [String] {
let words = string.components(
separatedBy: .whitespacesAndNewlines
)
let tags = words.filter { $0.starts(with: "#") }
// Using 'map' we can convert a sequence of values into
// a new array of values, using a closure as a transform:
return tags.map { $0.lowercased() }
}
Much better! Now we no longer have to maintain our own for
loop, nor do we have to create a temporary array, we just make one simple call to map
— and we’re done!
We can also use map
with any code that we’ve written ourselves — such as our new hashtags
function. Here’s how we could use map
to transform an array of strings into a series of hashtags — all in one go:
let strings = [
"I'm excited about #SwiftUI",
"#Combine looks cool too",
"This year's #WWDC was amazing"
]
let tags = strings.map { hashtags(in: $0) }
print(tags) // [["#swiftui"], ["#combine"], ["#wwdc"]]
What’s even better is that — since our hashtags
function accepts a String
as input, just like the closure we’re passing to map
above — we can simply pass our hashtags
function directly as an argument to map
, and it will actually use that function as its transform closure:
let tags = strings.map(hashtags)
Pretty cool! However, the output we’re getting above is an array containing arrays of strings (or [[String]]
), which is probably not what we want. We most likely want a “flat” array that just contains hashtags, without the nested arrays (or [String]
).
This is where flatMap
comes in. It works just like map, but also flattens the resulting “array of arrays” into just a single array — like this:
let tags = strings.flatMap(hashtags)
print(tags) // ["#swiftui", "#combine", "#wwdc"]
So map
transforms an array of values into an array of other values, and flatMap
does the same thing, but also flattens a result of nested collections into just a single array.
Finally, there’s compactMap
, which lets us discard any nil
values that our transform might produce. For example, let’s say we have an array of strings that could contain numbers, and that we want to convert all those numbers into proper Int
values. That’s an operation that could fail (and thus, produce nil
), so we’ll use compactMap
to perform that transformation, giving us only the valid integers that we were looking for:
let numbers = ["42", "19", "notANumber"]
let ints = numbers.compactMap { Int($0) }
print(ints) // [42, 19]
Just like before, since the Int
initializer that we’re using accepts a String
argument, and we’re mapping over an array of strings — we can simply pass Int.init
directly as our transform when calling compactMap
:
let ints = numbers.compactMap(Int.init)
So that’s map
, flatMap
and compactMap
— and how they can be applied to collections, such as Array
. But that’s not the only way to map values in Swift. In fact, mapping is a much more general concept, that can be applied in many more situations than just when transforming arrays.
For example, we can also use the above mapping functions on optionals as well, for example to convert an optional String
into an optional Int
— again using the same Int
initializer that we used before:
func convertToInt(_ string: String?) -> Int? {
return string.flatMap(Int.init)
}
We could also use plain map
, which requires us to produce a non-optional value, for example by using a default:
func convertToInt(_ string: String?) -> Int? {
return string.map { Int($0) ?? 0 }
}
Swift’s various map
functions are great to keep in mind when we need to transform sequences, or optionals, into a new form. They’re also general patterns that are not unique to Swift, and learning how each of them works can let us unlock a whole suite of powerful functional programming features that can also result in simpler, more elegant code.
Thanks for reading! 🚀