Cursor Rules for Swift: 6 Rules That Make AI Write Clean, Modern Swift
If you use Cursor or Claude Code for Swift development, you've watched the AI generate code that compiles but feels like it was written in 2016. Force-unwrapped optionals everywhere. Completion handlers instead of async/await. Massive view controllers with business logic mixed into viewDidLoad. Stringly-typed APIs where enums belong.
The fix isn't better prompting. It's better rules.
Here are 6 cursor rules for Swift that make your AI assistant write code that's modern, safe, and follows the patterns senior iOS engineers actually use. Each one includes a before/after example so you can see exactly what changes.
1. Enforce async/await — Ban Completion Handlers for New Code
Without this rule, AI generates pyramid-of-doom callback chains. Error handling is scattered across closures, cancellation is manual, and every function needs a completion: @escaping (Result<T, Error>) -> Void parameter.
The rule:
Use async/await for all asynchronous operations. Never write new code
with completion handlers. Use async let for concurrent tasks.
Use TaskGroup for dynamic concurrency. Mark throwing async functions
with async throws, not Result types in callbacks.
Bad — callback pyramid:
func loadUserProfile(id: String, completion: @escaping (Result<Profile, Error>) -> Void) {
networkService.fetchUser(id: id) { result in
switch result {
case .success(let user):
self.networkService.fetchAvatar(url: user.avatarURL) { avatarResult in
switch avatarResult {
case .success(let image):
completion(.success(Profile(user: user, avatar: image)))
case .failure(let error):
completion(.failure(error))
}
}
case .failure(let error):
completion(.failure(error))
}
}
}
Good — async/await:
func loadUserProfile(id: String) async throws -> Profile {
let user = try await networkService.fetchUser(id: id)
let avatar = try await networkService.fetchAvatar(url: user.avatarURL)
return Profile(user: user, avatar: avatar)
}
// Concurrent loading with async let
func loadDashboard(userId: String) async throws -> Dashboard {
async let profile = loadUserProfile(id: userId)
async let notifications = notificationService.fetchRecent(userId: userId)
async let settings = settingsService.fetch(userId: userId)
return try await Dashboard(profile: profile, notifications: notifications, settings: settings)
}
Linear code. One try/catch handles all errors. async let runs tasks concurrently. Cancellation propagates automatically through structured concurrency.
2. Enforce Value Types — Ban Classes for Data Models
AI defaults to class for everything because that's what most training data uses. But Swift's value types (struct, enum) give you automatic thread safety, copy semantics, and no reference-counting overhead.
The rule:
Use structs for data models, not classes. Use classes only when you need
reference semantics (identity, inheritance, or deinit). All model structs
must conform to Codable. Use let for properties that don't change after init.
Bad — class model with mutable state:
class User: NSObject {
var name: String
var email: String
var isActive: Bool
init(name: String, email: String, isActive: Bool) {
self.name = name
self.email = email
self.isActive = isActive
}
}
// Shared reference — mutation in one place affects all holders
let user = User(name: "Alice", email: "alice@example.com", isActive: true)
let copy = user
copy.isActive = false // user.isActive is now also false!
Good — value type with Codable:
struct User: Codable, Identifiable {
let id: UUID
let name: String
let email: String
var isActive: Bool
}
// Value semantics — each copy is independent
var user = User(id: UUID(), name: "Alice", email: "alice@example.com", isActive: true)
var copy = user
copy.isActive = false // user.isActive is still true
No surprise mutations. Thread-safe by default. Codable gives you JSON encoding/decoding for free. Identifiable integrates with SwiftUI lists.
3. Enforce Guard Clauses — Ban Nested Optional Unwrapping
Without this rule, AI nests if let statements four levels deep. Each optional adds another indentation level until the actual logic is buried inside a pyramid of braces.
The rule:
Use guard let for early returns when unwrapping optionals.
Reserve if let only for short, non-returning branches.
Never force-unwrap (!) optionals. Use guard with meaningful error
messages or return values. Keep the happy path at the left margin.
Bad — nested optional pyramid:
func processOrder(orderId: String?) {
if let orderId = orderId {
if let order = orderRepository.find(id: orderId) {
if let user = userRepository.find(id: order.userId) {
if let paymentMethod = user.defaultPaymentMethod {
paymentService.charge(paymentMethod, amount: order.total)
} else {
print("No payment method")
}
} else {
print("User not found")
}
} else {
print("Order not found")
}
}
}
Good — guard clauses, flat structure:
func processOrder(orderId: String?) throws {
guard let orderId else {
throw OrderError.missingOrderId
}
guard let order = orderRepository.find(id: orderId) else {
throw OrderError.notFound(orderId)
}
guard let user = userRepository.find(id: order.userId) else {
throw OrderError.userNotFound(order.userId)
}
guard let paymentMethod = user.defaultPaymentMethod else {
throw OrderError.noPaymentMethod(user.id)
}
try paymentService.charge(paymentMethod, amount: order.total)
}
The happy path reads top to bottom at the left margin. Each failure has a specific error. No nesting. The function's logic is immediately clear.
4. Enforce Enums for State — Ban Stringly-Typed APIs
AI uses raw strings for state, status codes, notification names, and configuration keys. One typo and your app silently fails. The compiler can't help you when everything is a String.
The rule:
Use enums with associated values for state machines and domain types.
Never use raw strings for states, categories, or type discriminators.
Use CaseIterable when you need to iterate all cases.
Use enums with String raw values only for API/JSON interop.
Bad — strings for state:
class OrderViewModel {
var status: String = "pending"
func updateStatus() {
if status == "pending" {
status = "processing"
} else if status == "procesing" { // typo — silent bug
status = "shipped"
}
}
func displayColor() -> Color {
switch status {
case "pending": return .orange
case "processing": return .blue
case "shipped": return .green
default: return .gray // what states hit this?
}
}
}
Good — enum with associated values:
enum OrderStatus: Codable {
case pending
case processing(startedAt: Date)
case shipped(trackingNumber: String)
case delivered(deliveredAt: Date)
case cancelled(reason: String)
}
struct OrderViewModel {
let status: OrderStatus
var displayColor: Color {
switch status {
case .pending: .orange
case .processing: .blue
case .shipped: .green
case .delivered: .green
case .cancelled: .red
// No default — adding a case forces you to handle it everywhere
}
}
}
The compiler catches typos. Adding a new status case produces errors at every switch that doesn't handle it. Associated values carry context without extra properties.
5. Enforce Protocol-Oriented Design — Ban Concrete Type Dependencies
Without this rule, AI creates tight coupling between types. Your view model directly instantiates the network layer. Your tests require a real API server. Swapping implementations means rewriting call sites.
The rule:
Define protocols for service boundaries (networking, persistence, analytics).
Inject dependencies as protocol types, not concrete classes.
Use protocol extensions for default implementations.
Keep protocols small — prefer multiple focused protocols over one large one.
Bad — concrete dependencies:
class ProfileViewModel: ObservableObject {
@Published var user: User?
func loadProfile() {
let service = APIService() // hard-coded, untestable
Task {
self.user = try? await service.fetchUser()
}
}
}
Good — protocol-based injection:
protocol UserFetching {
func fetchUser() async throws -> User
}
@MainActor
class ProfileViewModel: ObservableObject {
@Published var user: User?
@Published var error: Error?
private let userFetcher: UserFetching
init(userFetcher: UserFetching) {
self.userFetcher = userFetcher
}
func loadProfile() {
Task {
do {
user = try await userFetcher.fetchUser()
} catch {
self.error = error
}
}
}
}
// Test with a mock
struct MockUserFetcher: UserFetching {
var result: Result<User, Error>
func fetchUser() async throws -> User { try result.get() }
}
Tests pass in milliseconds with mock data. Swapping from REST to GraphQL means writing one new conformance. The view model doesn't know or care where the data comes from.
6. Enforce SwiftUI Patterns — Ban UIKit Habits in SwiftUI
AI mixes UIKit patterns into SwiftUI code. It creates @ObservedObject where @State belongs, puts navigation logic in view models, uses onAppear for initialization that should be in .task, and wraps everything in AnyView.
The rule:
Use @State for view-local state. Use @StateObject for view-owned objects.
Use @ObservedObject only for injected objects the view does not own.
Use .task instead of .onAppear for async work. Never use AnyView —
use @ViewBuilder or conditional modifiers. Use NavigationStack, not
NavigationView.
Bad — UIKit patterns in SwiftUI:
struct ProfileView: View {
@ObservedObject var viewModel = ProfileViewModel() // wrong ownership
var body: some View {
NavigationView { // deprecated
VStack {
if viewModel.isLoading {
AnyView(ProgressView()) // type erasure
} else {
AnyView(Text(viewModel.user?.name ?? ""))
}
}
.onAppear {
Task { await viewModel.load() } // manual Task in onAppear
}
}
}
}
Good — proper SwiftUI patterns:
struct ProfileView: View {
@StateObject private var viewModel = ProfileViewModel()
var body: some View {
NavigationStack {
Group {
if viewModel.isLoading {
ProgressView()
} else if let user = viewModel.user {
ProfileContent(user: user)
}
}
.task {
await viewModel.load()
}
}
}
}
@StateObject gives the view model the correct lifecycle. .task automatically cancels when the view disappears. No AnyView type erasure. NavigationStack is the modern navigation API.
Put These Rules to Work
These 6 rules cover the patterns where AI coding assistants fail most often in Swift projects. Add them to your .cursorrules or CLAUDE.md and the difference is immediate — fewer review comments, idiomatic code from the first generation, and less time rewriting AI output.
I've packaged these rules (plus 44 more covering SwiftUI, Combine, testing, and Core Data patterns) into a ready-to-use rules pack: Cursor Rules Pack v2
Drop it into your project directory and stop fighting your AI assistant.










