ai-docs/assets/skills/swift-model-design/SKILL.md
Matt Bruce 88e4402d38 fix: use tool-agnostic ~/.agents/ as default install path
Copilot, Claude, Cursor, and others all read from ~/.agents/.
The npx skills CLI handles fan-out to tool-specific directories.
2026-02-11 12:13:20 -06:00

6.4 KiB

name description globs
Swift Model Design Model design patterns including single source of truth and computed properties
**/*.swift

Model Design Patterns

Computed properties should be the single source of truth for derived data.

Single Source of Truth Principle

Never store data that can be computed from other stored data. This prevents sync bugs and simplifies maintenance.

Name Fields Pattern

When a model has multiple name components, use a computed property for the display name:

@Model
final class Person {
    var prefix: String = ""      // "Dr.", "Mr.", etc.
    var firstName: String = ""
    var middleName: String = ""
    var lastName: String = ""
    var suffix: String = ""      // "Jr.", "III", etc.
    var nickname: String = ""
    
    // GOOD - Computed from individual fields
    var fullName: String {
        var parts: [String] = []
        if !prefix.isEmpty { parts.append(prefix) }
        if !firstName.isEmpty { parts.append(firstName) }
        if !middleName.isEmpty { parts.append(middleName) }
        if !lastName.isEmpty { parts.append(lastName) }
        if !suffix.isEmpty { parts.append(suffix) }
        return parts.joined(separator: " ")
    }
    
    // For display with nickname
    var displayName: String {
        if !nickname.isEmpty {
            return nickname
        }
        if !firstName.isEmpty {
            return firstName
        }
        return fullName
    }
    
    // Plain format for export (no special formatting)
    var vCardName: String {
        [firstName, middleName, lastName]
            .filter { !$0.isEmpty }
            .joined(separator: " ")
    }
    
    // BAD - Stored displayName that can get out of sync
    // var storedDisplayName: String  // Never add this
}

Benefits of Computed Properties

  • Always up to date: Changes to individual fields are immediately reflected
  • No sync bugs: No risk of stored value diverging from component fields
  • Simpler code: No need to update derived values when editing source fields
  • Less storage: No duplicate data in database

Derived State Patterns

Counts and Aggregates

@Model
final class Project {
    var name: String = ""
    @Relationship(deleteRule: .cascade)
    var tasks: [Task]? = []
    
    // GOOD - Computed counts
    var taskCount: Int {
        tasks?.count ?? 0
    }
    
    var completedTaskCount: Int {
        tasks?.filter(\.isCompleted).count ?? 0
    }
    
    var progress: Double {
        guard taskCount > 0 else { return 0 }
        return Double(completedTaskCount) / Double(taskCount)
    }
    
    var isComplete: Bool {
        taskCount > 0 && completedTaskCount == taskCount
    }
    
    // BAD - Stored counts that need manual updates
    // var storedTaskCount: Int = 0
    // var storedCompletedCount: Int = 0
}

Status and State

@Model
final class Order {
    var createdAt: Date = Date()
    var paidAt: Date?
    var shippedAt: Date?
    var deliveredAt: Date?
    var cancelledAt: Date?
    
    // GOOD - Computed status
    var status: OrderStatus {
        if cancelledAt != nil { return .cancelled }
        if deliveredAt != nil { return .delivered }
        if shippedAt != nil { return .shipped }
        if paidAt != nil { return .paid }
        return .pending
    }
    
    var isActive: Bool {
        cancelledAt == nil && deliveredAt == nil
    }
    
    var canCancel: Bool {
        shippedAt == nil && cancelledAt == nil
    }
}

enum OrderStatus: String, Codable {
    case pending, paid, shipped, delivered, cancelled
}

Validation State

@Model
final class UserProfile {
    var email: String = ""
    var phone: String = ""
    var firstName: String = ""
    var lastName: String = ""
    
    // GOOD - Computed validation
    var isEmailValid: Bool {
        email.contains("@") && email.contains(".")
    }
    
    var isPhoneValid: Bool {
        let digits = phone.filter(\.isNumber)
        return digits.count >= 10
    }
    
    var isComplete: Bool {
        !firstName.isEmpty && !lastName.isEmpty && isEmailValid
    }
    
    var validationErrors: [String] {
        var errors: [String] = []
        if firstName.isEmpty { errors.append("First name is required") }
        if lastName.isEmpty { errors.append("Last name is required") }
        if !isEmailValid { errors.append("Valid email is required") }
        return errors
    }
}

When to Store vs Compute

Store When:

  • Data comes from an external source (API, user input)
  • Computation is expensive and value is accessed frequently
  • Historical accuracy matters (price at time of purchase)
  • You need to query/filter by the value in database

Compute When:

  • Value is derived from other stored properties
  • Value can change when source properties change
  • Keeping values in sync would be error-prone
  • Computation is fast (string concatenation, simple math)

Caching Expensive Computations

For expensive computed values accessed frequently:

@Observable
@MainActor
final class AnalyticsStore {
    private var items: [Item] = []
    
    // Cache invalidation tracking
    private var itemsVersion = 0
    private var cachedStatsVersion = -1
    private var cachedStats: Statistics?
    
    var statistics: Statistics {
        // Return cached if valid
        if cachedStatsVersion == itemsVersion, let cached = cachedStats {
            return cached
        }
        
        // Compute and cache
        let stats = computeStatistics()
        cachedStats = stats
        cachedStatsVersion = itemsVersion
        return stats
    }
    
    func updateItems(_ newItems: [Item]) {
        items = newItems
        itemsVersion += 1  // Invalidate cache
    }
    
    private func computeStatistics() -> Statistics {
        // Expensive computation
    }
}

Identifiable and Hashable

Implement Identifiable for use with SwiftUI lists:

@Model
final class Item {
    var id: UUID = UUID()  // Or use @Attribute(.unique) if not using CloudKit
    var name: String = ""
}

// SwiftData models are automatically Identifiable if they have an 'id' property

For value types used in Sets or as Dictionary keys:

struct Tag: Hashable, Codable {
    let id: UUID
    let name: String
    
    // Hashable based on id only
    func hash(into hasher: inout Hasher) {
        hasher.combine(id)
    }
    
    static func == (lhs: Tag, rhs: Tag) -> Bool {
        lhs.id == rhs.id
    }
}