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:
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:
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:
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:
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:
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:
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:
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:
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:
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 onlyIn a trading platform, you might filter orders by status:
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:
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.97A more realistic example: calculating order totals with tax in an e-commerce system:
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 + taxCompactMap: 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:
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:
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:
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:
// 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.






Comments
Loading comments…