Cursor Rules for Swift (iOS/macOS): The Complete Guide to AI-Assisted Swift Development
Swift is the language where you can ship a SwiftUI screen in an afternoon and a memory-cycle-leaking, main-thread-blocking, @Published-tangled production crash by the end of the sprint. The first regression is almost always concurrency: a Task { await fetch() } kicked off from a SwiftUI view's .onAppear, captured in a closure that strong-references self, mutating a @Published property from a background context because someone forgot to annotate the view model @MainActor, and Xcode's purple runtime warnings ignored until a user report comes in. The second is @StateObject vs @ObservedObject chosen at random: the AI creates @ObservedObject let vm = ViewModel() inside the view body, a new view model is instantiated on every SwiftUI re-render, subscriptions reset, and the screen "randomly refetches." The third is a URLSession call that decodes Data with try! JSONDecoder().decode(...) and crashes the app the first time the server returns an error body.
Then you add an AI assistant.
Cursor and Claude Code were trained on Swift code that spans the language's entire history — Objective-C interop with NSDictionary as a bag of types, completion-handler callbacks with @escaping (Result<T, Error>) -> Void (the right idiom in 2018, still the AI's default in 2026), reference-type view models with ObservableObject and @Published (superseded by @Observable macro since iOS 17), SwiftUI patterns from the first WWDC demo that leak memory, DispatchQueue.main.async in the middle of async code that's already on the main actor, force-unwrapping optionals to "fix" a compile error, and fatalError() as a catch-all for "can't happen." Ask for "a view model that loads a list of items," and you get an ObservableObject class with @Published var items: [Item] = [], a loadItems() method with a completion handler, DispatchQueue.main.async { self.items = ... } inside, URLSession.shared.dataTask(...).resume() with a try? decode, and @ObservedObject var vm = ViewModel() in the view. It compiles. It is not the Swift you should ship in 2026.
The fix is .cursorrules — one file in the repo that tells the AI what idiomatic modern Swift looks like. Eight rules below, each with the failure mode, the rule, and a before/after. Copy-paste .cursorrules at the end.
How Cursor Rules Work for Swift Projects
Cursor reads project rules from two locations: .cursorrules (a single file at the repo root, still supported) and .cursor/rules/*.mdc (modular files with frontmatter, recommended). For Swift I recommend modular rules so feature modules can own their own conventions while shared UIKit bridging, networking, and persistence stay consistent:
.cursor/rules/
swift-types.mdc # struct vs class, enums, immutability
swift-concurrency.mdc # async/await, actors, @MainActor, Task
swiftui-state.mdc # @State, @Bindable, @Observable, Environment
swift-networking.mdc # URLSession, Codable, typed errors
swift-testing.mdc # XCTest / Swift Testing, DI, fakes
swift-errors.mdc # throws, typed throws, Result, Never
swift-memory.mdc # weak/unowned, capture lists, cycles
swift-modules.mdc # access control, SPM, module boundaries
Frontmatter controls activation: globs: ["**/*.swift", "**/Package.swift"] with alwaysApply: false. Now the rules.
Rule 1: Value Types By Default — struct Over class, enum With Associated Values, Immutability First
The most common AI failure in Swift is reaching for class the way it would for Java or Kotlin. Cursor writes a class User { var name: String; var email: String } when a struct with let properties is what the domain actually needs. Reference semantics are a tax: they force you to think about aliasing, equality, identity, and mutation visibility across callers. Swift's design point is "value semantics by default, reference semantics when you need identity or shared mutable state." The rule: struct unless you can name the reason it must be a class.
The rule:
Domain models, DTOs, value objects, and configuration types are `struct`.
A `class` is justified only by one of:
- Genuine identity (an object that persists across state changes and
needs reference equality).
- Inheritance from a framework type (UIViewController, NSObject).
- Need to be observed as a reference (ObservableObject / @Observable
view models — and even then, consider @Observable).
- Explicit shared-mutable-state semantics (caches, actors).
Properties default to `let`. Use `var` only when the property genuinely
needs to change post-initialization.
`final class` is the default when a class is justified. Non-final only
when inheritance is part of the contract.
Enums model closed sets of cases — status, state machines, error
kinds — with associated values for case-specific data:
enum LoadState<T> { case idle, loading, loaded(T), failed(Error) }
Prefer enums to "stringly-typed" fields. `enum OrderStatus { case
pending, shipped, delivered, cancelled }`, not `var status: String`.
`@frozen` on public enums only when you are certain the cases are
closed forever (library contract). Internal enums default to non-frozen.
`Equatable`, `Hashable`, `Codable`, `Sendable` conformances are declared
explicitly — never inferred implicitly by "happens to have all-Hashable
members." The compiler now synthesizes them, so just list them.
Collections:
- `let items: [Item]` for immutable collections.
- `var items: [Item]` only when the collection is mutated.
- Generic `Collection` / `Sequence` in signatures when the caller
shouldn't care about the concrete type.
Before — reference-typed domain model, mutable by default, stringly-typed status:
class Order {
var id: Int
var total: Double
var status: String
var items: [OrderItem]
init(id: Int, total: Double, status: String, items: [OrderItem]) {
self.id = id
self.total = total
self.status = status
self.items = items
}
}
Every Order is a heap allocation. status == "pending" lies around waiting to be "Pending" typo'd.
After — struct with enum status, immutable, synthesized conformances:
enum OrderStatus: String, Codable, Sendable {
case pending, shipped, delivered, cancelled
}
struct OrderItem: Codable, Equatable, Sendable {
let productId: Int
let quantity: Int
let unitPrice: Decimal
}
struct Order: Codable, Equatable, Identifiable, Sendable {
let id: Int
let total: Decimal
let status: OrderStatus
let items: [OrderItem]
var itemCount: Int { items.count }
}
Value semantics. A status switch is exhaustive at compile time. Decimal where currency belongs, not Double.
Rule 2: Concurrency With async/await, Actors, and @MainActor — Not Completion Handlers, Not DispatchQueue.main.async
Cursor's default async idiom is a completion handler: func load(completion: @escaping (Result<[Item], Error>) -> Void). That was correct in Swift 5.0. It is not correct in 2026. Swift Concurrency (async/await/actors), introduced in Swift 5.5, superseded by Swift 6's strict concurrency mode, is the primitive. The rule: new code is async, crossing isolation boundaries is explicit, the main actor is declared on anything that touches UIKit/AppKit/SwiftUI, and Task captures are audited for leaks.
The rule:
Every new async API is `async throws` (or `async` for non-throwing).
Completion-handler APIs exist only as adapters to legacy callers, and
are labeled deprecated in the project style.
No `DispatchQueue.main.async { ... }` in async code. Use `@MainActor`
isolation, `await MainActor.run { ... }`, or `Task { @MainActor in ... }`.
Every view, view model, and any type that mutates UI state is
`@MainActor`. Non-main work is explicit (`nonisolated` methods that
await into background actors, or free `async` functions).
Actors isolate shared mutable state:
- `actor Cache { ... }` for in-memory caches.
- `actor NetworkClient { ... }` when the type holds connections.
- `@globalActor` only for genuinely global isolation (image cache,
logger).
`Sendable` discipline:
- Types passed across isolation boundaries are Sendable.
- Value types with Sendable members are implicitly Sendable.
- Reference types that cross boundaries are `final class` + explicit
`@unchecked Sendable` (justified in a comment) or — better — an
actor.
`Task` creation:
- `Task { ... }` inherits actor and priority — prefer it.
- `Task.detached { ... }` only when you explicitly want to escape
the current actor, documented with a comment.
- Every `Task` stored in a property has a cancellation path:
`task?.cancel()` in `deinit` or on view disappear.
`async let` for parallel awaits:
async let a = loadA()
async let b = loadB()
let (x, y) = try await (a, b)
`TaskGroup` for fan-out over collections:
try await withThrowingTaskGroup(of: Item.self) { group in ... }
`Task.sleep(for: .seconds(1))` — never `Thread.sleep`.
`Task.checkCancellation()` at loop boundaries in long-running tasks.
Strict concurrency (Swift 6) is enabled:
swiftSettings: [.enableExperimentalFeature("StrictConcurrency")]
(or Swift 6 language mode directly).
Before — completion handler, dispatch hops, task leak:
class FeedViewModel: ObservableObject {
@Published var items: [Item] = []
func load() {
URLSession.shared.dataTask(with: url) { data, _, error in
if let data = data, let items = try? JSONDecoder().decode([Item].self, from: data) {
DispatchQueue.main.async {
self.items = items
}
}
}.resume()
}
}
No cancellation. self strong-captured in the closure. Background-to-main hop is manual. Decode errors are silently swallowed.
After — @MainActor view model, async/await, cancellable Task:
@MainActor
@Observable
final class FeedViewModel {
private(set) var state: LoadState<[Item]> = .idle
private var loadTask: Task<Void, Never>?
private let client: FeedClient
init(client: FeedClient) { self.client = client }
func load() {
loadTask?.cancel()
loadTask = Task { [weak self] in
guard let self else { return }
self.state = .loading
do {
let items = try await self.client.fetchItems()
guard !Task.isCancelled else { return }
self.state = .loaded(items)
} catch is CancellationError {
return
} catch {
self.state = .failed(error)
}
}
}
deinit { loadTask?.cancel() }
}
Cancellation is wired. Main-actor isolation is declared once, not hopped every time. Decode errors land in .failed.
Rule 3: SwiftUI State — @State / @Binding / @Bindable / @Environment / @Observable, Never @ObservedObject In A Body
SwiftUI's state model has more knobs than most AI training data has caught up with. The AI will reach for @ObservedObject var vm = ViewModel() as a property wrapper on a view — which constructs a new view model every time SwiftUI re-renders the parent. The modern pattern (iOS 17+): @Observable on the view model, @State on the root view that creates it, @Bindable when a child view needs write access to its properties, and @Environment for values that flow through the tree. The rule below codifies the choice matrix.
The rule:
View models adopt the `@Observable` macro (iOS 17 / macOS 14+). Do not
write new ObservableObject / @Published view models. Migrate on touch.
Ownership matrix:
- View OWNS the view model: `@State var model = ViewModel()` on the
root view that creates it. NEVER `@ObservedObject var model = ...`
inside a child view.
- Child view READS the view model: plain `let model: ViewModel`.
- Child view WRITES to view model properties: `@Bindable var model:
ViewModel` inside `body`, or pass a specific `Binding<T>`.
- View-local ephemeral state: `@State private var searchText: String`.
- Shared across view tree: `@Environment(\.someKey) var` with custom
EnvironmentKey.
`@StateObject` / `@ObservedObject` / `@EnvironmentObject` are only
accepted in existing code not yet migrated. No new code uses them.
Bindings:
- Two-way binding to a value: `$searchText`.
- Binding into a struct inside state: `$model.user.name`.
- Computed binding when you need derivation:
`Binding(get: { ... }, set: { ... })`.
View structs are small. A view body over 50 lines is split into
subviews. Subviews take the minimum state they need.
Navigation:
- NavigationStack with typed `NavigationPath` on iOS 16+.
- Enum-based routing for screens:
enum Route: Hashable { case detail(Int), settings }
NavigationStack(path: $router.path) { ... .navigationDestination(for:
Route.self) { route in ... } }
Previews:
- #Preview macro (Xcode 15+) with multiple variations.
- View models in previews use fakes / stubs (see Rule 5), never the
production networking layer.
Environment values are declared with typed keys, not via
untyped strings:
private struct NotificationCenterKey: EnvironmentKey {
static let defaultValue = NotificationCenter.default
}
extension EnvironmentValues {
var notificationCenter: NotificationCenter {
get { self[NotificationCenterKey.self] }
set { self[NotificationCenterKey.self] = newValue }
}
}
SwiftUI animations use `withAnimation` or `.animation(value:)`, never
the implicit-everywhere modifier.
Before — view model constructed in body, @ObservedObject everywhere:
struct FeedView: View {
@ObservedObject var viewModel = FeedViewModel()
var body: some View {
NavigationView {
List(viewModel.items) { item in
Text(item.name)
}
.onAppear { viewModel.load() }
}
}
}
Every parent re-render creates a new FeedViewModel. Subscriptions restart. onAppear fires repeatedly. NavigationView has been deprecated since iOS 16.
After — @observable, owned via @State, typed navigation:
@Observable
@MainActor
final class FeedViewModel { /* … Rule 2 */ }
struct FeedView: View {
@State private var model: FeedViewModel
@State private var path = NavigationPath()
init(client: FeedClient) {
_model = State(wrappedValue: FeedViewModel(client: client))
}
var body: some View {
NavigationStack(path: $path) {
content
.navigationDestination(for: Item.ID.self) { id in
ItemDetailView(id: id)
}
.task { await model.loadOnce() }
}
}
@ViewBuilder
private var content: some View {
switch model.state {
case .idle, .loading:
ProgressView()
case .loaded(let items):
List(items) { item in
NavigationLink(value: item.id) { ItemRow(item: item) }
}
case .failed(let error):
ErrorView(error: error) { model.load() }
}
}
}
#Preview("Loaded") {
FeedView(client: .stub(items: .sample))
}
View model is created once and owned by the view. .task auto-cancels on view disappear. Navigation is typed.
Rule 4: URLSession + Codable + Typed Errors — No try!, No Global .shared, No Raw Data Parsing
The easiest way to crash a Swift app is try! JSONDecoder().decode(Response.self, from: data) on the network path. AI assistants write this every time they build an API client. The modern path: a dedicated client type (not URLSession.shared), URLRequest constructed from typed endpoints, async throws return types that decode to Decodable structs, a closed error enum per client, and retries/timeouts configured on the URLSessionConfiguration.
The rule:
Every API client is a type (struct or final class) that wraps a
URLSession configured explicitly:
- Timeouts: `timeoutIntervalForRequest` (30s default), `timeoutIntervalForResource`.
- Cellular policy: `allowsCellularAccess`.
- HTTP maximum connections per host: `httpMaximumConnectionsPerHost`.
Never use `URLSession.shared` outside of simple one-off fetches in
playgrounds.
`URLSession.shared.data(from:)` is forbidden in production code; use
the injected client.
Requests are built from typed endpoints:
enum Endpoint {
case orders, orderDetail(Int)
var path: String { ... }
var method: HTTPMethod { ... }
}
Response decoding:
- Every decoder has `keyDecodingStrategy = .convertFromSnakeCase`
(or explicit CodingKeys).
- Date strategies explicit: `.iso8601` with fractional seconds, or
a custom formatter — never default.
- A single shared `JSONDecoder` per client, built once.
Errors are a closed enum, not raw `Error`:
enum APIError: Error, Sendable {
case transport(URLError)
case server(statusCode: Int, body: Data)
case decoding(DecodingError)
case unauthorized
case cancelled
}
`try?` / `try!` are forbidden on network paths. Decoding errors surface
as `APIError.decoding(...)`, not silent nil.
HTTP status codes map to domain:
- 2xx: decode body into T.
- 401: APIError.unauthorized (triggers token refresh / sign-out).
- 4xx: APIError.server(statusCode, body) — body is decoded into a
ProblemDetails shape when possible.
- 5xx: APIError.server(...) — retryable at the call site.
Retry policy lives at the call site, not inside the client. The client
is "one request → one response or error."
Authentication:
- An `Authenticator` actor holds the current token, refreshes atomically.
- Requests go through an `authorizing` wrapper that attaches the token
and retries once on 401.
URLRequest body encoding uses a shared JSONEncoder with the same
conventions as the decoder.
`HTTPURLResponse.statusCode` is always checked; never assume 200.
Reachability is not polled from the app — let the request fail with
`URLError.notConnectedToInternet` and surface it.
Before — shared URLSession, force-unwrap, raw Error:
func loadOrders() {
let url = URL(string: "https://api.example.com/orders")!
URLSession.shared.dataTask(with: url) { data, response, error in
let orders = try! JSONDecoder().decode([Order].self, from: data!)
DispatchQueue.main.async {
self.orders = orders
}
}.resume()
}
One malformed server response crashes the app. No status-code check. No decoding discipline. No timeout.
After — typed client, closed error enum, Sendable:
struct OrdersClient: Sendable {
private let session: URLSession
private let baseURL: URL
private let decoder: JSONDecoder
init(baseURL: URL, session: URLSession = .shared, decoder: JSONDecoder = .apiDefault) {
self.baseURL = baseURL
self.session = session
self.decoder = decoder
}
func fetchOrders() async throws(APIError) -> [Order] {
try await request([Order].self, path: "orders")
}
private func request<T: Decodable>(
_ type: T.Type,
path: String
) async throws(APIError) -> T {
var req = URLRequest(url: baseURL.appendingPathComponent(path))
req.httpMethod = "GET"
req.setValue("application/json", forHTTPHeaderField: "Accept")
let data: Data
let response: URLResponse
do {
(data, response) = try await session.data(for: req)
} catch let error as URLError where error.code == .cancelled {
throw APIError.cancelled
} catch let error as URLError {
throw APIError.transport(error)
} catch {
throw APIError.transport(URLError(.unknown))
}
guard let http = response as? HTTPURLResponse else {
throw APIError.transport(URLError(.badServerResponse))
}
switch http.statusCode {
case 200..<300:
do { return try decoder.decode(T.self, from: data) }
catch let error as DecodingError { throw APIError.decoding(error) }
catch { throw APIError.transport(URLError(.cannotParseResponse)) }
case 401: throw APIError.unauthorized
default: throw APIError.server(statusCode: http.statusCode, body: data)
}
}
}
extension JSONDecoder {
static var apiDefault: JSONDecoder {
let d = JSONDecoder()
d.keyDecodingStrategy = .convertFromSnakeCase
d.dateDecodingStrategy = .iso8601
return d
}
}
A 500 does not crash. A malformed body surfaces as APIError.decoding. Typed throws (Swift 6) let callers handle specific cases without as? casting.
Rule 5: Testing — XCTest or Swift Testing, Dependency Injection Over Singletons, No Network In Unit Tests
Cursor writes Swift tests the way it writes Python tests: an XCTestCase subclass, a sut = MyViewModel() in setUp(), and URLSession.shared inside the view model's load() method. Half of those tests hit the real API. The other half are XCTAssertEqual(sut.items.count, 0) that test the initial state. The modern path: Swift Testing (@Test) for new code, explicit dependency injection (the view model takes a client), protocol-based or closure-based fakes, and confirmation / async-aware assertions for concurrency.
The rule:
Test framework: Swift Testing (`import Testing`, `@Test` macro) for new
code (Xcode 16+ / Swift 6). XCTestCase is maintained but new tests are
Swift Testing. Migration is opportunistic.
Swift Testing conventions:
@Suite("FeedViewModel")
struct FeedViewModelTests {
@Test("loads items into .loaded on success")
func loadsOnSuccess() async throws { ... }
@Test(arguments: [1, 2, 5, 100])
func paginates(pageSize: Int) async throws { ... }
}
Expectations:
#expect(model.state == .loaded(.sample))
#require(model.items.first) // throws CancellationError if nil
Dependency injection:
- Types under test receive collaborators via init.
- Singletons (`.shared`) are wrapped in protocols and injected.
- NO direct use of URLSession.shared, UserDefaults.standard,
FileManager.default inside testable code.
Fakes:
- Protocol + stub struct for simple cases.
- A `Client` that takes closures lets tests inject behavior without
protocols:
struct FeedClient: Sendable {
var fetchItems: @Sendable () async throws -> [Item]
}
extension FeedClient {
static let success: Self = .init(fetchItems: { .sample })
static let failure: Self = .init(fetchItems: { throw APIError.unauthorized })
}
Networking tests use recorded fixtures (JSON files in the test bundle)
and an injected `URLProtocol` stub, NEVER the real network. UI tests can
use a local mock server when they must.
SwiftUI view models are unit-tested directly (instantiate, call, assert
on state). UI tests are separate targets and test navigation, not state.
Concurrency in tests:
- Await `Task.value` or use `await Task.yield()` to let state settle.
- `confirmation { confirm in ... }` (Swift Testing) for async callbacks.
- Never `XCTestExpectation` with `.wait(timeout:)` in new code.
Snapshot tests (swift-snapshot-testing) for complex SwiftUI views that
are expensive to assert imperatively — record intentionally, diff on CI.
Coverage: >85% on view models and services. UI tests cover the happy
path per screen, not every branch (unit tests cover branches).
Before — XCTestCase, real URLSession, sleep-based assertion:
class FeedViewModelTests: XCTestCase {
func testLoad() {
let vm = FeedViewModel()
vm.load()
sleep(3)
XCTAssertGreaterThan(vm.items.count, 0)
}
}
Real network, flaky under CI, sleep(3) waiting for completion, tests whatever the API returns that day.
After — Swift Testing, injected client, deterministic:
@Suite("FeedViewModel")
@MainActor
struct FeedViewModelTests {
@Test("loaded state after successful fetch")
func loadsOnSuccess() async throws {
let model = FeedViewModel(client: .success)
model.load()
try await Task.sleep(for: .milliseconds(10))
try await #require(model.loadTask).value
#expect(model.state == .loaded(.sample))
}
@Test("failed state surfaces API error")
func failureSurfaces() async throws {
let model = FeedViewModel(client: .failure)
model.load()
try await #require(model.loadTask).value
guard case .failed(let error) = model.state else {
Issue.record("expected .failed, got \(model.state)")
return
}
#expect(error is APIError)
}
@Test("cancellation does not flip to failed")
func cancellationIsSilent() async throws {
let model = FeedViewModel(client: .slow)
model.load()
model.cancel()
try await #require(model.loadTask).value
#expect(model.state == .idle)
}
}
Deterministic, fast, no network, documents behavior per case.
Rule 6: Memory — [weak self] Only Where It's Needed, Explicit Cycle Audits, @MainActor deinit Nuance
AI-generated Swift tends to be paranoid about memory, applying [weak self] to every closure — including synchronous map/filter blocks where it does nothing. It also tends to miss actual cycles: a Timer.scheduledTimer(...) that captures self strongly, an NotificationCenter observer that retains a closure. The rule below codifies when weak/unowned is justified, when it's noise, and when Combine/Observation should replace manual observer patterns entirely.
The rule:
`[weak self]` is used in closures that outlive the current function AND
reach self. Specifically:
- Stored closures on long-lived objects (Timer, NotificationCenter,
NSTimer handlers, delegate callbacks).
- Published subscriptions (Combine sinks, Observation closures)
stored in `cancellables` on a view model.
- `Task { ... }` bodies stored in properties (see Rule 2) — optional,
the Task holds a strong reference but the reference is cancellable.
`[weak self]` is NOT needed in:
- Closures that run synchronously and immediately return (map, filter,
forEach, sort).
- Closures that are passed to async functions and awaited (their scope
doesn't outlive the await).
- `Task { ... }` that is fire-and-forget and lives a short time —
though [weak self] is a safe habit if the Task may race the view
lifetime.
`[unowned self]` only when you can prove the closure never outlives
self. A crash bug when you're wrong.
Don't forget:
- `guard let self else { return }` right after capturing `[weak self]`.
- `NotificationCenter.default.addObserver(...)` must be paired with
`removeObserver` in `deinit` — or use the block-based API and
retain the token.
- KVO (`observe(\.foo)`) returns an `NSKeyValueObservation` that
must be retained; dropping it removes the observation.
Combine subscriptions:
- Every `.sink` has a `.store(in: &cancellables)` on a
`Set<AnyCancellable>` property.
- `cancellables` is cleared in `deinit` implicitly; no manual cleanup
needed.
Reference cycle smells (fix before shipping):
- `self.closure = { self.doSomething() }` — closure retains self,
self retains closure.
- `delegate` properties NOT declared `weak`. Protocols with delegate
must declare: `weak var delegate: MyDelegate?`.
- Child view controllers holding a strong reference to their parent.
Use Instruments' Leaks + Allocations templates before release. A view
that comes and goes should return to zero instances in Allocations.
`@MainActor` types on `deinit` — `deinit` is NOT main-actor-isolated
even on a `@MainActor` type (Swift 6 rule). Cleanup in `deinit` cannot
call main-actor methods synchronously; either mark the method
`nonisolated` or dispatch.
Before — cycle via stored closure, missing cleanup:
final class HomeViewController: UIViewController {
var timer: Timer?
override func viewDidLoad() {
super.viewDidLoad()
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
self.refresh()
}
NotificationCenter.default.addObserver(
forName: .appDidBecomeActive,
object: nil,
queue: .main
) { _ in
self.refresh()
}
}
func refresh() { /* ... */ }
}
Timer captures self. Observer block captures self. Never torn down. Controller leaks on every push.
After — weak captures, observer token retained, invalidate on deinit:
@MainActor
final class HomeViewController: UIViewController {
private var timer: Timer?
private var activeObserver: NSObjectProtocol?
override func viewDidLoad() {
super.viewDidLoad()
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
self?.refresh()
}
activeObserver = NotificationCenter.default.addObserver(
forName: .appDidBecomeActive,
object: nil,
queue: .main
) { [weak self] _ in
self?.refresh()
}
}
deinit {
timer?.invalidate()
if let activeObserver {
NotificationCenter.default.removeObserver(activeObserver)
}
}
private func refresh() { /* ... */ }
}
Timer and observer do not retain self. deinit tears both down. Instruments shows zero leaked instances on navigation.
Rule 7: Errors — throws With Typed Errors, Result For Storage, Never For "Can't Happen"
Swift's error story has evolved. The AI's default is throws with a bare Error existential, caught in the view model and discarded. Modern Swift (5.10+ / 6) supports typed throws — throws(APIError) — which lets callers handle specific cases without casting. Result<Success, Failure> is for storing an async outcome you haven't yet awaited, not for every function that might fail. Never is the right return type for "this function does not return normally."
The rule:
Most functions `throw` (untyped). Typed throws (`throws(E)`) are used
at API boundaries where the error set is closed and the caller benefits:
func fetch() async throws(APIError) -> [Item]
`Result<Success, Failure>` is for storing a deferred outcome — most
often as a `@Published` or `@Observable` property for the UI. It is
NOT a replacement for `throws` in function signatures.
Error types:
- Closed enums conforming to Error + Sendable.
- Carry just enough context to recover (HTTP status, URL, underlying
Error). Do not embed UI strings — localized messages are the view's
job.
- Conform to `LocalizedError` only when the error can be surfaced
directly in UI; otherwise convert via a mapper.
`fatalError()` and `preconditionFailure()` are used for invariants
that are genuinely impossible by construction. They include a message
explaining why. They are not a substitute for `throws`.
`Never` return type for functions that don't return:
func crash(_ reason: String) -> Never { fatalError(reason) }
`switch` cases returning Never (e.g., in an exhaustive switch over
an enum) are exhausted by the compiler.
`try?` is allowed for "I genuinely don't care about the error" paths —
logged at the call site. `try!` is allowed only in tests and in setup
code where failure is a crash condition you want.
Error mapping is a pure function:
func userFacingMessage(for error: APIError) -> String { ... }
Called at the UI boundary, not scattered through the view model.
`Cancellation`:
- `CancellationError` is caught and treated as a normal flow in Task
bodies (don't surface it as "failed").
- Caller-side: `Task.checkCancellation()` at loop boundaries.
Error logging:
- `os.Logger` (or `OSLog`) with subsystem + category.
- Never `print` in production code. `Logger` in debug builds gets the
same sink.
Before — untyped Error, discarded, fatalError on any branch:
func load() {
do {
let data = try client.fetch()
items = data
} catch {
fatalError("couldn't load")
}
}
Any network blip crashes the app. No information about what failed.
After — typed throws, closed error, UI mapping:
@MainActor
@Observable
final class FeedViewModel {
private(set) var state: LoadState<[Item]> = .idle
private let client: FeedClient
private let logger = Logger(subsystem: "app.feed", category: "viewmodel")
init(client: FeedClient) { self.client = client }
func load() {
Task { [weak self] in
guard let self else { return }
self.state = .loading
do {
let items = try await self.client.fetchItems()
self.state = .loaded(items)
} catch let error as APIError {
self.logger.warning("fetch failed: \(String(describing: error))")
self.state = .failed(error)
} catch is CancellationError {
self.state = .idle
} catch {
self.logger.error("unexpected: \(error)")
self.state = .failed(APIError.transport(URLError(.unknown)))
}
}
}
}
func userFacingMessage(_ error: APIError) -> String {
switch error {
case .unauthorized: "Please sign in to continue."
case .transport: "Network is unreachable."
case .server(let code, _): "Server error (\(code))."
case .decoding: "Unexpected response from server."
case .cancelled: ""
}
}
Caller switches exhaustively on APIError. Cancellation is a normal flow, not a failure. UI strings live in one mapper.
Rule 8: Modules, Access Control, and Swift Package Manager — Public Only Where Public Is Intended
The default class/struct in a Swift file is internal. The default for a framework's public API is "whatever has public in front of it." Cursor will make everything public because "the view uses it" — breaking module boundaries and leaking implementation types out of feature modules. The rule below codifies when each access level is right, how SPM modules are structured, and how to communicate stability to callers.
The rule:
Access levels, default-narrowest:
- `private`: default. Scoped to the enclosing declaration (or file
for top-level in Swift 4+).
- `fileprivate`: for types that need cross-declaration visibility in
the same file; rare.
- `internal`: module-visible. Use when a type is consumed by other
files in the same module.
- `package` (Swift 5.9+): visible across modules in the same SPM
package. Use for shared-implementation types that aren't public API.
- `public`: module's external API. Requires `@inlinable` / `@frozen`
judgment for ABI.
- `open`: only for classes explicitly designed for subclassing by
external consumers; rare.
Every public type has a documentation comment describing purpose,
thread-safety (nonisolated / @MainActor / actor), and error conditions.
Public enums default to non-frozen. Adding a case is not a breaking
change (the compiler forces `@unknown default`). Use `@frozen` only
when you are certain the cases are final.
SPM package structure (recommended for apps of any size):
App # entry point target, imports features + core
Core # shared types, networking, persistence
DesignSystem # reusable UI components, typography, tokens
FeatureFoo # feature module with its own views + view models
FeatureBar # ...
Feature modules DO NOT import each other. Cross-feature communication
goes through Core or the App module's composition root. A feature's
public surface is the smallest set of types required by the App target.
`@testable import` only in unit tests of the same module. Avoid cross-
module testable imports; test through the public API.
`@_spi(Internal)` for API that is public for tooling reasons but not
part of the library contract. Document it as such.
`@available(iOS 17, *)` annotations on types that use iOS-17-only
features. Deployment target in Package.swift pins the minimum; newer
features are conditionally compiled or guarded.
Build settings:
- Treat warnings as errors in CI (`-warnings-as-errors`).
- Strict concurrency checking `complete` in Swift 5, or Swift 6
language mode, for new modules.
- `-enable-actor-data-race-checks` in debug builds for runtime
diagnostics.
Objective-C interop (when it exists):
- `@objc` on methods only when truly needed; Swift-first API elsewhere.
- `NS_REFINED_FOR_SWIFT` on bridged headers when the Swift-facing API
should differ.
Before — everything public, flat module, no feature boundaries:
// in App target
public class FeedService { ... }
public class Database { ... }
public class AppCoordinator { ... }
public func logEvent(_ name: String) { ... }
All implementation details exposed as public API. Consumers import types they shouldn't.
After — feature modules, narrow public surface, package-level internals:
// Package.swift
let package = Package(
name: "App",
platforms: [.iOS(.v17)],
products: [
.library(name: "FeatureFeed", targets: ["FeatureFeed"]),
.library(name: "Core", targets: ["Core"]),
],
targets: [
.target(name: "Core"),
.target(name: "FeatureFeed", dependencies: ["Core"]),
.testTarget(name: "FeatureFeedTests", dependencies: ["FeatureFeed"]),
],
swiftLanguageModes: [.v6]
)
// Sources/Core/FeedClient.swift
public struct FeedClient: Sendable {
public var fetchItems: @Sendable () async throws(APIError) -> [Item]
public init(fetchItems: @escaping @Sendable () async throws(APIError) -> [Item]) {
self.fetchItems = fetchItems
}
}
// Sources/FeatureFeed/FeedView.swift
import Core
public struct FeedView: View {
public init(client: FeedClient) { ... }
public var body: some View { ... }
}
// Sources/FeatureFeed/Internal/FeedViewModel.swift
@MainActor
@Observable
final class FeedViewModel { /* internal — not visible outside module */ }
The App target imports FeatureFeed and wires a FeedClient. It never sees the view model type. Features swap implementations without breaking consumers.
The Complete .cursorrules File
Drop this in the repo root. Cursor and Claude Code both pick it up.
# Swift (iOS/macOS) — Production Patterns
## Types
- struct over class by default; class only for identity, inheritance,
observable reference, or shared mutable state.
- final class is the default class.
- Properties default to `let`; var only when it must change.
- Enums with associated values for closed state sets. Prefer enums to
stringly-typed status.
- @frozen enums only when cases are truly closed.
- Explicit conformances to Equatable, Hashable, Codable, Sendable.
- Domain types are Sendable.
- Decimal for currency, not Double.
## Concurrency
- Every new async API is `async throws`; completion handlers are
legacy adapters only.
- No DispatchQueue.main.async in async code.
- UI types are @MainActor.
- Actors for shared mutable state.
- Sendable conformance for types crossing isolation boundaries.
- Task { ... } inherits; Task.detached only with a comment on why.
- Stored Tasks cancelled in deinit / on view disappear.
- async let + TaskGroup for parallelism.
- Swift 6 strict concurrency enabled.
## SwiftUI State
- @Observable macro for view models (iOS 17+); migrate
ObservableObject on touch.
- Owning view: @State var model = ViewModel().
- Child read: let model; child write: @Bindable or Binding<T>.
- View-local ephemeral: @State private var.
- Shared via @Environment with typed keys.
- NavigationStack + typed NavigationPath (no NavigationView).
- Body > 50 lines splits into subviews.
- #Preview with stub clients, never production networking.
## Networking
- Dedicated URLSession per client; no URLSession.shared in production.
- Typed endpoints, shared JSONDecoder with explicit strategies.
- Closed error enum (APIError) with transport/server/decoding/
unauthorized/cancelled.
- No try! / try? on network paths.
- Status-code to domain error mapping.
- Auth via Authenticator actor; retry 401 once after refresh.
- Retries at call site, not in the client.
## Testing
- Swift Testing (@Test) for new code; XCTest maintained, migrated
on touch.
- Dependencies injected via init; no .shared / .default inside testable
code.
- Closure-based fakes preferred; protocol fakes when structure helps.
- URLProtocol stubs + JSON fixtures for network; no real network in
unit tests.
- confirmation / Task.value for async assertions; no
XCTestExpectation in new code.
- Coverage >85% view models + services.
## Memory
- [weak self] only in closures that outlive the function AND reach self.
- Not in map/filter/forEach or awaited async calls.
- Stored closures, NotificationCenter blocks, Timers always [weak self].
- guard let self else { return } after [weak self].
- delegate properties declared weak.
- Combine sinks .store(in: &cancellables).
- Instruments Leaks pass before release.
- @MainActor deinit is NOT isolated — mark cleanup nonisolated.
## Errors
- throws (untyped) is default; throws(E) at API boundaries with
closed error sets.
- Result<Success, Failure> for stored deferred outcomes (UI state),
not as a throws replacement.
- Closed-enum error types, Sendable, no UI strings embedded.
- fatalError / preconditionFailure only for by-construction
impossibilities, with a message.
- try? logged; try! only in tests / setup.
- Error → UI string mapping is one pure function at the view boundary.
- os.Logger everywhere; no print in production paths.
## Modules & Access
- private default; internal when shared in module; package across
package; public for module API; open rare.
- Every public type documented (purpose, isolation, errors).
- SPM structure: App + Core + DesignSystem + feature modules.
- Features do not import each other; composition in App.
- @testable import same-module only.
- @_spi(Internal) for tooling-only public API.
- Deployment target in Package.swift; @available guards per-type.
- -warnings-as-errors + strict concurrency in CI.
- Swift 6 language mode for new modules.
End-to-End Example: A Fetched List With State, Cancellation, and a Test
Without rules: ObservableObject + DispatchQueue + try! + @ObservedObject in body + sleep-based test.
class VM: ObservableObject {
@Published var items: [Item] = []
func load() {
URLSession.shared.dataTask(with: url) { data, _, _ in
DispatchQueue.main.async {
self.items = try! JSONDecoder().decode([Item].self, from: data!)
}
}.resume()
}
}
struct V: View { @ObservedObject var vm = VM(); var body: some View { List(vm.items) { ... } } }
With rules: @observable + @State owner + typed client + Swift Testing.
// Core/FeedClient.swift
public struct FeedClient: Sendable {
public var fetchItems: @Sendable () async throws(APIError) -> [Item]
}
// FeatureFeed/FeedViewModel.swift
@MainActor
@Observable
final class FeedViewModel {
enum State: Equatable { case idle, loading, loaded([Item]), failed(APIError) }
private(set) var state: State = .idle
private var loadTask: Task<Void, Never>?
private let client: FeedClient
init(client: FeedClient) { self.client = client }
func load() {
loadTask?.cancel()
loadTask = Task { [weak self] in
guard let self else { return }
self.state = .loading
do {
let items = try await self.client.fetchItems()
guard !Task.isCancelled else { return }
self.state = .loaded(items)
} catch is CancellationError {
self.state = .idle
} catch let error as APIError {
self.state = .failed(error)
} catch {
self.state = .failed(.transport(URLError(.unknown)))
}
}
}
deinit { loadTask?.cancel() }
}
// FeatureFeed/FeedView.swift
public struct FeedView: View {
@State private var model: FeedViewModel
public init(client: FeedClient) {
_model = State(wrappedValue: FeedViewModel(client: client))
}
public var body: some View {
switch model.state {
case .idle, .loading: ProgressView().task { model.load() }
case .loaded(let items): List(items) { Text($0.name) }
case .failed(let err): ErrorView(message: userFacingMessage(err)) { model.load() }
}
}
}
// Tests
@Suite @MainActor
struct FeedViewModelTests {
@Test func loadedOnSuccess() async throws {
let model = FeedViewModel(client: .init(fetchItems: { .sample }))
model.load()
try await #require(model.loadTask).value
#expect(model.state == .loaded(.sample))
}
}
Cancellation-safe, main-actor-correct, DI-clean, deterministically testable.
Get the Full Pack
These eight rules cover the Swift patterns where AI assistants consistently reach for the wrong idiom. Drop them into .cursorrules and the next prompt you write will look different — value-typed, concurrency-safe, SwiftUI-correct, URLSession-disciplined, test-friendly, leak-free, error-typed, module-clean Swift, without having to re-prompt.
If you want the expanded pack — these eight plus rules for SwiftData (Core Data replacement), Core Data migrations and the persistent-container pattern, Combine in places async/await doesn't yet fit, Swift Package Manager monorepo layouts with XCFrameworks, accessibility (VoiceOver, Dynamic Type) defaults per view, localization + String.LocalizationValue, deep-link parsing with NavigationStack, App Intents for Shortcuts + Siri, and the deploy patterns I use for iOS on TestFlight + App Store Connect — it is bundled in Cursor Rules Pack v2 ($27, one payment, lifetime updates). Drop it in your repo, stop fighting your AI, ship Swift you would actually merge.
Top comments (0)