# Swift Code Review Guide A code review checklist for modern Swift (5.9+/6), covering SwiftUI, Swift Concurrency, and the Swift API Design Guidelines. ## Quick Review Checklist ### Must-Check Items - [ ] Are force-unwraps (`!`) and `try!` avoided in favor of safe unwrapping - [ ] Do closures that capture `self` use `[weak self]` to avoid retain cycles - [ ] Is the value vs reference type choice intentional (struct vs class) - [ ] Are errors propagated with `throws`/`Result` instead of being swallowed - [ ] Are concurrency boundaries data-race-safe (`Sendable`, `@MainActor`, actors) ### Common Issues - [ ] Fire-and-forget `Task {}` that leaks or is never cancelled - [ ] Wrong SwiftUI property wrapper (`@ObservedObject` where `@StateObject` is needed) - [ ] O(n^2) lookups in loops that could use a `Set` or `Dictionary` - [ ] Implicitly unwrapped optionals (`var x: T!`) outside of IBOutlets - [ ] Over-broad access control (`public`/`open` where `internal` suffices) - [ ] Naming that ignores the Swift API Design Guidelines --- ## 1. Optionals and Unwrapping ### 1.1 Avoid Force-Unwrapping ```swift // ❌ Wrong: crashes at runtime if nil let name = user.name! let url = URL(string: urlString)! // ✅ Correct: bind with guard let / if let guard let name = user.name else { return } if let url = URL(string: urlString) { load(url) } ``` ### 1.2 Use Nil-Coalescing for Defaults ```swift // ❌ Wrong: verbose and crash-prone let count: Int if let c = dictionary["count"] { count = c } else { count = 0 } // ✅ Correct: nil-coalescing let count = dictionary["count"] ?? 0 ``` ### 1.3 Prefer guard let for Early Exit ```swift // ❌ Wrong: deep nesting (pyramid of doom) func process(_ input: String?) { if let input = input { if let value = Int(input) { if value > 0 { handle(value) } } } } // ✅ Correct: guard keeps the happy path unindented func process(_ input: String?) { guard let input, let value = Int(input), value > 0 else { return } handle(value) } ``` ### 1.4 Avoid Implicitly Unwrapped Optionals ```swift // ❌ Wrong: T! is a hidden force-unwrap on every access class ViewModel { var service: NetworkService! } // ✅ Correct: inject a non-optional dependency class ViewModel { private let service: NetworkService init(service: NetworkService) { self.service = service } } ``` ### 1.5 Use Optional Chaining and map/flatMap ```swift // ❌ Wrong: manual unwrapping just to transform var initial: String? if let name = user.name { initial = String(name.prefix(1)) } // ✅ Correct: optional chaining + map let initial = user.name.map { String($0.prefix(1)) } // ✅ Correct: flatMap to avoid double optionals let port: Int? = components.port.flatMap { Int(exactly: $0) } ``` --- ## 2. Memory Management and Retain Cycles ### 2.1 Use [weak self] in Escaping Closures ```swift // ❌ Wrong: closure strongly captures self, creating a retain cycle class ImageLoader { var onComplete: (() -> Void)? func load() { service.fetch { data in self.cache = data // self is retained by the closure self.onComplete?() } } } // ✅ Correct: capture self weakly and guard class ImageLoader { var onComplete: (() -> Void)? func load() { service.fetch { [weak self] data in guard let self else { return } self.cache = data self.onComplete?() } } } ``` ### 2.2 weak vs unowned ```swift // ✅ Use weak when the reference can legitimately become nil class Controller { weak var delegate: ControllerDelegate? } // ✅ Use unowned only when the captured object is guaranteed to // outlive the closure (e.g. self owns the closure tightly). // unowned crashes if accessed after deallocation. class Owner { lazy var describe: () -> String = { [unowned self] in self.name } let name = "owner" } // ❌ Wrong: unowned on something that can outlive self -> crash networkClient.onResponse = { [unowned self] in self.update() } // Prefer [weak self] here, since onResponse may fire after self is gone. ``` ### 2.3 Break Delegate Retain Cycles ```swift // ❌ Wrong: strong delegate keeps both objects alive forever protocol DataSourceDelegate: AnyObject {} class DataSource { var delegate: DataSourceDelegate? // strong by default } // ✅ Correct: delegates should be weak (and protocol AnyObject-bound) class DataSource { weak var delegate: DataSourceDelegate? } ``` ### 2.4 Closures Stored as Properties ```swift // ❌ Wrong: stored closure captures self strongly -> permanent cycle class Timer { var tick: (() -> Void)! func configure() { tick = { self.count += 1 } } var count = 0 } // ✅ Correct: weak capture for stored closures referencing self class Timer { var tick: (() -> Void)? func configure() { tick = { [weak self] in self?.count += 1 } } var count = 0 } ``` --- ## 3. Value vs Reference Types ### 3.1 Prefer Structs by Default ```swift // ✅ Use a struct for data/models with value semantics struct Coordinate { var latitude: Double var longitude: Double } // Copies are independent; no shared mutable state, thread-friendly. var a = Coordinate(latitude: 1, longitude: 2) var b = a b.latitude = 99 // a is unchanged ``` ### 3.2 Use a Class for Identity or Shared State ```swift // ✅ Use a class when instances have identity or must be shared/mutated // by reference, or when you need inheritance / Objective-C interop. final class DatabaseConnection { private(set) var isOpen = false func open() { isOpen = true } } // Two references point to the same connection. let conn1 = DatabaseConnection() let conn2 = conn1 conn1.open() // conn2.isOpen == true ``` ### 3.3 Mark Classes final When Not Subclassed ```swift // ❌ Wrong: open to subclassing unintentionally (slower dispatch, fragile API) class UserViewModel {} // ✅ Correct: final enables static dispatch and signals intent final class UserViewModel {} ``` ### 3.4 Beware Reference Types Inside Structs ```swift // ❌ Surprising: struct copy still shares the inner class instance final class Box { var value = 0 } struct Container { var box = Box() } var x = Container() var y = x y.box.value = 42 // x.box.value is also 42 (shared reference!) // ✅ Correct: use value semantics throughout, or copy on write deliberately struct Container { var value = 0 // plain value type, copies are independent } ``` --- ## 4. Error Handling ### 4.1 Avoid try! and try? ```swift // ❌ Wrong: try! crashes on any thrown error let data = try! Data(contentsOf: url) // ❌ Often wrong: try? silently discards the error and the cause let data = try? Data(contentsOf: url) // data is nil, you lose "why" // ✅ Correct: propagate or handle with do-catch do { let data = try Data(contentsOf: url) process(data) } catch { log.error("failed to read \(url): \(error)") } ``` ### 4.2 Define Meaningful Error Types ```swift // ✅ Recommended: an Error enum communicates failure modes precisely enum NetworkError: Error { case invalidURL case unauthorized case server(statusCode: Int) case decoding(underlying: Error) } func fetch(_ path: String) throws -> Data { guard let url = URL(string: path) else { throw NetworkError.invalidURL } // ... } ``` ### 4.3 Use Result for Stored or Deferred Outcomes ```swift // ✅ Result is useful at callback boundaries or when storing an outcome func load(completion: @escaping (Result) -> Void) { // completion(.success(user)) or completion(.failure(.unauthorized)) } // ✅ Convert between Result and throws as needed let user = try result.get() ``` ### 4.4 Typed Throws (Swift 6) ```swift // ✅ Typed throws constrains the error type when it is fully known. // Use it for closed, exhaustive error domains; prefer untyped // `throws` for library APIs that may grow new error cases. func parse(_ raw: String) throws(ParsingError) -> Token { guard let token = Token(raw) else { throw ParsingError.malformed } return token } do { let token = try parse(input) } catch { // `error` is statically known to be ParsingError handle(error) } ``` ### 4.5 Don't Catch and Rethrow Without Value ```swift // ❌ Wrong: catch that adds nothing but obscures the trace do { try work() } catch { throw error // pointless } // ✅ Correct: only catch to add context or recover do { try work() } catch { throw AppError.workFailed(underlying: error) } ``` --- ## 5. Swift Concurrency ### 5.1 Prefer async/await Over Nested Callbacks ```swift // ❌ Wrong: callback pyramid, error handling scattered func loadProfile(completion: @escaping (Result) -> Void) { fetchUser { userResult in switch userResult { case .success(let user): fetchAvatar(user) { avatarResult in /* ... */ } case .failure(let error): completion(.failure(error)) } } } // ✅ Correct: linear async/await func loadProfile() async throws -> Profile { let user = try await fetchUser() let avatar = try await fetchAvatar(user) return Profile(user: user, avatar: avatar) } ``` ### 5.2 Use @MainActor for UI State ```swift // ❌ Wrong: mutating UI state from a background context (data race / crash) func refresh() async { let items = try? await api.load() self.items = items ?? [] // may run off the main thread } // ✅ Correct: isolate UI-facing types to the main actor @MainActor final class FeedViewModel: ObservableObject { @Published var items: [Item] = [] func refresh() async { let loaded = (try? await api.load()) ?? [] items = loaded // guaranteed on the main actor } } ``` ### 5.3 Protect Mutable State with Actors ```swift // ❌ Wrong: shared mutable state without synchronization (data race) final class Counter { var value = 0 func increment() { value += 1 } } // ✅ Correct: an actor serializes access to its mutable state actor Counter { private(set) var value = 0 func increment() { value += 1 } } let counter = Counter() await counter.increment() // access is awaited and serialized ``` ### 5.4 Conform Shared Types to Sendable ```swift // ❌ Wrong: passing a non-Sendable class across actors (Swift 6 error) final class Config { // mutable, not Sendable var retries = 3 } // ✅ Correct: make shared types Sendable (immutable value type is ideal) struct Config: Sendable { let retries: Int } // ✅ For reference types, use final + immutable stored properties, // or @unchecked Sendable only with manual synchronization. final class Cache: @unchecked Sendable { private let lock = NSLock() private var storage: [String: Data] = [:] // all access guarded by lock } ``` ### 5.5 Handle Task Cancellation ```swift // ❌ Wrong: ignores cancellation, keeps working after the view is gone func search(_ query: String) async -> [Result] { var results: [Result] = [] for page in 0..<100 { results += await fetchPage(query, page) // never stops } return results } // ✅ Correct: check for cancellation cooperatively func search(_ query: String) async throws -> [Result] { var results: [Result] = [] for page in 0..<100 { try Task.checkCancellation() results += try await fetchPage(query, page) } return results } ``` ### 5.6 Don't Leak Fire-and-Forget Tasks ```swift // ❌ Wrong: unstructured Task with no handle, never cancelled final class ViewModel { func onAppear() { Task { await self.stream() // runs forever even after dismissal } } } // ✅ Correct: retain the handle and cancel it (or use .task in SwiftUI) final class ViewModel { private var streamTask: Task? func onAppear() { streamTask = Task { [weak self] in await self?.stream() } } func onDisappear() { streamTask?.cancel() } } ``` ### 5.7 Use Structured Concurrency for Parallelism ```swift // ❌ Wrong: sequential awaits where work could run concurrently let a = await loadA() let b = await loadB() // waits for A to finish first // ✅ Correct: async let runs them concurrently async let a = loadA() async let b = loadB() let (resultA, resultB) = await (a, b) // ✅ For a dynamic number of children, use a task group try await withThrowingTaskGroup(of: Item.self) { group in for id in ids { group.addTask { try await fetch(id) } } for try await item in group { store(item) } } ``` --- ## 6. SwiftUI ### 6.1 Choose the Right State Wrapper ```swift // ✅ @State: simple value-type state owned by this view struct Toggle: View { @State private var isOn = false var body: some View { /* ... */ } } // ✅ @StateObject: the view CREATES and OWNS a reference-type model struct ProfileScreen: View { @StateObject private var model = ProfileViewModel() var body: some View { /* ... */ } } // ✅ @ObservedObject: the model is OWNED elsewhere and passed in struct ProfileHeader: View { @ObservedObject var model: ProfileViewModel var body: some View { /* ... */ } } // ✅ @Binding: a two-way reference to state owned by a parent struct SearchField: View { @Binding var text: String var body: some View { /* ... */ } } ``` ### 6.2 @StateObject vs @ObservedObject ```swift // ❌ Wrong: @ObservedObject for an object the view itself creates. // SwiftUI may recreate the view, re-instantiating the model and // losing its state on every re-render. struct CounterView: View { @ObservedObject var model = CounterModel() // recreated unexpectedly } // ✅ Correct: @StateObject ties the model's lifetime to the view struct CounterView: View { @StateObject private var model = CounterModel() } ``` ### 6.3 Preserve View Identity ```swift // ❌ Wrong: index-based id reuses identity when the array reorders, // causing wrong animations and stale state. ForEach(0.. fresh state ``` ### 6.4 Avoid Over-Rendering ```swift // ❌ Wrong: a single huge body re-renders everything on any change struct Dashboard: View { @ObservedObject var model: DashboardModel var body: some View { VStack { // header + heavy chart + list all recompute together } } } // ✅ Correct: extract subviews so only the affected part re-renders. // Each child observes only the state it needs. struct Dashboard: View { var body: some View { VStack { HeaderView() ChartView() ItemList() } } } ``` ### 6.5 Do Async Work with .task ```swift // ❌ Wrong: kicking off work in onAppear without cancellation .onAppear { Task { await model.load() } // not cancelled when view disappears } // ✅ Correct: .task is tied to the view's lifetime and auto-cancels .task { await model.load() } // ✅ Re-run when an input changes .task(id: query) { await model.search(query) } ``` --- ## 7. Protocols and Generics ### 7.1 Protocol-Oriented Design ```swift // ✅ Compose behavior with protocols and default implementations protocol Identifiable2 { var id: String { get } } protocol Describable { var description: String { get } } extension Describable { var description: String { "no description" } // default } ``` ### 7.2 Prefer some Over any ```swift // ❌ Slower: `any` is an existential box with dynamic dispatch func makeShape() -> any Shape { Circle() } // ✅ Faster: `some` is an opaque type resolved at compile time, // preserving the concrete type and enabling static dispatch. func makeShape() -> some Shape { Circle() } // Use `any` only when you genuinely need heterogeneous values: let shapes: [any Shape] = [Circle(), Square()] ``` ### 7.3 Generic Constraints Over Existentials ```swift // ❌ Wrong: existential parameter loses the concrete type and is slower func logTotal(_ items: [any Numeric]) { // awkward: the concrete numeric type is erased, so arithmetic needs casts } // ✅ Correct: a generic constraint keeps full type information func total(_ items: [T]) -> T { items.reduce(.zero, +) } ``` ### 7.4 Associated Types with Primary Associated Types ```swift // ✅ Primary associated types (Swift 5.7+) allow lightweight constraints protocol Container { associatedtype Item var count: Int { get } subscript(_ index: Int) -> Item { get } } // Constrain the element type without a where-clause: func first(in container: some Container) -> Int { container[0] } ``` --- ## 8. Access Control and API Design ### 8.1 Use the Narrowest Access Level ```swift // ❌ Wrong: everything public exposes internal details as API surface public class Service { public var cache: [String: Data] = [:] public func reset() {} } // ✅ Correct: expose only the intended API; hide the rest public final class Service { private var cache: [String: Data] = [:] public func reset() { cache.removeAll() } } ``` ### 8.2 private vs fileprivate vs internal vs public/open ```swift // private: visible only within the enclosing declaration (and its extensions in the same file) // fileprivate: visible within the same source file // internal: visible within the module (the default) // public: visible outside the module, but not subclassable/overridable // open: visible outside the module AND subclassable/overridable // ✅ Use private(set) to expose read-only state public final class Account { public private(set) var balance: Decimal = 0 } ``` ### 8.3 Follow the Swift API Design Guidelines ```swift // ❌ Wrong: redundant words, unclear argument roles func insertObject(_ object: Element, atIndex index: Int) list.removeElement(at: 0) // ✅ Correct: read at the call site like a phrase; omit needless words func insert(_ element: Element, at index: Int) list.insert(item, at: 0) // reads as "insert item at 0" list.remove(at: 0) // ✅ Boolean properties read as assertions var isEmpty: Bool var hasChanges: Bool ``` ### 8.4 Name Methods by Side Effects ```swift // ✅ Mutating verb vs non-mutating noun pairs (the "ed/ing" rule) var sorted = array.sorted() // returns a new value (non-mutating) array.sort() // mutates in place (imperative verb) let reversed = text.reversed() text.reverse() ``` --- ## 9. Collections and Functional Style ### 9.1 Prefer map/filter/compactMap ```swift // ❌ Verbose: manual loop with mutable accumulator var names: [String] = [] for user in users { if user.isActive { names.append(user.name) } } // ✅ Correct: declarative transform let names = users.filter(\.isActive).map(\.name) ``` ### 9.2 compactMap to Drop nils ```swift // ❌ Wrong: map leaves an [Int?] you then have to unwrap let numbers = strings.map { Int($0) } // [Int?] // ✅ Correct: compactMap removes nils and unwraps let numbers = strings.compactMap { Int($0) } // [Int] ``` ### 9.3 Avoid O(n^2) Membership Checks ```swift // ❌ Wrong: contains on an Array is O(n); the loop is O(n*m) let result = candidates.filter { blocked.contains($0) } // blocked: [ID] // ✅ Correct: a Set makes membership O(1) let blockedSet = Set(blocked) let result = candidates.filter { blockedSet.contains($0) } ``` ### 9.4 reduce and Dictionary Grouping ```swift // ✅ Group with Dictionary(grouping:) let byFirstLetter = Dictionary(grouping: words) { $0.first } // ❌ Wrong: reduce(into:) is preferred over reduce that copies each step let total = numbers.reduce(0) { $0 + $1 } // fine for scalars // ✅ Use reduce(into:) when accumulating into a collection (avoids copies) let counts = words.reduce(into: [:]) { acc, word in acc[word, default: 0] += 1 } ``` ### 9.5 Use lazy for Chained Transforms on Large Sequences ```swift // ❌ Wrong: each step allocates an intermediate array let firstMatch = bigArray.map(expensive).filter(isValid).first // ✅ Correct: lazy avoids intermediate arrays and stops early let firstMatch = bigArray.lazy.map(expensive).filter(isValid).first ``` --- ## 10. Testing ### 10.1 Arrange-Act-Assert with XCTest ```swift import XCTest @testable import MyApp final class PriceCalculatorTests: XCTestCase { func testDiscountApplied() { // Arrange let calculator = PriceCalculator(discount: 0.1) // Act let total = calculator.total(for: 100) // Assert XCTAssertEqual(total, 90, accuracy: 0.001) } } ``` ### 10.2 Testing async Code ```swift // ✅ Mark the test method async and await directly func testFetchUser() async throws { let service = UserService(client: MockClient()) let user = try await service.fetchUser(id: "42") XCTAssertEqual(user.id, "42") } // ✅ Assert that an async call throws the expected error func testFetchUserUnauthorized() async { let service = UserService(client: UnauthorizedClient()) do { _ = try await service.fetchUser(id: "42") XCTFail("expected to throw") } catch NetworkError.unauthorized { // expected } catch { XCTFail("unexpected error: \(error)") } } ``` ### 10.3 Inject Dependencies via Protocols ```swift // ✅ Depend on a protocol so tests can substitute a mock protocol HTTPClient { func get(_ url: URL) async throws -> Data } struct MockClient: HTTPClient { var result: Result func get(_ url: URL) async throws -> Data { try result.get() } } ``` ### 10.4 Avoid Sleeps; Await Expectations or Values ```swift // ❌ Wrong: arbitrary sleep makes tests slow and flaky func testCallback() { var done = false object.run { done = true } Thread.sleep(forTimeInterval: 1) XCTAssertTrue(done) } // ✅ Correct: use XCTestExpectation for callback APIs func testCallback() { let expectation = expectation(description: "callback fired") object.run { expectation.fulfill() } wait(for: [expectation], timeout: 1.0) } // ✅ Better: refactor to async and await the value directly func testCallback() async { let value = await object.run() XCTAssertEqual(value, expected) } ``` --- ## References - [Swift API Design Guidelines](https://www.swift.org/documentation/api-design-guidelines/) - [The Swift Programming Language](https://docs.swift.org/swift-book/) - [Swift Concurrency (TSPL)](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/concurrency/) - [Migrating to Swift 6](https://www.swift.org/migration/documentation/migrationguide/) - [Apple: Managing Model Data in Your App (SwiftUI)](https://developer.apple.com/documentation/swiftui/managing-model-data-in-your-app) - [Apple: Automatic Reference Counting](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/automaticreferencecounting/) - [WWDC: Protocol-Oriented Programming in Swift](https://developer.apple.com/videos/play/wwdc2015/408/) - [Swift Evolution](https://github.com/apple/swift-evolution)