Replacing Closures with Key Paths - Cleaner Swift Code

October 3, 2026

Swift's functional collection methods, such as map(_:), compactMap(_:), and filter(_:), accept a closure that describes how to process each element. Many of these closures do only one thing: they read a property.

let usernames = users.map { $0.username }

The closure syntax here adds braces and an anonymous $0 argument that say nothing about what the code does. Starting in Swift 5.2, you can pass a key path expression anywhere a function of the form (Root) -> Value is expected. The compiler converts the key path into the equivalent function for you.

let usernames = users.map(\.username)

The result is identical. The intent, map each user to its username, is now the only thing on the line.

This style is often called point-free, because the code describes the transformation without naming the individual element (the "point") it operates on.


Define a Sample Model

The examples in this article use the following types.

struct Address {
    let city: String
    let postalCode: String?
}

struct User {
    let id: UUID
    let username: String
    let email: String?
    let age: Int
    let isActive: Bool
    let isAdmin: Bool
    let department: String
    let address: Address
}

Transform Elements with Map

Use a key path with map(_:) to extract a single property from every element in a collection.

let ids = users.map(\.id)               // [UUID]
let ages = users.map(\.age)             // [Int]
let departments = users.map(\.department)

Key paths can also traverse nested properties. Chain property names with dots, just as you would in an ordinary expression.

let cities = users.map(\.address.city)

Discard Missing Values with Compact Map

When a property is optional, compactMap(_:) returns only the non-nil values. A key path to an optional property works exactly as a closure would.

// Before
let emails = users.compactMap { $0.email }

// After
let emails = users.compactMap(\.email)   // [String]

Key paths support optional chaining, so you can reach through optional values along the way.

let postalCodes = users.compactMap(\.address.postalCode)

To remove nil values from a collection of optionals, use the identity key path, \.self.

let scores: [Int?] = [98, nil, 72, nil, 85]
let recordedScores = scores.compactMap(\.self)   // [98, 72, 85]

Select Elements with Boolean Properties

Any method that takes a predicate of the form (Element) -> Bool accepts a key path to a Bool property.

let activeUsers = users.filter(\.isActive)
let firstAdmin = users.first(where: \.isAdmin)
let hasAdmin = users.contains(where: \.isAdmin)
let everyoneActive = users.allSatisfy(\.isActive)

Each line now reads almost like a sentence: filter users that are active, find the first user who is an admin.


Group and Index Elements

Initializers that take a transformation closure also accept key paths. Use Dictionary(grouping:by:) to bucket elements by a shared property.

let usersByDepartment = Dictionary(grouping: users, by: \.department)
// [String: [User]]

Combine key paths with other initializers to build lookups concisely.

let uniqueCities = Set(users.map(\.address.city))

Extend Key Paths to Other APIs

Some standard library methods, such as sorted(by:) and max(by:), expect a closure that takes two elements. A key path can't be converted into that shape directly. You can bridge the gap with a small extension.

extension Sequence {
    func sorted<Value: Comparable>(
        by keyPath: KeyPath<Element, Value>,
        using areInIncreasingOrder: (Value, Value) -> Bool = (<)
    ) -> [Element] {
        sorted {
            areInIncreasingOrder($0[keyPath: keyPath], $1[keyPath: keyPath])
        }
    }
}

The call site then matches the style of the other examples.

let youngestFirst = users.sorted(by: \.age)
let oldestFirst = users.sorted(by: \.age, using: >)

Similarly, you can define an operator to negate a Boolean key path, which is useful because filter(\.isActive) has no built-in inverse.

prefix func ! <Root>(keyPath: KeyPath<Root, Bool>) -> (Root) -> Bool {
    { !$0[keyPath: keyPath] }
}

let inactiveUsers = users.filter(!\.isActive)

Tip: Keep extensions like these small and few. Each one is another piece of vocabulary your team must learn, so add them only where they make repeated code clearer.


Understand the Limitations

Key path expressions are a focused tool. Prefer a closure in the following situations.

The transformation does more than read a property. Key paths can't call methods, perform arithmetic, or combine values.

// Requires a closure
let lowercased = users.map { $0.username.lowercased() }
let summaries = users.map { "\($0.username) (\($0.age))" }

The key path is stored in a variable. The automatic conversion applies only to key path literals written directly at the call site. A key path held in a constant or variable must be applied explicitly.

let nameKeyPath = \User.username

// users.map(nameKeyPath)   // Error: cannot convert value of type 'KeyPath<User, String>'
let names = users.map { $0[keyPath: nameKeyPath] }

The type can't be inferred. A leading-dot key path such as \.username relies on the surrounding context to determine its root type. In rare cases where the compiler can't infer it, write the root type explicitly, as in \User.username.

Important: Key path–to–function conversion requires Swift 5.2 or later. If your project supports an earlier toolchain, continue using closures.


Choose the Clearer Form

Key paths don't replace closures; they replace a specific, very common kind of closure. Use these guidelines to decide which form to write:

If the closure… Prefer
Reads one property, possibly nested Key path
Tests a single Bool property Key path
Unwraps an optional property Key path with compactMap(_:)
Calls a method or computes a new value Closure
Compares two elements Closure, or a key path–based extension

When you apply them consistently, key paths let readers scan a chain of collection operations and see what each step extracts, without wading through the syntax of how.

let activeAdminEmails = users
    .filter(\.isActive)
    .filter(\.isAdmin)
    .compactMap(\.email)

Conclusion

Key path expressions give you a concise way to describe the most common collection transformations in Swift. When a closure does nothing more than read a property, replacing it with a key path removes the braces and the anonymous $0 argument, leaving only the property you care about.

Keep these points in mind as you adopt the pattern:

  • Pass key paths to map(_:), compactMap(_:), filter(_:), first(where:), contains(where:), and allSatisfy(_:) whenever the closure would only read a property.
  • Use nested and optional-chained key paths, such as \.address.city and \.address.postalCode, to reach deeper into your models without extra syntax.
  • Add small extensions, like a key path–based sorted(by:), only where they make repeated code clearer.
  • Fall back to closures when the logic calls methods, computes new values, or uses a key path stored in a variable.

Applied consistently, key paths make a chain of collection operations read like a description of your data rather than a set of instructions for processing it. Start with the simplest cases, such as users.map(\.username), and let the pattern spread naturally through your codebase.

Thank you for reading. If you have any questions feel free to follow me on X and send me a DM. If this article helped you, Buy me a coffee.