Files

933 lines
23 KiB
Markdown

# 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<User, NetworkError>) -> 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<Profile, Error>) -> 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<Void, Never>?
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..<items.count, id: \.self) { i in
ItemRow(item: items[i])
}
// Correct: use a stable, unique identifier
ForEach(items) { item in // Item: Identifiable
ItemRow(item: item)
}
// Use .id(...) to deliberately reset a view's state
ProfileView(user: user)
.id(user.id) // new identity per user -> 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<T: Numeric>(_ 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<Item> {
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>) -> 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<Data, Error>
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)