| name | swift-conventions |
| description | Expert Swift decisions Claude doesn't instinctively make: struct vs class trade-offs, @MainActor placement, async/await vs Combine selection, memory management pitfalls, and iOS-specific anti-patterns. Use when writing Swift code for iOS/tvOS apps, reviewing Swift architecture decisions, or debugging memory/concurrency issues. Trigger keywords: Swift, iOS, tvOS, actor, async, Sendable, retain cycle, memory leak, struct, class, protocol, generic |
| version | 3.0.0 |
Swift Conventions — Expert Decisions
Expert decision frameworks for Swift choices that require experience. Claude knows Swift syntax — this skill provides the judgment calls.
Decision Trees
Struct vs Class
Need shared mutable state across app?
├─ YES → Class (singleton pattern, session managers)
└─ NO
└─ Need inheritance hierarchy?
├─ YES → Class (UIKit subclasses, NSObject interop)
└─ NO
└─ Data model or value type?
├─ YES → Struct (User, Configuration, Point)
└─ NO → Consider what identity means
├─ Same instance matters → Class
└─ Same values matters → Struct
The non-obvious trade-off: Structs with reference-type properties (arrays, classes inside) lose copy-on-write benefits. A struct containing [UIImage] copies the array reference, not images — mutations affect all "copies."
async/await vs Combine vs Callbacks
Is this a one-shot operation? (fetch user, save file)
├─ YES → async/await (cleaner, better stack traces)
└─ NO → Is it a stream of values over time?
├─ YES
│ └─ Need transformations/combining?
│ ├─ Heavy transforms → Combine (map, filter, merge)
│ └─ Simple iteration → AsyncStream
└─ NO → Must support iOS 14?
├─ YES → Combine or callbacks
└─ NO → async/await with continuation
When Combine still wins: Multiple publishers needing combineLatest, merge, or debounce. Converting this to pure async/await requires manual coordination that Combine handles elegantly.
@MainActor Placement
Is every public method UI-related?
├─ YES → @MainActor on class/struct
└─ NO
└─ Does it manage UI state? (@Published, bindings)
├─ YES → @MainActor on class, nonisolated for non-UI methods
└─ NO
└─ Only some methods touch UI?
├─ YES → @MainActor on specific methods
└─ NO → No @MainActor needed
Critical: @Published properties MUST be updated on MainActor. SwiftUI observes on main thread — background updates cause undefined behavior, not just warnings.
TaskGroup vs async let
Number of concurrent operations known at compile time?
├─ YES (2-5 fixed operations) → async let
│ Example: async let user = fetchUser()
│ async let posts = fetchPosts()
│
└─ NO (dynamic count, array of IDs) → TaskGroup
Example: for id in userIds { group.addTask { ... } }
async let gotcha: All async let values MUST be awaited before scope ends. Forgetting to await silently cancels the task — no error, just missing data.
NEVER Do
Memory & Retain Cycles
NEVER capture self strongly in stored closures:
class ViewModel {
var onUpdate: (() -> Void)?
func setup() {
onUpdate = { self.refresh() }
}
}
onUpdate = { [weak self] in self?.refresh() }
NEVER use unowned unless you can PROVE the reference outlives the closure. When in doubt, use weak. The crash from dangling unowned is worse than the nil-check cost.
NEVER forget Timer invalidation:
timer = Timer.scheduledTimer(target: self, selector: #selector(tick), ...)
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
self?.tick()
}
deinit { timer?.invalidate() }
Concurrency
NEVER access @Published from background:
Task.detached {
viewModel.isLoading = false
}
Task { @MainActor in
viewModel.isLoading = false
}
NEVER use Task { } for fire-and-forget without understanding cancellation:
func buttonTapped() {
Task { await heavyOperation() }
}
func buttonTapped() {
Task.detached(priority: .userInitiated) {
await heavyOperation()
}
}
NEVER assume Task.cancel() stops execution immediately. Cancellation is cooperative — your code must check Task.isCancelled or use try Task.checkCancellation().
Optionals
NEVER force-unwrap in production code except:
@IBOutlet — set by Interface Builder
URL(string: "https://known-valid.com")! — compile-time known strings
fatalError paths where crash is correct behavior
NEVER use implicitly unwrapped optionals (var user: User!) for regular properties. Only valid for:
@IBOutlet connections
- Two-phase initialization where value is set immediately after init
Protocol Design
NEVER make protocols require AnyObject unless you need weak references:
protocol DataProvider: AnyObject {
func fetchData() -> Data
}
protocol ViewModelDelegate: AnyObject {
func viewModelDidUpdate()
}
NEVER add default implementations that change protocol semantics:
protocol Validator {
func validate() -> Bool
}
extension Validator {
func validate() -> Bool { true }
}
extension Validator {
func isAlwaysValid() -> Bool { true }
}
iOS-Specific Patterns
Dependency Injection in ViewModels
protocol UserServiceProtocol {
func fetchUser(id: String) async throws -> User
}
@MainActor
final class UserViewModel: ObservableObject {
@Published private(set) var user: User?
@Published private(set) var error: Error?
private let userService: UserServiceProtocol
init(userService: UserServiceProtocol = UserService()) {
self.userService = userService
}
}
Why default parameter: Production code uses real service, tests inject mock. No container framework needed for most apps.
Property Wrapper Selection
| Wrapper | Use When | Memory Behavior |
|---|
@State | View-local primitive/value types | View-owned, recreated on parent rebuild |
@StateObject | View creates and owns the ObservableObject | Created once, survives view rebuilds |
@ObservedObject | View receives ObservableObject from parent | Not owned, may be recreated |
@EnvironmentObject | Shared across view hierarchy | Must be injected by ancestor |
@Binding | Two-way connection to parent's state | Reference to parent's storage |
The StateObject vs ObservedObject trap: Using @ObservedObject for a locally-created object causes recreation on every view update — losing all state.
Error Handling Strategy
enum UserError: LocalizedError {
case notFound(userId: String)
case unauthorized
case networkFailure(underlying: Error)
var errorDescription: String? {
switch self {
case .notFound(let id): return "User \(id) not found"
case .unauthorized: return "Please log in again"
case .networkFailure: return "Connection failed"
}
}
var recoverySuggestion: String? {
switch self {
case .notFound: return "Check the user ID and try again"
case .unauthorized: return "Your session expired"
case .networkFailure: return "Check your internet connection"
}
}
}
Performance Traps
Copy-on-Write Gotchas
var a = [1, 2, 3]
var b = a
b.append(4)
struct Container {
var items: NSMutableArray
}
var c1 = Container(items: NSMutableArray())
var c2 = c1
c2.items.add(1)
Lazy vs Computed
lazy var dateFormatter: DateFormatter = {
let f = DateFormatter()
f.dateStyle = .medium
return f
}()
var formattedDate: String {
dateFormatter.string(from: date)
}
Rule: Expensive object creation → lazy. Simple derived values → computed.
String Performance
var result = ""
for item in items {
result += item.description
}
var result = ""
result.reserveCapacity(estimatedLength)
for item in items {
result.append(item.description)
}
let result = items.map(\.description).joined(separator: ", ")
Quick Reference
Access Control Decision
| Level | Use When |
|---|
private | Implementation detail within declaration |
fileprivate | Shared between types in same file (rare) |
internal | Module-internal, app code default |
package | Same package, different module (Swift 5.9+) |
public | Framework API, readable outside module |
open | Framework API, subclassable outside module |
Default to most restrictive. Start private, widen only when needed.
Naming Quick Check
- Types:
PascalCase nouns — UserViewModel, NetworkError
- Protocols:
PascalCase — capability (-able/-ible) or description
- Functions:
camelCase verbs — fetchUser(), configure(with:)
- Booleans:
is/has/should/can prefix — isLoading, hasContent
- Factory methods:
make prefix — makeUserViewModel()