Skip to content
ExponaBLOG
SWIFT, SWIFTUI & IOS APPS · DAY 009 / 100

Closures in Swift: Swift & iOS best practices explained

Functions as values.

Expona Admin8 min read

Closures in Swift: Swift & iOS best practices explained

Closures are one of the most powerful yet misunderstood features in Swift. If you've written a completion handler, used map on an array, or passed a callback to a network request, you've already worked with closures. Understanding how to use them effectively—and safely—is what separates junior code from production-grade iOS applications.

At their core, closures are self-contained blocks of functionality that can be passed around your codebase as values. They capture variables from their surrounding scope and can be stored, passed as arguments, or returned from functions. This flexibility makes them indispensable for asynchronous operations, functional programming patterns, and building clean APIs. But with that power comes responsibility: memory leaks, retain cycles, and confusing syntax can creep in quickly if you're not careful.

In this article, we'll walk through the practical patterns we use daily in production: trailing closure syntax to keep call sites readable, shorthand argument syntax to reduce boilerplate, weak self captures to prevent memory leaks, and the @escaping attribute to mark closures that outlive their enclosing function.

Trailing closure syntax

Trailing closure syntax is a quality-of-life feature that transforms closure readability. When a function's last parameter is a closure, you can pass it outside the parentheses and omit the parameter label. This is more than stylistic—it's what makes functional operations feel native to the language.

Consider a typical network request completion handler written without trailing syntax:

swift
fetchUserData(userId: 42, completion: { result in
    print("User: \(result.name)")
})

Now with trailing closure syntax:

swift
fetchUserData(userId: 42) { result in
    print("User: \(result.name)")
}

The difference is immediate: the code reads like a statement rather than a nested function call. This is especially powerful when chaining operations. In production systems processing payments or user onboarding workflows, we often see multi-step sequences that become nearly unreadable without trailing syntax:

swift
func processPayment(amount: Decimal, card: PaymentCard, completion: @escaping (Result<Receipt, PaymentError>) -> Void) {
    validateCard(card) { validationResult in
        switch validationResult {
        case .success(let validated):
            chargeCard(validated, amount: amount) { chargeResult in
                completion(chargeResult)
            }
        case .failure(let error):
            completion(.failure(error))
        }
    }
}

Without trailing syntax, this becomes callback hell quickly. With it—and with proper abstraction—the intent is clearer. The syntax also applies when you're the one defining the function:

swift
func executeAsync(work: @escaping () -> Void) {
    DispatchQueue.global().async(execute: work)
}

// Called with trailing syntax
executeAsync {
    print("Running in background")
}

When a closure is the only parameter, you can omit the parentheses entirely. This pattern appears everywhere in iOS: animation blocks, completion handlers, and functional transforms.

$0 shorthand arguments

Shorthand argument syntax ($0, $1, etc.) eliminates parameter naming boilerplate for simple closures. When your closure is a one-liner that doesn't benefit from explicit parameter names, $0 keeps it concise without sacrificing readability.

The canonical example is array transformation:

swift
let prices = [19.99, 49.99, 9.99]

// Verbose
let doubled = prices.map { price in
    price * 2
}

// Shorthand
let doubled = prices.map { $0 * 2 }

Both are valid; the choice depends on context. For filtering users by status in a real e-commerce system, explicit names often win:

swift
let activeUsers = users.filter { user in
    user.status == .active && user.lastLoginDate > thirtyDaysAgo
}

That's clearer than $0.status == .active && $0.lastLoginDate > thirtyDaysAgo. The rule we follow: if the logic fits on one line and the parameter's meaning is obvious from context, shorthand is fine. If it spans multiple lines or the logic is complex, explicit naming wins.

Shorthand is especially useful in chained operations:

swift
let summary = orders
    .filter { $0.status == .completed }
    .map { $0.total }
    .reduce(0, +)

Here, the parameter's meaning is clear from the chain context. But if you're doing this in production code for ride-hailing or travel systems where domain logic matters, don't force shorthand:

swift
let availableDrivers = allDrivers
    .filter { driver in
        driver.isOnline && driver.vehicleInspectionValid && !driver.onActiveRide
    }
    .sorted { driverA, driverB in
        driverA.acceptanceRate > driverB.acceptanceRate
    }

Explicit names make the intent unmistakable for the next developer (or you, six months later) reading this code in a production system.

[weak self] to avoid retain cycles

This is where closures become dangerous. When a closure captures self and stores a reference to that closure, you create a cycle: the object holds the closure, the closure holds the object. Nothing deallocates. Memory leaks cascade through your app, and users report slowdowns and battery drain.

The classic pattern: a view controller with a network request. Without care, the closure captures self strongly:

swift
class UserProfileViewController: UIViewController {
    var viewModel: UserViewModel?
    
    func loadProfile() {
        // Dangerous: self is captured strongly
        apiClient.fetchUser(id: 123) { result in
            switch result {
            case .success(let user):
                self.viewModel?.updateUser(user)
            case .failure(let error):
                self.showError(error)
            }
        }
    }
}

If the view controller is dismissed before the network request completes, self can't be deallocated because the closure holds it. The completion block waits forever, holding memory.

The solution is [weak self]:

swift
func loadProfile() {
    apiClient.fetchUser(id: 123) { [weak self] result in
        switch result {
        case .success(let user):
            self?.viewModel?.updateUser(user)
        case .failure(let error):
            self?.showError(error)
        }
    }
}

Now self is captured weakly. The closure doesn't prevent deallocation. When the network request completes and self has already been deallocated, self is nil and the optional chaining (self?) safely does nothing.

When should you use [weak self]? Always when the closure might outlive the object and the object holds a reference to the closure (directly or indirectly). In production code handling real user data:

swift
class CartViewController: UIViewController {
    func checkout() {
        paymentService.processPayment(cart: cart) { [weak self] result in
            guard let self = self else { return }
            
            switch result {
            case .success(let receipt):
                self.showConfirmation(receipt)
            case .failure(let error):
                self.showError(error)
            }
        }
    }
}

The guard pattern is common: we unwrap self and bail if it's nil. This is safer than chaining optionals through multiple statements.

Occasionally you might use [unowned self] when you're absolutely certain the closure can't outlive self. In practice, we avoid unowned in production code because crashes from accessing deallocated unowned references are worse than silent nil handling. Stick with [weak self].

@escaping for stored closures

By default, closures in Swift are non-escaping: they execute before the function returns. If a closure might execute after the function exits—like a completion handler or a stored callback—it must be marked @escaping. Without it, the compiler prevents you from storing it or passing it to another escaping function.

Non-escaping closures are the default for good reasons: they're easier to reason about, the compiler can optimize them better, and you can use self without capturing it. But when you need a closure to persist, @escaping is mandatory:

swift
// Non-escaping: closure runs before execute returns
func synchronousMap<T, U>(_ items: [T], transform: (T) -> U) -> [U] {
    return items.map(transform)
}

// Escaping: closure runs after function returns
func executeAsync(work: @escaping () -> Void) {
    DispatchQueue.global().async(execute: work)
}

In production systems, @escaping appears constantly. Any API that queues work for later must use it:

swift
class NetworkClient {
    private var requestQueue: [() -> Void] = []
    
    func queueRequest(url: URL, completion: @escaping (Data?) -> Void) {
        requestQueue.append {
            self.perform(url: url, completion: completion)
        }
    }
    
    func flushQueue() {
        requestQueue.forEach { $0() }
        requestQueue.removeAll()
    }
}

Here, @escaping is necessary because the closure is stored in requestQueue and executed later. Without the annotation, the compiler refuses the code.

A common escaping pattern in real systems is delegating work to background services. In a ride-hailing app, you might queue location updates:

swift
class LocationTracker {
    func sendUpdate(location: CLLocationCoordinate2D, completion: @escaping (Bool) -> Void) {
        DispatchQueue.global().async { [weak self] in
            let result = self?.uploadToServer(location) ?? false
            DispatchQueue.main.async {
                completion(result)
            }
        }
    }
}

The @escaping annotation tells readers and the compiler: this closure lives longer than the function call. Pair it with [weak self] when necessary to avoid retain cycles in background work.

Common mistakes

We've seen these patterns fail in production code repeatedly:

  • Forgetting [weak self] with stored closures: If a view controller holds a reference to a network service, and the service stores completion handlers, you'll leak memory without [weak self]. Test by dismissing view controllers and checking memory in Instruments.
  • Using unowned reflexively: Unowned is faster than weak but crashes if the referenced object deallocates. Weak is safer and the performance difference is negligible. Prefer weak unless you have measured evidence otherwise.
  • Over-using shorthand syntax: $0.user.preferences.notifications.enabled is harder to read than explicitly naming parameters. Readability compounds over time.
  • Forgetting @escaping when storing closures: The compiler catches this, but it's easy to miss in complex refactors. If you store it, mark it @escaping.
  • Mixing callback patterns inconsistently: In a codebase, pick Result<Success, Failure> or (value?, error?) for consistency. Mixing both creates confusion for the next developer.

Key takeaways

  • Use trailing closure syntax for readability; it's the idiomatic Swift style for all completion handlers and functional operations.
  • Shorthand arguments ($0, $1) are fine for simple single-line operations but explicit naming wins for complex logic or multi-line closures.
  • Always use [weak self] when a closure might outlive the object and captures self. Pair it with guard let for safety.
  • Mark closures @escaping when they're stored, dispatched to background queues, or passed to other escaping functions.
  • Test memory behavior in Instruments when closures interact with long-lived objects; retain cycles are silent until they're not.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles