SwiftData Model Inheritance di iOS 26: Panduan Lengkap Subclass @Model dan Migrasi Schema

Pelajari fitur baru SwiftData di iOS 26: class inheritance, subclass @Model, query subclass dengan predicate is, dan migrasi schema dari V3 ke V4 — lengkap dengan contoh kode siap pakai.

SwiftData Inheritance iOS 26: Complete Guide

Selama dua tahun terakhir, salah satu permintaan paling sering dari developer iOS — termasuk dari saya sendiri, jujur — adalah pertanyaan yang sama: "Kenapa @Model di SwiftData nggak bisa diwariskan?" Akhirnya, di iOS 26 Apple ngabulin permintaan itu. SwiftData sekarang resmi mendukung class inheritance, dan jujur saja, ini fitur yang bikin model dengan hierarki natural jadi jauh lebih bersih dan mudah di-query.

Di panduan ini, kita bakal bedah tuntas SwiftData Model Inheritance di iOS 26. Mulai dari kenapa kamu butuh fitur ini, cara bikin subclass @Model, query berdasarkan tipe subclass dengan keyword is, sampai migrasi schema dari versi tanpa inheritance ke versi dengan inheritance — semua dengan contoh kode siap salin-tempel.

Oke, tanpa basa-basi lagi, mari kita mulai.

Apa yang Baru di SwiftData iOS 26

SwiftData di iOS 26 (Swift 6.2 + Xcode 26) bawa beberapa perubahan penting. Tapi yang paling menonjol adalah ini:

  • Class Inheritance: subclass @Model sekarang bisa diwariskan layaknya class Swift biasa.
  • Predicate dengan keyword is: kamu bisa filter hasil @Query berdasarkan tipe subclass — keren kan?
  • Persistent History dengan sortBy: history fetch sekarang bisa di-sort, sebelumnya nggak bisa (akhirnya!).
  • Migration Stage untuk inheritance: lightweight migration dari schema flat ke schema dengan subclass.

Catatan penting: fitur ini hanya tersedia di iOS 26 ke atas. Setiap subclass @Model wajib diberi anotasi @available(iOS 26, *). Jadi kalau minimum deployment target aplikasimu masih di bawah iOS 26, ya, kamu belum bisa pakai fitur ini. Sayang sekali, tapi begitulah.

Kapan Pakai Inheritance, Composition, atau Protocol?

Sebelum buru-buru pakai inheritance, pertimbangkan dulu tiga opsi ini. Saya pernah salah pilih di project lama (akhirnya harus refactor besar-besaran), jadi pilihan awal ini penting banget.

Inheritance — kalau model membentuk hierarki natural

Cocok ketika subclass memang adalah parent-nya, ditambah sedikit perilaku khusus. Contoh klasik: Trip sebagai parent, lalu BusinessTrip dan PersonalTrip sebagai subclass. Semua trip punya destination, startDate, dan endDate; subclass tinggal nambah field spesifik. Sederhana.

Composition — kalau dua model punya bagian yang sama tapi tidak sehirarki

Misal Invoice dan Order sama-sama punya Address. Invoice bukan jenis Order, jadi pakai composition: simpan Address sebagai property terpisah. Selesai.

Protocol Conformance — kalau cuma butuh interface yang sama

Kalau dua model cuma butuh kontrak yang sama (misal sama-sama bisa archive()), pakai protocol. Inheritance bikin physical schema jadi satu tabel besar dengan banyak kolom null — protocol nggak.

Aturan praktisnya: kalau kamu sering melakukan query polimorfik (mengambil semua Trip tanpa peduli subclass), pakai inheritance. Kalau hampir tidak pernah, mending pertimbangkan tabel terpisah dengan protocol.

Membuat Model Inheritance Pertama

Mari mulai dengan parent class Trip yang sederhana:

import SwiftData
import Foundation

@Model
class Trip {
    var destination: String
    var startDate: Date
    var endDate: Date

    init(destination: String, startDate: Date, endDate: Date) {
        self.destination = destination
        self.startDate = startDate
        self.endDate = endDate
    }
}

Sekarang kita tambahkan dua subclass dengan field tambahan. Perhatikan baik-baik anotasi @available — ini bukan opsional:

@available(iOS 26, macOS 26, *)
@Model
class BusinessTrip: Trip {
    var perdiem: Double = 0.0
    var clientName: String = ""

    init(destination: String,
         startDate: Date,
         endDate: Date,
         perdiem: Double = 0.0,
         clientName: String = "") {
        self.perdiem = perdiem
        self.clientName = clientName
        super.init(destination: destination,
                   startDate: startDate,
                   endDate: endDate)
    }
}

@available(iOS 26, macOS 26, *)
@Model
class PersonalTrip: Trip {
    enum Reason: String, Codable {
        case vacation, family, health, other
    }

    var reason: Reason = Reason.vacation

    init(destination: String,
         startDate: Date,
         endDate: Date,
         reason: Reason = .vacation) {
        self.reason = reason
        super.init(destination: destination,
                   startDate: startDate,
                   endDate: endDate)
    }
}

Beberapa hal yang wajib kamu perhatikan (dan saya tekankan karena saya pernah lupa salah satunya):

  • Setiap subclass harus dianotasi @available(iOS 26, *). Tanpa ini, kompilasi langsung gagal di project dengan deployment target di bawah iOS 26.
  • Property baru di subclass wajib punya default value agar lightweight migration bisa berjalan. Lupa aja sekali, dan kamu bakal ketemu crash di runtime.
  • Selalu panggil super.init(...) di akhir initializer subclass, setelah set property milik subclass.

Mendaftarkan Subclass ke ModelContainer

SwiftData perlu tahu semua tipe (parent dan subclass) yang ada di schema. Jadi, daftarkan semuanya saat membuat ModelContainer:

import SwiftUI
import SwiftData

@main
struct TripApp: App {
    var sharedModelContainer: ModelContainer = {
        let schema = Schema([
            Trip.self,
            BusinessTrip.self,
            PersonalTrip.self
        ])
        let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
        do {
            return try ModelContainer(for: schema, configurations: [config])
        } catch {
            fatalError("Gagal membuat ModelContainer: \(error)")
        }
    }()

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(sharedModelContainer)
    }
}

Query Subclass dengan Keyword is

Nah, ini bagian favorit saya. #Predicate di iOS 26 sekarang mendukung type-check dengan keyword is, persis seperti pattern matching Swift biasa. Artinya? Kamu bisa memfilter berdasarkan subclass langsung dari database — tanpa fetch dulu lalu filter di memori.

Mengambil Semua Trip (Polimorfik)

struct AllTripsView: View {
    @Query(sort: \Trip.startDate) private var trips: [Trip]

    var body: some View {
        List(trips) { trip in
            TripRow(trip: trip)
        }
    }
}

Query di atas mengembalikan semua trip — termasuk BusinessTrip dan PersonalTrip — karena semua subclass otomatis di-fetch saat parent yang di-query. Magis? Bukan, cuma SwiftData yang sudah dewasa.

Filter Hanya Subclass Tertentu

@available(iOS 26, *)
struct BusinessTripsView: View {
    @Query(
        filter: #Predicate<Trip> { trip in
            trip is BusinessTrip
        },
        sort: \Trip.startDate
    )
    private var businessTrips: [Trip]

    var body: some View {
        List(businessTrips) { trip in
            if let bt = trip as? BusinessTrip {
                VStack(alignment: .leading) {
                    Text(bt.destination).font(.headline)
                    Text("Per diem: $\(bt.perdiem, specifier: "%.2f")")
                    Text("Klien: \(bt.clientName)")
                        .foregroundStyle(.secondary)
                }
            }
        }
    }
}

Compound Predicate: Search + Subclass Filter

Kamu bisa kombinasikan filter subclass dengan filter teks biasa, persis seperti contoh di sesi WWDC25 Apple:

@available(iOS 26, *)
struct SearchableTripsView: View {
    @State private var searchText = ""

    var body: some View {
        TripsList(searchText: searchText)
            .searchable(text: $searchText)
    }
}

@available(iOS 26, *)
struct TripsList: View {
    @Query private var trips: [Trip]

    init(searchText: String) {
        let predicate = #Predicate<Trip> { trip in
            trip is PersonalTrip &&
            (searchText.isEmpty || trip.destination.localizedStandardContains(searchText))
        }
        _trips = Query(filter: predicate, sort: \Trip.startDate)
    }

    var body: some View {
        List(trips) { trip in
            TripRow(trip: trip)
        }
    }
}

Predicate digabung dengan operator &&, dan SwiftData akan menerjemahkan ini ke query SQLite yang efisien — tanpa harus fetch semua data lalu filter di memori. Ini perbedaan besar untuk dataset yang gede.

Migrasi Schema: Dari Tanpa Inheritance ke Inheritance

Aplikasi yang sudah live nggak bisa langsung pakai inheritance — kamu butuh VersionedSchema dan SchemaMigrationPlan. Berikut polanya kalau aplikasimu sebelumnya punya schema flat (V3) dan ingin pindah ke schema dengan subclass (V4).

Versi 3.0 — Schema Flat (Pre iOS 26)

enum TripSchemaV3: VersionedSchema {
    static var versionIdentifier = Schema.Version(3, 0, 0)
    static var models: [any PersistentModel.Type] { [Trip.self] }

    @Model
    class Trip {
        var destination: String
        var startDate: Date
        var endDate: Date
        var category: String   // "business" atau "personal"

        init(destination: String, startDate: Date, endDate: Date, category: String) {
            self.destination = destination
            self.startDate = startDate
            self.endDate = endDate
            self.category = category
        }
    }
}

Versi 4.0 — Schema dengan Inheritance

@available(iOS 26, *)
enum TripSchemaV4: VersionedSchema {
    static var versionIdentifier = Schema.Version(4, 0, 0)
    static var models: [any PersistentModel.Type] {
        [Trip.self, BusinessTrip.self, PersonalTrip.self]
    }

    @Model
    class Trip {
        var destination: String
        var startDate: Date
        var endDate: Date

        init(destination: String, startDate: Date, endDate: Date) {
            self.destination = destination
            self.startDate = startDate
            self.endDate = endDate
        }
    }

    @Model
    class BusinessTrip: Trip {
        var perdiem: Double = 0.0
        // ... initializer
    }

    @Model
    class PersonalTrip: Trip {
        var reason: String = "vacation"
        // ... initializer
    }
}

Migration Plan

enum TripMigrationPlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] {
        if #available(iOS 26, *) {
            return [TripSchemaV3.self, TripSchemaV4.self]
        } else {
            return [TripSchemaV3.self]
        }
    }

    static var stages: [MigrationStage] {
        if #available(iOS 26, *) {
            return [migrateV3toV4]
        } else {
            return []
        }
    }

    @available(iOS 26, *)
    static let migrateV3toV4 = MigrationStage.custom(
        fromVersion: TripSchemaV3.self,
        toVersion: TripSchemaV4.self,
        willMigrate: { context in
            // Field "category" akan di-drop; data lama tetap jadi Trip biasa.
            // Kalau mau, kamu bisa konversi manual data lama jadi subclass:
            let trips = try context.fetch(FetchDescriptor<TripSchemaV3.Trip>())
            for old in trips {
                _ = old.category
            }
            try context.save()
        },
        didMigrate: nil
    )
}

Pasang Migration Plan ke ModelContainer

let container = try ModelContainer(
    for: Trip.self, BusinessTrip.self, PersonalTrip.self,
    migrationPlan: TripMigrationPlan.self
)

Untuk perubahan sederhana — misal cuma menambahkan subclass tanpa mengubah field parent — kamu bisa pakai MigrationStage.lightweight sebagai gantinya, tanpa closure willMigrate. Lebih praktis, lebih hemat kode.

Bekerja dengan Relationship di Subclass

Subclass juga bisa punya relationship sendiri. Contoh: hanya BusinessTrip yang punya daftar Expense (karena ya, masa PersonalTrip ngitung expense tagihan klien?):

@available(iOS 26, *)
@Model
class Expense {
    var amount: Double
    var note: String
    var trip: BusinessTrip?

    init(amount: Double, note: String) {
        self.amount = amount
        self.note = note
    }
}

@available(iOS 26, *)
@Model
class BusinessTrip: Trip {
    var perdiem: Double = 0.0

    @Relationship(deleteRule: .cascade, inverse: \Expense.trip)
    var expenses: [Expense]? = []

    init(destination: String, startDate: Date, endDate: Date, perdiem: Double = 0.0) {
        self.perdiem = perdiem
        super.init(destination: destination, startDate: startDate, endDate: endDate)
    }
}

SwiftData otomatis tahu kalau hanya BusinessTrip yang punya relasi ke Expense; PersonalTrip dan Trip dasar nggak terpengaruh sama sekali.

Praktik Terbaik dan Kendala Umum

1. Selalu beri default value pada property baru

Tanpa default, lightweight migration bakal gagal — dan kamu terpaksa pakai custom migration yang jauh lebih ribet. Percayalah, hindari aja.

2. Hindari hierarki yang terlalu dalam

SwiftData menyimpan inheritance di satu tabel besar (single-table inheritance). Hierarki 3+ level bikin tabel sangat lebar dengan banyak kolom null. Maksimal 2 level cukup untuk hampir semua kasus realistis.

3. Gunakan @available di setiap titik

Bukan cuma di subclass, tapi juga di VersionedSchema, di view yang pakai subclass, dan di property yang merujuk subclass. Compiler Swift bakal kasih tahu kalau ada yang kelewat — dengarkan dia.

4. Test migration dengan data dummy

Sebelum rilis ke App Store, install versi V3 di simulator, isi data dummy, lalu update ke build dengan V4. Pastikan data lama nggak hilang dan tidak ada crash. Saya selalu wajibkan ini di tim saya, dan beberapa kali itu nyelametin kami dari bug yang nyebelin di production.

5. Hati-hati dengan #Predicate dan optional

Saat cast ke subclass di body view (trip as? BusinessTrip), pastikan kamu handle case nil. Kombinasikan dengan filter is di predicate supaya cast jarang gagal.

Bug yang Perlu Diwaspadai di iOS 26.0 / 26.1

Per Mei 2026, ada beberapa laporan bug terkait inheritance + relationship dengan deleteRule: .cascade di forum Apple Developer. Workaround sementara: jangan pasang cascade pada relasi dari subclass ke entity lain yang juga punya inheritance — pisahkan dulu jadi composition sederhana sampai bug ter-fix di iOS 26.2. Selalu update ke patch terbaru sebelum rilis, oke?

Pertanyaan yang Sering Ditanyakan (FAQ)

Apakah SwiftData inheritance bisa dipakai di iOS 18 atau iOS 17?

Tidak. Fitur ini eksklusif iOS 26 ke atas. Subclass @Model harus dianotasi @available(iOS 26, *), dan kalau deployment target aplikasi di bawah itu, kompilasi bakal gagal. Untuk multi-target, gunakan flag #available dan sediakan jalur fallback dengan composition atau enum-tagged single class.

Apa bedanya inheritance dengan composition di SwiftData?

Inheritance bikin satu tabel dengan kolom dari parent + semua subclass (kolom subclass jadi null kalau row-nya parent atau subclass lain). Composition bikin tabel terpisah dengan relasi. Pakai inheritance kalau kamu sering query polimorfik; pakai composition kalau model tidak benar-benar punya hubungan "is-a".

Bisakah saya melakukan query fetch langsung pada subclass tanpa @Query?

Bisa. Pakai FetchDescriptor<BusinessTrip> untuk hanya mengambil instance subclass tertentu, atau FetchDescriptor<Trip> dengan predicate berisi trip is BusinessTrip untuk hasil yang sama lewat parent.

Apakah CloudKit sync mendukung model inheritance?

Ya, tapi dengan catatan: semua subclass yang akan disinkronkan harus punya default value untuk semua property non-optional, dan tidak boleh ada relationship yang required. Ini sama persis dengan persyaratan CloudKit standar di SwiftData.

Bagaimana cara migrasi data lama (yang punya field category) menjadi subclass yang sesuai?

Pakai custom MigrationStage. Di closure willMigrate, fetch semua data versi lama, baca field discriminator (misal category), buat instance subclass yang sesuai, lalu hapus instance lama. Setelah migrasi selesai, schema baru tinggal pakai inheritance native.

Kesimpulan

SwiftData Model Inheritance di iOS 26 menutup salah satu gap terbesar framework ini sejak diperkenalkan di WWDC23. Dengan dukungan subclass @Model, predicate is, dan migration plan yang jelas, kamu sekarang bisa membangun arsitektur data yang bersih — tanpa harus pakai trick enum-discriminator atau tabel paralel.

Saran saya? Mulailah dengan kasus sederhana dulu — misal mengubah satu model flat jadi parent + 2 subclass. Test migrasinya secara menyeluruh, naikkan deployment target ke iOS 26 hanya kalau audiens app-mu memang sudah siap, dan nikmati query polimorfik yang sebelumnya nggak mungkin tanpa Core Data.

Selamat ngoding, dan semoga sukses dengan project SwiftData-mu!

Tentang Penulis Mei-Lin Chen

Mei-Lin joined Robinhood in 2020 as an iOS engineer on the Crypto team and stayed through the SwiftUI rewrite of the order-entry flow before leaving in 2025. She also did a two-year stint at Asana earlier in her career working on the iPad app and the Mac Catalyst port. She writes about the parts of Apple's frameworks that the WWDC talks gloss over - what Observable actually does to your view-update graph, why @Bindable bindings tear in some animation contexts, and the surprisingly deep rabbit hole of Swift macros for boilerplate elimination. She has shipped two indie apps to the App Store, one of which hit #4 in the Health & Fitness category for a week in 2023. Mei-Lin is based in Seattle and has been writing Swift for 8 years.