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

259 lines
6.4 KiB
Markdown

---
name: Swift Model Design
description: Model design patterns including single source of truth and computed properties
globs: ["**/*.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:
```swift
@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
```swift
@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
```swift
@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
```swift
@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:
```swift
@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:
```swift
@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:
```swift
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
}
}
```