DEV Community

Olivia Craft
Olivia Craft

Posted on

CLAUDE.md for Swift and iOS: 13 Rules That Stop AI From Writing Unsafe, Non-Idiomatic Apple Code

Ask Claude Code to "add a screen that loads a user profile and shows their orders" and the output compiles. It also strong-captures self in three closures, blocks the main thread on a network call, mutates @State from a background actor, and leaks a coordinator. Nothing crashes in the simulator. Everything crashes in TestFlight.

Swift is hostile to AI defaults: ARC, the SwiftUI lifecycle, Combine, structured concurrency, and a decade of UIKit history coexist in the same target. Half the model's training data is Objective-C, half is pre-async/await Swift, and the rest is SwiftUI tutorials that ignore actor isolation. A CLAUDE.md next to Package.swift is the cheapest way to pull it back to current Apple practice.

Get the full CLAUDE.md Rules Pack — oliviacraftlat.gumroad.com/l/skdgt. The 13 rules below are a free preview.

1. [weak self] in every escaping closure that touches self

ARC retain cycles are the canonical iOS leak, and AI writes them constantly: a closure captured by a long-lived publisher, Task, URLSession handler, or NotificationCenter observer that strongly references self. Every @escaping closure that mentions self opens with [weak self] in and unwraps via guard let self else { return }.

viewModel.$user
    .sink { [weak self] user in
        guard let self else { return }
        self.titleLabel.text = user.name
    }
    .store(in: &cancellables)
Enter fullscreen mode Exit fullscreen mode

Non-escaping closures (map, filter, forEach) don't need it — capture is bounded by the call.

2. Force-unwrap ! and try! are banned outside tests

user.profile!.name and try! JSONDecoder().decode(...) are the top crash signature in production iOS apps. Use guard let, if let, optional chaining, ??, or do/try/catch. Reserve ! for IBOutlet and test setup where failure is the test failing.

guard let url = URL(string: endpoint) else {
    throw APIError.invalidURL(endpoint)
}
let (data, _) = try await URLSession.shared.data(from: url)
let user = try JSONDecoder().decode(User.self, from: data)
Enter fullscreen mode Exit fullscreen mode

fatalError is allowed only for genuinely impossible states, with a message explaining why.

3. async/await only — no completion handlers in new code

If the deployment target is iOS 15+, every new async API returns async throws or uses AsyncSequence. Completion-handler closures leak callbacks, scatter error handling, and don't compose with Task cancellation. Bridge legacy APIs once via withCheckedThrowingContinuation and never again.

func loadOrders(for userID: UUID) async throws -> [Order] {
    let request = try ordersRequest(userID: userID)
    let (data, response) = try await session.data(for: request)
    try OrdersAPI.validate(response)
    return try decoder.decode([Order].self, from: data)
}
Enter fullscreen mode Exit fullscreen mode

No DispatchQueue.global().async { DispatchQueue.main.async { ... } } pyramids.

4. UI mutations run on @MainActor, full stop

Updating UIKit views or @State from a background context is a Swift 6 concurrency error and a flicker bug today. Annotate view models with @MainActor so the compiler enforces it. AI defaults to DispatchQueue.main.async — which works, but skips the actor isolation guarantee and breaks under strict concurrency.

@MainActor
final class OrdersViewModel: ObservableObject {
    @Published private(set) var orders: [Order] = []

    func refresh() async {
        do { orders = try await api.loadOrders() }
        catch { /* surface to UI */ }
    }
}
Enter fullscreen mode Exit fullscreen mode

Heavy decoding or image work goes in a non-isolated helper (or Task.detached) and the result is awaited back on the main actor.

5. Pick the right SwiftUI property wrapper, once

@State, @StateObject, @ObservedObject, @Binding, @Environment are not interchangeable, but AI uses them as if they were:

  • @State — value types owned by this view.
  • @StateObject — reference-type view models created by this view.
  • @ObservedObject — view models passed in from a parent.
  • @Binding — two-way value bindings from a parent.
  • @Environment / @EnvironmentObject — dependencies injected up the tree.
struct OrdersScreen: View {
    @StateObject private var viewModel = OrdersViewModel()  // owned here

    var body: some View {
        OrdersList(viewModel: viewModel)
    }
}

struct OrdersList: View {
    @ObservedObject var viewModel: OrdersViewModel  // passed in
    var body: some View { /* ... */ }
}
Enter fullscreen mode Exit fullscreen mode

Wrong wrapper = state thrown away on parent re-render, lifecycle bugs only visible in TestFlight.

6. Shared mutable state lives in an actor

Whenever AI writes private let queue = DispatchQueue(label: "...") to synchronize access, replace it with an actor. Locks (NSLock, os_unfair_lock) are reserved for non-async hot paths or C interop.

actor ImageCache {
    private var storage: [URL: UIImage] = [:]
    func image(for url: URL) -> UIImage? { storage[url] }
    func store(_ image: UIImage, for url: URL) { storage[url] = image }
}
Enter fullscreen mode Exit fullscreen mode

Calls become await cache.image(for: url). Data races become compile errors, not Sentry alerts.

7. Errors are typed enums conforming to LocalizedError

throw NSError(domain: "...", code: -1) is AI that gave up. Stable error conditions get an enum case; errors that cross boundaries conform to LocalizedError so they're safe to surface in UI.

enum APIError: Error, LocalizedError {
    case invalidURL(String)
    case http(status: Int)
    case decoding(underlying: Error)

    var errorDescription: String? {
        switch self {
        case .invalidURL(let s): return "Invalid URL: \(s)"
        case .http(let code):    return "Server error (\(code))"
        case .decoding:          return "Couldn't read server response"
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Callers branch on cases, not string matching.

8. Protocols + struct value types — inheritance is the last resort

AI loves class hierarchies — BaseViewController, BaseViewModel, AbstractRepository. Swift prefers protocol-oriented composition and value-type models. Use class only for real reference semantics (long-lived VMs, ARC observers, UIKit subclasses).

protocol OrdersRepository {
    func loadOrders(for userID: UUID) async throws -> [Order]
}

struct LiveOrdersRepository: OrdersRepository { /* ... */ }
struct StubOrdersRepository: OrdersRepository { /* tests */ }
Enter fullscreen mode Exit fullscreen mode

Domain models (Order, User, Money) are struct with Codable/Equatable/Sendable where applicable.

9. SwiftUI views are small, dumb, and pure

A View body that scrolls past one screen is doing too much. Extract @ViewBuilder helpers, child views, and ViewModifiers; keep state and side effects in the view model. AI defaults to god-views with inline networking — that path produces non-deterministic previews and untestable UI.

struct OrderRow: View {
    let order: Order
    var body: some View {
        HStack {
            Text(order.title).font(.body)
            Spacer()
            Text(order.total.formatted(.currency(code: "USD")))
                .monospacedDigit()
        }
        .padding(.vertical, 8)
    }
}
Enter fullscreen mode Exit fullscreen mode

No network, no @StateObject, no business logic in row views. Previews work offline.

10. UIKit interop: UIViewRepresentable for one widget, not whole flows

When something has to drop to UIKit (camera, mail composer, third-party SDK), wrap that one view in UIViewRepresentable or UIViewControllerRepresentable. AI wraps entire flows and reinvents navigation inside SwiftUI — that produces double nav stacks, broken back buttons, and coordinators that outlive their views.

struct ScannerView: UIViewControllerRepresentable {
    let onResult: (String) -> Void

    func makeUIViewController(context: Context) -> ScannerViewController {
        let vc = ScannerViewController()
        vc.onResult = onResult
        return vc
    }
    func updateUIViewController(_ vc: ScannerViewController, context: Context) {}
}
Enter fullscreen mode Exit fullscreen mode

Coordinator types exist only when you need a delegate target — and they hold self weakly.

11. Dependency injection via initializer — no singletons, no Resolver

UserDefaults.standard, URLSession.shared, and homemade ServiceLocators are the AI's go-to dependencies and they make tests impossible. Inject collaborators through the initializer; default the parameter for production. Sub them out for tests and previews.

@MainActor
final class OrdersViewModel: ObservableObject {
    private let repository: OrdersRepository
    init(repository: OrdersRepository = LiveOrdersRepository()) {
        self.repository = repository
    }
}
Enter fullscreen mode Exit fullscreen mode

@Environment carries cross-cutting concerns (theme, feature flags); everything else is constructor-injected.

12. Tests cover async paths with XCTest's async APIs — no XCTestExpectation for new code

XCTestExpectation and wait(for:timeout:) are how you tested completion handlers in 2019. New tests await directly. Use XCTUnwrap instead of !, snapshot tests for view layouts, and mock at the protocol boundary — not at URLSession.

@MainActor
final class OrdersViewModelTests: XCTestCase {
    func test_refresh_populatesOrders() async throws {
        let repo = StubOrdersRepository(orders: .sample)
        let sut = OrdersViewModel(repository: repo)

        await sut.refresh()

        XCTAssertEqual(sut.orders.count, 3)
    }
}
Enter fullscreen mode Exit fullscreen mode

CI runs xcodebuild test -enableThreadSanitizer YES on a matrix including the lowest supported iOS version.

13. Build hygiene: SwiftPM, SwiftLint, strict concurrency on

Dependencies live in Package.swift, pinned to exact or up-to-next-minor versions. SwiftLint runs in a build phase with project rules. Turn on -strict-concurrency=complete before Swift 6 forces you to. AI will add CocoaPods, disable warnings, and set SWIFT_TREAT_WARNINGS_AS_ERRORS=NO "to ship" — that's how 200-warning codebases happen.

// Package.swift
.package(url: "https://github.com/apple/swift-collections", from: "1.1.0"),
Enter fullscreen mode Exit fullscreen mode
# .swiftlint.yml
opt_in_rules:
  - force_unwrapping
  - implicit_return
  - explicit_init
disabled_rules:
  - line_length
Enter fullscreen mode Exit fullscreen mode

Warnings are errors. Force-unwrap is a lint failure. Strict concurrency catches the @MainActor violation before TestFlight does.

A starter CLAUDE.md snippet

# CLAUDE.md — iOS app

## Stack
- Swift 5.10+, iOS 16+, SwiftUI primary, UIKit only via Representable
- async/await, actors, Combine for legacy publishers, SwiftPM, XCTest

## Hard rules
- `[weak self]` + `guard let self else { return }` in every escaping closure that uses self.
- No `!`, no `try!` outside tests and IBOutlets. Use `guard let` / `do try catch`.
- New async APIs are `async throws` or `AsyncSequence`. Bridge legacy via continuation once.
- All UI mutations run on `@MainActor`. Heavy work in detached tasks, await result back.
- Pick the right wrapper: `@State` value-owned, `@StateObject` view-owned VM, `@ObservedObject` injected VM.
- Shared mutable state = `actor`. Locks only for non-async hot paths.
- Errors are typed enums conforming to `LocalizedError`. No `NSError(domain:)`.
- Protocols + `struct` first. `class` only for reference semantics.
- SwiftUI views: small, pure, no network, previews must work offline.
- UIKit via `UIViewRepresentable` for one widget, not whole flows.
- DI via initializer. No singletons, no service locators.
- Tests use async/await, `XCTUnwrap`, mock at protocol boundary, ThreadSanitizer in CI.
- SwiftPM only. SwiftLint enforced. Strict concurrency on. Warnings = errors.
Enter fullscreen mode Exit fullscreen mode

What Claude gets wrong without these rules

  • Strong-captures self in Combine sinks → view models leak forever.
  • Force-unwraps decoded JSON → first malformed payload crashes the app.
  • Updates @Published from a URLSession callback → flickers, Swift 6 errors.
  • Uses @ObservedObject instead of @StateObject → state resets on parent rerender.
  • Wraps a UIKit flow in UIViewControllerRepresentable → double back buttons.
  • Reaches for URLSession.shared everywhere → repository is impossible to test.

Drop the 13 rules above into CLAUDE.md and the next AI PR looks like an iOS app, not a SwiftUI tutorial. TestFlight stops paging you.

Want this for 20+ stacks with 200+ rules ready to paste? Grab the CLAUDE.md Rules Pack at oliviacraftlat.gumroad.com/l/skdgt.

— Olivia (@OliviaCraftLat)

Top comments (0)