Swift & Objective-C

[Swift Intermediate #5] Higher-Order Functions in Practice

The five functions map·filter·compactMap·flatMap·reduce differ by what the closure returns. This guide covers when to choose them over for loops, the intermediate arrays created by chaining, and the problems lazy sequences solve.

6 min read
Cover image for [Swift Intermediate #5] Higher-Order Functions in Practice

Everyone uses map and filter. The trouble starts after that. compactMap and flatMap are easy to confuse because their names look alike, while reduce feels intimidating because of its signature. Chain them for long enough and a nagging performance worry appears: “How many new arrays is this creating?”

Part 5 of the intermediate series is a practical guide to higher-order functions. It covers the exact distinctions among the five functions, when to choose them over for loops, and the problems lazy sequences solve. This is the hands-on follow-up to the foundations built in the closures chapter.

The exact role of each function — what its signature tells you

A higher-order function is a function that accepts another function as an argument. The five key array functions are best distinguished by the closure’s signature—specifically, what one element becomes.

map: element → new element. Count preserved. Give it n elements and you get n elements back. It transforms; it does not filter.

filter: element → Bool. Count may decrease; elements stay unchanged. It keeps only elements that meet the condition and performs no transformation.

compactMap: element → optional. Discards nil and unwraps the result. It is a specialized combination of map and filter, useful when a transformation can fail.

let inputs = ["1", "2", "three", "4"]
let numbers = inputs.compactMap { Int($0) }  // [1, 2, 4]

Int(“three”) is nil, so compactMap filters it out. If map { Int($0) } had been used, [Int?] would have been produced, forcing optional handling to be repeated at every use site. compactMap is the collection version of the optional chapter’s principle: “unwrap optionals at the boundary.”

flatMap: element → array. Flattens one level of nesting. Use it when one element expands into several. If sentences.flatMap { $0.split(separator: " ") } turns an array of sentences into an array of words, map would produce [[단어]], while flatMap removes one nesting level. The distinction from compactMap is easy: an optional-returning closure means compactMap; an array or sequence-returning closure means flatMap.

reduce: entire collection → one value. It is the general form of folding everything into one result, such as computing a sum, finding a maximum, or building a dictionary. reduce(0, +) computes a sum; its first argument is the initial value, and the second defines how to combine the accumulated result with the next element. Remember one performance tip: when accumulating into an array or dictionary, use reduce(into:). Regular reduce copies the accumulator at every step, while the into variant keeps mutating one value, which makes a major difference for large data sets.

for loops vs. higher-order functions — what readability really means

The claim that “using higher-order functions instead of for loops is more idiomatic Swift” is half right and half dangerous. Here are the criteria.

The real benefit of higher-order functions is not brevity but explicit intent. The moment readers see filter, they know at the signature level that elements are being selected and not changed. With a for loop, they must read the whole body to reach that conclusion. It is an example of the expressiveness principle from Part 1: make the intent visible in the code.

That benefit holds only when each step is simple. Put three nested conditionals and side effects—mutating an external variable or calling print—inside the closure, and the expectation that filter merely filters is violated. The result can be harder to read than a for loop, violating the principle of least astonishment. The criteria are:

  • Simple combinations of transformation, filtering, and aggregation → higher-order functions.
  • Complex branching at each step, side effects, or an intermediate escape (break) required → for loop. Higher-order functions have no good equivalent of break: first(where:) works for stopping at the first matching element, but loops are more natural for more involved early-exit logic.
  • Tasks that need an index → higher-order functions using enumerated() are possible, but use a loop once things become complex.

One antipattern deserves a clear warning: appending to an external array inside forEach. var result: [Int] = []; items.forEach { result.append($0 * 2) } manually reimplements what one line of map does while adding mutable state. forEach fits side effects on each element, such as sending notifications; when the goal is to produce something, use a map-family function.

A diagram contrasting chaining that accumulates intermediate arrays with a lazy pipeline that processes one element at a time
Chaining creates an intermediate array at each step; lazy passes elements through one at a time

The cost of chaining — the hidden guest called the intermediate array

Chaining higher-order functions looks elegant, but remember that each step creates a new array.

let result = products
    .filter { $0.inStock }      // intermediate array 1
    .map { $0.price }           // intermediate array 2
    .prefix(5)                  // final

With one million elements, filter may create an array of up to one million items, and map creates another. Even when you need only the first five, the data is traversed and allocated twice.

lazy is the standard solution. Insert lazy as in products.lazy.filter{...}.map{...}.prefix(5) and the pipeline changes character. Instead of executing immediately and creating arrays, it builds a lazy sequence that records what to do. When finally consumed, each element passes through the entire pipeline one at a time. Iteration stops as soon as five elements are collected, so only the needed prefix of the million elements is touched. There are no intermediate arrays.

That does not mean you should always use lazy. Because lazy does not store results, consuming the same lazy sequence twice performs the computation twice. Since closures are stored by reference and executed later, escaping-related constraints can also arise. The simple rule: lazy shines with large data and partial consumption, such as a prefix or first(where:) combination. If everything will be consumed and stored as an array, eager execution is usually better. This is the collection version of the same question behind a property convenience lazy var: is deferring the computation worthwhile?

Practical combination recipes

Here are a few frequently used combinations as recipes.

Building a dictionary. Use Dictionary(uniqueKeysWithValues: users.map { ($0.id, $0) }) to build an ID lookup table. If duplicate keys are possible, Dictionary(grouping: orders, by: { $0.customerID }) is the grouping variant.

Safe optional unwrapping. Convert an array of string IDs from a server response into URLs: ids.compactMap { URL(string: $0) }. Failures are silently filtered out, and the type is fixed as [URL].

Sorting combinations. It is also worth knowing items.sorted { $0.priority > $1.priority }, which uses a KeyPath instead of items.sorted(using: KeyPathComparator(\.priority, order: .reverse)). KeyPath will be covered separately later.

Aggregation. The cart total is cart.reduce(0) { $0 + $1.price * Double($1.quantity) }. For a simple sum, however, splitting it as cart.map(\.subtotal).reduce(0, +) is often easier to read. A complex reduce closure is a signal to split it up.

An illustration of a developer reading a signpost at the fork between higher-order functions and for loops
For simple transformations, use higher-order functions; for branching, side effects, or early exits, use loops

Summary

  • The criterion is what the closure returns: a new element means map, Bool means filter, an optional means compactMap, an array means flatMap, and folding everything into one value means reduce (reduce(into:) for accumulated collections).
  • The benefit of higher-order functions is explicit intent. When closures become complex or include side effects, that benefit disappears, so return to a for loop.
  • Chaining creates an intermediate array at every step. For partial consumption of large data, build a lazy pipeline; when consuming everything, keep eager execution.
  • If you are building an array with forEach, that is a place to switch to a map-family function.

The next part covers the topic every iOS developer who wrestles with JSON encounters: advanced Codable. It covers CodingKeys, nested structures, date strategies, and the fix for decoding the entire payload failing because of a single field.

Continue reading