Вступ
Якщо ви працювали зі SwiftData до iOS 26, то напевно стикались із цим: спробуєте створити підклас @Model — і Xcode радісно зустрічає вас крешем про конфлікт властивостей. Рекомендація від спільноти була прямолінійною: позначте моделі як final і навіть не думайте про ієрархії.
Ну що ж, на WWDC 2025 Apple нарешті це виправила.
SwiftData тепер офіційно підтримує наслідування класів, і, чесно кажучи, це суттєво змінює підхід до моделювання даних у Swift-додатках. Можна створювати базові моделі зі спільними властивостями, розширювати їх через підкласи, робити запити за типом — і все це з повною інтеграцією у SwiftUI через @Query.
У цьому посібнику ми пройдемо весь шлях: від створення ієрархії моделей до предикатів із перевіркою типів, міграції схеми через VersionedSchema та оптимізації запитів за допомогою FetchDescriptor. Усі приклади — робочий код для Xcode 26, який можна копіювати прямо в проєкт.
Коли використовувати наслідування моделей
Перш ніж кидатися створювати ієрархії класів, давайте розберемося, коли наслідування дійсно має сенс. Ключовий принцип тут — відношення «є» (is-a). Якщо один тип моделі природно розширює інший — наслідування стає корисним інструментом.
Ось конкретний приклад. Уявіть додаток для управління подіями. Усі події мають спільні речі: назву, дату, місце проведення. Але робочі події мають бюджет і список учасників, а соціальні — тему вечірки та дрес-код. Це якраз класичний випадок для наслідування:
Event— базовий клас зі спільними властивостямиWorkEvent— підклас із бізнес-специфічними полямиSocialEvent— підклас із полями для соціальних подій
Але (і це важливо) не кожна схожість між моделями означає потребу в наслідуванні. Якщо ваші «підкласи» мають принципово різні набори полів із мінімальним перетином — краще використовувати протоколи або композицію. Справа в тому, що SwiftData зберігає всі підкласи в одній таблиці SQLite (стратегія Single Table Inheritance), і занадто різнорідні підкласи створять «широку таблицю» з купою порожніх колонок. А цього ми точно не хочемо.
Створення ієрархії моделей
Отже, починаємо з базового класу. Це звичайна @Model-модель із спільними властивостями для всіх типів подій:
import SwiftData
import Foundation
@Model
class Event {
var title: String
var date: Date
var location: String
var notes: String?
init(title: String, date: Date, location: String, notes: String? = nil) {
self.title = title
self.date = date
self.location = location
self.notes = notes
}
}
Тепер додаємо підкласи. Зверніть увагу на обов'язковий атрибут @available(iOS 26, *) — без нього SwiftData просто не розпізнає наслідування, і ви знову отримаєте той самий неприємний креш:
@available(iOS 26, *)
@Model
class WorkEvent: Event {
var budget: Decimal
var attendees: [String]
var isConfidential: Bool
init(
title: String,
date: Date,
location: String,
notes: String? = nil,
budget: Decimal,
attendees: [String] = [],
isConfidential: Bool = false
) {
self.budget = budget
self.attendees = attendees
self.isConfidential = isConfidential
super.init(title: title, date: date, location: location, notes: notes)
}
}
@available(iOS 26, *)
@Model
class SocialEvent: Event {
var theme: String
var dressCode: String?
var maxGuests: Int
init(
title: String,
date: Date,
location: String,
notes: String? = nil,
theme: String,
dressCode: String? = nil,
maxGuests: Int = 50
) {
self.theme = theme
self.dressCode = dressCode
self.maxGuests = maxGuests
super.init(title: title, date: date, location: location, notes: notes)
}
}
І останній крок — оновлюємо конфігурацію ModelContainer, щоб він знав про підкласи. Без цього SwiftData просто не побачить нові типи:
import SwiftUI
@main
struct EventPlannerApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(for: [
Event.self,
WorkEvent.self,
SocialEvent.self
])
}
}
Важливий нюанс: потрібно явно перерахувати всі типи в ієрархії, включно з базовим класом. SwiftData не виводить підкласи автоматично — це легко пропустити й потім довго шукати причину крешу.
Запити до підкласів: предикати з перевіркою типів
Ось де наслідування починає по-справжньому сяяти. SwiftData підтримує оператор is у макросі #Predicate, що дозволяє створювати типобезпечні запити — і це, мабуть, одна з найкрутіших речей у цій фічі.
import SwiftUI
import SwiftData
struct EventListView: View {
@Query(sort: \Event.date) var allEvents: [Event]
var body: some View {
NavigationStack {
List(allEvents) { event in
EventRow(event: event)
}
.navigationTitle("Усі події")
}
}
}
Запит через базовий клас Event повертає всі об'єкти — і базові, і підкласи. Для загального списку це зручно. Але що робити, якщо потрібні тільки робочі події?
struct WorkEventsView: View {
@Query(
filter: #Predicate { event in
event is WorkEvent
},
sort: \Event.date
)
var workEvents: [Event]
var body: some View {
List(workEvents) { event in
if let workEvent = event as? WorkEvent {
VStack(alignment: .leading) {
Text(workEvent.title)
.font(.headline)
Text("Бюджет: \(workEvent.budget, format: .currency(code: "UAH"))")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
}
.navigationTitle("Робочі події")
}
}
Зверніть увагу: предикат працює з типом Event, але фільтрує за допомогою is WorkEvent. Після фільтрації можна безпечно привести тип через as? WorkEvent, щоб дістатися до специфічних властивостей.
Комбіновані предикати
Перевірку типу можна поєднувати з іншими умовами. Скажімо, вам потрібно знайти всі соціальні події з більше ніж 100 гостями:
@Query(
filter: #Predicate { event in
event is SocialEvent
},
sort: \Event.date
)
var socialEvents: [Event]
// Далі фільтруємо в Swift:
var largeSocialEvents: [SocialEvent] {
socialEvents
.compactMap { $0 as? SocialEvent }
.filter { $0.maxGuests > 100 }
}
На жаль, станом на iOS 26, напряму звертатися до властивостей підкласу всередині #Predicate через приведення типу поки не можна — це обмеження макросу. Фільтрацію за специфічними полями підкласу доведеться робити або на стороні Swift після отримання результатів, або через FetchDescriptor із прямим запитом до типу підкласу (про це далі).
Прямий запит до підкласу через FetchDescriptor
Якщо потрібно фільтрувати за властивостями підкласу на рівні бази даних — FetchDescriptor ваш найкращий друг:
func fetchConfidentialWorkEvents(context: ModelContext) throws -> [WorkEvent] {
var descriptor = FetchDescriptor(
predicate: #Predicate { workEvent in
workEvent.isConfidential == true
},
sortBy: [SortDescriptor(\WorkEvent.date, order: .reverse)]
)
descriptor.fetchLimit = 20
return try context.fetch(descriptor)
}
Цей підхід набагато ефективніший, тому що фільтрація відбувається на рівні SQLite, а не в пам'яті додатку.
Міграція схеми: VersionedSchema та SchemaMigrationPlan
Додавання наслідування до існуючої моделі — це зміна схеми, і вона потребує міграції. Так, навіть якщо ви просто додаєте підкласи. SwiftData використовує систему версійованих схем, і давайте пройдемося по кроках.
Крок 1: Визначте початкову версію схеми
Навіть якщо ви ще не робили жодних змін, завжди починайте з VersionedSchema. Це дасть стабільну точку відліку (і повірте, майбутній ви скажете за це подяку):
enum EventSchemaV1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] {
[Event.self]
}
@Model
class Event {
var title: String
var date: Date
var location: String
var notes: String?
init(title: String, date: Date, location: String, notes: String? = nil) {
self.title = title
self.date = date
self.location = location
self.notes = notes
}
}
}
Крок 2: Створіть нову версію з підкласами
@available(iOS 26, *)
enum EventSchemaV2: VersionedSchema {
static var versionIdentifier = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type] {
[Event.self, WorkEvent.self, SocialEvent.self]
}
@Model
class Event {
var title: String
var date: Date
var location: String
var notes: String?
init(title: String, date: Date, location: String, notes: String? = nil) {
self.title = title
self.date = date
self.location = location
self.notes = notes
}
}
@Model
class WorkEvent: Event {
var budget: Decimal
var attendees: [String]
var isConfidential: Bool
init(
title: String, date: Date, location: String,
notes: String? = nil,
budget: Decimal = 0,
attendees: [String] = [],
isConfidential: Bool = false
) {
self.budget = budget
self.attendees = attendees
self.isConfidential = isConfidential
super.init(title: title, date: date, location: location, notes: notes)
}
}
@Model
class SocialEvent: Event {
var theme: String
var dressCode: String?
var maxGuests: Int
init(
title: String, date: Date, location: String,
notes: String? = nil,
theme: String = "",
dressCode: String? = nil,
maxGuests: Int = 50
) {
self.theme = theme
self.dressCode = dressCode
self.maxGuests = maxGuests
super.init(title: title, date: date, location: location, notes: notes)
}
}
}
Зверніть увагу: масив models повинен містити всі типи ієрархії — базовий клас і кожен підклас. Пропустите один — і міграція мовчки зламається.
Крок 3: Створіть план міграції
Хороша новина: додавання підкласів — це легка (lightweight) міграція. SwiftData сам додасть нові колонки до таблиці, без жодного додаткового коду з вашого боку:
@available(iOS 26, *)
enum EventMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[EventSchemaV1.self, EventSchemaV2.self]
}
static var stages: [MigrationStage] {
[migrateV1toV2]
}
static let migrateV1toV2 = MigrationStage.lightweight(
fromVersion: EventSchemaV1.self,
toVersion: EventSchemaV2.self
)
}
Крок 4: Підключіть план міграції до контейнера
@main
struct EventPlannerApp: App {
let container: ModelContainer
init() {
do {
container = try ModelContainer(
for: Event.self, WorkEvent.self, SocialEvent.self,
migrationPlan: EventMigrationPlan.self
)
} catch {
fatalError("Не вдалося створити ModelContainer: \(error)")
}
}
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(container)
}
}
Коли потрібна кастомна міграція
Легка міграція чудово працює для додавання нових підкласів. Але якщо вам потрібно перетворити існуючі об'єкти базового класу на підкласи — тут вже знадобиться кастомна міграція з логікою в willMigrate:
static let migrateV1toV2 = MigrationStage.custom(
fromVersion: EventSchemaV1.self,
toVersion: EventSchemaV2.self,
willMigrate: { context in
// Отримуємо всі існуючі події
let descriptor = FetchDescriptor()
let existingEvents = try context.fetch(descriptor)
for event in existingEvents {
// Логіка класифікації: визначаємо тип на основі даних
if event.title.contains("[Робоча]") {
// Трансформуємо у WorkEvent
// Конкретна логіка залежить від ваших потреб
}
}
try context.save()
},
didMigrate: nil
)
На практиці кастомна міграція потрібна рідко, але коли вона потрібна — вона реально рятує.
Типовий псевдонім для актуальної версії
Щоб не писати EventSchemaV2.Event по всьому коду (бо це, м'яко кажучи, не додає читабельності), створіть типові псевдоніми:
typealias Event = EventSchemaV2.Event
typealias WorkEvent = EventSchemaV2.WorkEvent
typealias SocialEvent = EventSchemaV2.SocialEvent
Тепер у решті коду просто використовуєте Event, WorkEvent і SocialEvent без префіксу схеми. Коли з'явиться V3 — достатньо оновити лише ці три рядки.
Оптимізація продуктивності запитів
Як ми вже згадували, SwiftData зберігає всі підкласи в одній таблиці SQLite. Для більшості мобільних додатків із десятками тисяч записів це взагалі не проблема. Але якщо ваша схема росте, ось кілька прийомів, які варто тримати в голові.
Завантажуйте тільки потрібні властивості
var descriptor = FetchDescriptor()
descriptor.propertiesToFetch = [\.title, \.date, \.location]
let events = try context.fetch(descriptor)
Особливо корисно для списків, де вам не потрібні всі поля об'єкта. SwiftData завантажить лише зазначені властивості, а решту підтягне тільки якщо ви до них явно звернетесь.
Використовуйте fetchLimit для пагінації
var descriptor = FetchDescriptor(
sortBy: [SortDescriptor(\.date, order: .reverse)]
)
descriptor.fetchLimit = 25
descriptor.fetchOffset = 0 // Збільшуйте для наступних сторінок
let page = try context.fetch(descriptor)
Передзавантажуйте зв'язки
SwiftData завантажує зв'язки ліниво (lazy loading). Якщо ви знаєте, що будете використовувати певний зв'язок одразу — вкажіть його у relationshipKeyPathsForPrefetching, і SwiftData підтягне дані одним запитом:
var descriptor = FetchDescriptor()
descriptor.relationshipKeyPathsForPrefetching = [\.relatedDocuments]
let events = try context.fetch(descriptor)
Підраховуйте без завантаження
Якщо потрібна лише кількість записів — використовуйте fetchCount() замість завантаження повного масиву:
let workEventCount = try context.fetchCount(
FetchDescriptor()
)
Різниця суттєва. fetchCount() повертає число напряму з SQLite, тоді як context.fetch(descriptor).count спершу завантажує всі об'єкти в пам'ять — а потім рахує. Для великих таблиць це може бути дуже відчутно.
Практичний приклад: повний CRUD із підкласами
Завершимо повноцінним прикладом, який демонструє створення, читання, оновлення та видалення об'єктів з ієрархією наслідування. Цей код можна взяти за основу для свого проєкту:
struct EventManagerView: View {
@Environment(\.modelContext) private var context
@Query(sort: \Event.date) var events: [Event]
@State private var showingAddSheet = false
var body: some View {
NavigationStack {
List {
ForEach(events) { event in
eventRow(for: event)
}
.onDelete(perform: deleteEvents)
}
.navigationTitle("Менеджер подій")
.toolbar {
Button("Додати", systemImage: "plus") {
showingAddSheet = true
}
}
.sheet(isPresented: $showingAddSheet) {
AddEventView()
}
}
}
@ViewBuilder
private func eventRow(for event: Event) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(event.title)
.font(.headline)
Text(event.date, style: .date)
.font(.caption)
.foregroundStyle(.secondary)
if let workEvent = event as? WorkEvent {
Label(
"Бюджет: \(workEvent.budget, format: .currency(code: "UAH"))",
systemImage: "briefcase"
)
.font(.caption)
.foregroundStyle(.blue)
} else if let socialEvent = event as? SocialEvent {
Label(
"Тема: \(socialEvent.theme)",
systemImage: "party.popper"
)
.font(.caption)
.foregroundStyle(.purple)
}
}
}
private func deleteEvents(at offsets: IndexSet) {
for index in offsets {
context.delete(events[index])
}
}
}
Завдяки наслідуванню один @Query повертає всі типи подій. Приведення типів (as? WorkEvent) дозволяє відображати специфічну інформацію для кожного підкласу, а видалення через context.delete() працює однаково для всієї ієрархії. Просто і елегантно.
Часті запитання (FAQ)
Чи можна використовувати наслідування SwiftData із iOS 17 або iOS 18?
Ні, на жаль. Наслідування моделей у SwiftData доступне тільки починаючи з iOS 26. Якщо ваш мінімальний таргет — iOS 17 або 18, ця функція вам недоступна. Як альтернативу використовуйте протоколи або композицію для моделювання спільної поведінки.
Як SwiftData зберігає підкласи у базі даних?
SwiftData використовує стратегію Single Table Inheritance — усі підкласи зберігаються в одній таблиці SQLite. Колонки, специфічні для конкретного підкласу, будуть NULL для об'єктів інших типів. Для більшості мобільних додатків це не створює проблем, але варто уникати створення десятків підкласів із великою кількістю унікальних полів — інакше таблиця стане занадто «широкою».
Чи підтримує SwiftData наслідування разом із CloudKit?
Так, наслідування моделей сумісне із синхронізацією CloudKit. SwiftData обробляє синхронізацію даних підкласів так само, як і базових класів. Але є нюанс: переконайтеся, що всі пристрої користувача оновлені до iOS 26, інакше старіші версії додатку не зможуть прочитати дані підкласів.
Чи потрібно мігрувати з Core Data для використання наслідування?
Ні, міграція з Core Data не обов'язкова. Якщо ви вже на SwiftData — достатньо оновити схему та створити план міграції. А якщо ви на Core Data — наслідування там підтримувалось завжди, тож переходити заради саме цієї функції немає сенсу.
Як тестувати міграцію схеми перед релізом?
Створіть юніт-тести з ModelContainer в in-memory конфігурації та планом міграції. Заповніть контейнер даними старої версії, застосуйте міграцію і перевірте, що все коректно трансформувалось. Apple також рекомендує тестувати на реальних пристроях із бета-версіями даних — і від себе додам, що це не просто рекомендація, а необхідність.