DEV Community

Olivia Craft
Olivia Craft

Posted on

Cursor Rules for Swift: The Complete Guide to AI-Assisted iOS Development

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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 }
}
Enter fullscreen mode Exit fullscreen mode

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).
Enter fullscreen mode Exit fullscreen mode

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()
    }
}
Enter fullscreen mode Exit fullscreen mode

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() }
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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() }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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))
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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).
Enter fullscreen mode Exit fullscreen mode

Before — XCTestCase, real URLSession, sleep-based assertion:

class FeedViewModelTests: XCTestCase {
    func testLoad() {
        let vm = FeedViewModel()
        vm.load()
        sleep(3)
        XCTAssertGreaterThan(vm.items.count, 0)
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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() { /* ... */ }
}
Enter fullscreen mode Exit fullscreen mode

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() { /* ... */ }
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

Before — untyped Error, discarded, fatalError on any branch:

func load() {
    do {
        let data = try client.fetch()
        items = data
    } catch {
        fatalError("couldn't load")
    }
}
Enter fullscreen mode Exit fullscreen mode

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: ""
    }
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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) { ... }
Enter fullscreen mode Exit fullscreen mode

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 */ }
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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) { ... } } }
Enter fullscreen mode Exit fullscreen mode

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))
    }
}
Enter fullscreen mode Exit fullscreen mode

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)