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

Swift collections: Swift & iOS best practices explained

Arrays, sets and dictionaries.

Expona Admin9 min read

Understanding Swift collections

If you're building production iOS apps, you'll spend more time working with collections than almost anything else. Whether you're fetching user data from an API, filtering search results, or transforming nested API responses, the collection types you choose directly impact both performance and code clarity. This guide to Swift collections: Swift & iOS best practices explained covers the three essential collection types—arrays, sets, and dictionaries—alongside the functional operations that make them powerful.

Array for ordered data

Arrays maintain insertion order and allow duplicates. They're the most commonly used collection type because most real-world data arrives ordered: API responses return arrays of items, user feeds are ordered by time, search results rank by relevance. When you care about position or sequence, an array is the right choice.

Consider a typical e-commerce scenario where we fetch product listings:

swift
struct Product {
    let id: Int
    let name: String
    let price: Double
    let inStock: Bool
}

let products = [
    Product(id: 1, name: "Laptop", price: 999.99, inStock: true),
    Product(id: 2, name: "Mouse", price: 29.99, inStock: false),
    Product(id: 3, name: "Keyboard", price: 79.99, inStock: true)
]

// Access by index—order matters
if let firstProduct = products.first {
    print("Featured: \(firstProduct.name)")
}

Arrays excel when you need to preserve order, iterate sequentially, or access elements by index. However, lookup by index is O(1), but searching for a specific element by value is O(n)—you'll scan the entire array. For large datasets where you frequently need to check membership, consider a set instead.

A common pattern in mobile apps is paginated loading. Arrays naturally support appending new items as users scroll:

swift
var loadedProducts: [Product] = []

func loadNextPage(products: [Product]) {
    loadedProducts.append(contentsOf: products)
}

Set for unique values

Sets guarantee uniqueness and provide O(1) membership testing. If you're tracking which items a user has already liked, which notifications have been displayed, or which IDs have been processed, a set prevents duplicates efficiently.

In a real-world ride-hailing app, you might track completed trip IDs to avoid processing the same trip twice:

swift
var processedTripIDs: Set = []

func handleTripCompletion(_ tripID: String) {
    guard !processedTripIDs.contains(tripID) else {
        print("Trip already processed")
        return
    }
    
    processedTripIDs.insert(tripID)
    // Process the trip
}

The contains check is significantly faster with a set than with an array, especially as your data grows. If you used an array and called contains on thousands of items, you'd scan the entire array each time. With a set, you get constant-time lookup.

Sets also provide convenient operations for finding common elements or differences:

swift
let userPreferences: Set = ["dark_mode", "notifications", "analytics"]
let systemDefaults: Set = ["notifications", "analytics", "auto_update"]

let commonSettings = userPreferences.intersection(systemDefaults)
// Result: ["notifications", "analytics"]

let userOnly = userPreferences.subtracting(systemDefaults)
// Result: ["dark_mode"]

One constraint: set elements must conform to Hashable. For custom types, you'll need to implement the required protocol. This is worth the effort when uniqueness matters.

Dictionary for key lookups

Dictionaries provide O(1) lookup by key. Use them when you need fast access to values based on a unique identifier, not just position. In a travel booking app, you might cache flight details by flight code:

swift
var flightCache: [String: Flight] = [:]

func getFlightDetails(_ code: String) -> Flight? {
    return flightCache[code]
}

func cacheFlights(_ flights: [Flight]) {
    for flight in flights {
        flightCache[flight.code] = flight
    }
}

Dictionaries are ideal for lookups but not for iteration in a guaranteed order (though in Swift 5.1+, dictionaries maintain insertion order as an implementation detail—don't rely on it for correctness). If you need both fast lookup and guaranteed order, pair a dictionary with an array of keys.

A practical pattern in CRM systems is mapping user IDs to their profiles for quick reference during batch operations:

swift
struct UserProfile {
    let id: Int
    let email: String
    let lastLogin: Date
}

let usersByID: [Int: UserProfile] = [
    1: UserProfile(id: 1, email: "alice@example.com", lastLogin: Date()),
    2: UserProfile(id: 2, email: "bob@example.com", lastLogin: Date())
]

func findUser(id: Int) -> UserProfile? {
    return usersByID[id]
}

Map: transforming collections

Map transforms each element in a collection and returns a new collection with the results. It's invaluable for converting API responses into view models or applying calculations:

swift
let products = [
    Product(id: 1, name: "Laptop", price: 999.99, inStock: true),
    Product(id: 2, name: "Mouse", price: 29.99, inStock: false)
]

let priceStrings = products.map { "$\(String(format: "%.2f", $0.price))" }
// Result: ["$999.99", "$29.99"]

In a SaaS dashboard, you'd typically map database models to display view models:

swift
struct OrderViewModel {
    let id: Int
    let displayPrice: String
}

let orders = [/* database results */]
let viewModels = orders.map { order in
    OrderViewModel(
        id: order.id,
        displayPrice: "$\(String(format: "%.2f", order.total))"
    )
}

Filter: selecting elements

Filter keeps only elements matching a condition. It returns a new collection without modifying the original:

swift
let products = [
    Product(id: 1, name: "Laptop", price: 999.99, inStock: true),
    Product(id: 2, name: "Mouse", price: 29.99, inStock: false),
    Product(id: 3, name: "Keyboard", price: 79.99, inStock: true)
]

let inStockProducts = products.filter { $0.inStock }
// Returns products 1 and 3 only

In a trading platform, you might filter orders by status:

swift
let allOrders = [/* orders from API */]
let executedOrders = allOrders.filter { $0.status == .executed }
let pendingOrders = allOrders.filter { $0.status == .pending }

Reduce: aggregating values

Reduce combines elements into a single value. It's perfect for calculations like summing prices, concatenating strings, or building complex objects:

swift
let products = [
    Product(id: 1, name: "Laptop", price: 999.99, inStock: true),
    Product(id: 2, name: "Mouse", price: 29.99, inStock: false),
    Product(id: 3, name: "Keyboard", price: 79.99, inStock: true)
]

let totalPrice = products.reduce(0.0) { $0 + $1.price }
// Result: 1109.97

A more realistic example: calculating order totals with tax in an e-commerce system:

swift
let cartItems: [CartItem] = [/* items */]
let subtotal = cartItems.reduce(0.0) { sum, item in
    sum + (item.price * Double(item.quantity))
}
let tax = subtotal * 0.08
let total = subtotal + tax

CompactMap: filtering and transforming

CompactMap combines filtering and transformation. It transforms elements and excludes any that return nil. Use it when parsing optional data or skipping invalid entries:

swift
let jsonStrings = ["123", "456", "invalid", "789"]

let numbers = jsonStrings.compactMap { Int($0) }
// Result: [123, 456, 789]

In a real API integration, you might fetch user data where some fields are optional, and you only want complete profiles:

swift
struct APIUserResponse: Codable {
    let id: Int
    let email: String?
    let name: String?
}

struct UserProfile {
    let id: Int
    let email: String
    let name: String
}

let responses: [APIUserResponse] = [/* API results */]
let validProfiles = responses.compactMap { response in
    guard let email = response.email, let name = response.name else {
        return nil
    }
    return UserProfile(id: response.id, email: email, name: name)
}

Combining operations

The real power emerges when you chain these operations. In a ride-hailing app, you might filter available drivers, calculate distances, transform to view models, and sort—all in one readable pipeline:

swift
let availableDrivers = allDrivers
    .filter { $0.isOnline && $0.acceptingRides }
    .map { driver in
        DriverViewModel(
            id: driver.id,
            distance: calculateDistance(from: userLocation, to: driver.location),
            rating: driver.averageRating
        )
    }
    .sorted { $0.distance < $1.distance }
    .prefix(5)

This reads like natural language: filter online drivers accepting rides, transform to view models with calculated distance, sort by distance, take the first five. Each step is clear and testable independently.

Common mistakes

Using arrays for membership checking: If you find yourself repeatedly calling array.contains(value) in a loop, convert to a set. This is especially critical in nested loops where O(n) becomes O(n²).

Mutating during iteration: Never modify a collection while iterating over it. Instead, create a new collection:

swift
// Wrong
var items = [1, 2, 3, 4, 5]
for item in items {
    if item % 2 == 0 {
        items.remove(at: items.firstIndex(of: item)!) // Crash risk
    }
}

// Correct
let items = [1, 2, 3, 4, 5]
let oddItems = items.filter { $0 % 2 != 0 }

Ignoring performance implications: Understand the complexity of your operations. Calling filter repeatedly on large arrays is wasteful. Cache results or restructure your data.

Forcing optionals from map operations: When using map with optional types, use compactMap instead to safely handle nil values without force-unwrapping.

Key takeaways

  • Use arrays for ordered sequences where position matters; accept O(n) membership testing.
  • Use sets for uniqueness guarantees and O(1) membership testing; requires Hashable conformance.
  • Use dictionaries for fast key-based lookups; ideal for caches and mappings.
  • Map transforms collections element-by-element; use for converting types or applying calculations.
  • Filter selects elements matching conditions; creates new collections without mutation.
  • Reduce aggregates elements into single values; useful for summing, concatenating, or building objects.
  • CompactMap combines filtering and transformation; handles optional values gracefully.
  • Chain operations for readable, composable data transformations that are easier to test and maintain.
  • Match your data structure to your access patterns: if you need fast lookups, use a set or dictionary, not an array.
  • Avoid mutating collections during iteration; prefer functional approaches that create new collections.
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