SwiftData Model Inheritance in iOS 26: Subklassen, Queries en Migratie

SwiftData in iOS 26 ondersteunt eindelijk echte class inheritance. Leer hoe je model-hiërarchieën opzet, queries optimaliseert met #Index en #Unique, en schema-migraties uitvoert — met werkende codevoorbeelden.

Inleiding: Eindelijk Echte Inheritance in SwiftData

Als je ooit geprobeerd hebt om model inheritance te gebruiken in SwiftData vóór iOS 26, dan weet je precies waar ik het over heb. Je plaatste het @Model-macro op een subklasse, en bam — een muur aan compilerfouten. "Redundant conformance of Subclass to protocol PersistentModel." Klaar, einde verhaal.

Jarenlang was dit één van de meest gevraagde features op de Apple Developer Forums. Ontwikkelaars die vanuit Core Data kwamen — waar inheritance al járen werd ondersteund — liepen steeds weer tegen dezelfde muur aan. De workarounds waren, eerlijk gezegd, nogal lelijk: protocollen in plaats van basisklassen, extra properties om types te onderscheiden, of simpelweg al je velden kopiëren naar elke modelklasse. Niet bepaald elegant.

Maar met iOS 26 en WWDC 2025 is dat verleden tijd.

Apple heeft class inheritance officieel toegevoegd aan SwiftData. En het mooie eraan? Het is verrassend eenvoudig in gebruik — dankzij de kracht van Swift Macros hoef je bijna niets extra's te doen. Ik was eerlijk gezegd aangenaam verrast toen ik het voor het eerst uitprobeerde.

In deze handleiding lopen we stap voor stap door alles wat je moet weten: van het opzetten van je eerste model-hiërarchie tot geavanceerde queries op subklassen, van schema-migratie tot prestatie-optimalisatie. Inclusief werkende codevoorbeelden die je meteen kunt gebruiken in je eigen projecten.

Wat Is Model Inheritance in SwiftData?

Oké, even de basis. Model inheritance stelt je in staat om een basisklasse te definiëren met gedeelde eigenschappen, en vervolgens subklassen te maken die extra functionaliteit toevoegen. Het is het "is-a"-principe uit objectgeoriënteerd programmeren, maar dan toegepast op je datalaag.

Stel je voor dat je een reis-app bouwt. Elke reis heeft een bestemming, een startdatum en een einddatum. Maar een zakenreis heeft ook een dagvergoeding, terwijl een persoonlijke reis een reden heeft (vakantie, familiebezoek, dat soort dingen). Zonder inheritance zou je deze gedeelde properties in elke klasse moeten kopiëren. Met inheritance definieer je ze één keer in de basisklasse. Simpel.

Hoe werkte het vóór iOS 26?

Vóór iOS 26 waren er twee gangbare workarounds:

  • Protocollen: Je definieerde een protocol met de gedeelde properties en liet elke modelklasse daaraan conformeren. Nadeel: geen echte data-inheritance, en véél boilerplate.
  • Type-discriminator: Je gebruikte één enkele klasse met een type-property (bijvoorbeeld een enum) om onderscheid te maken. Nadeel: optionele properties voor type-specifieke data, onoverzichtelijke code.

Beide benaderingen werkten — technisch gezien. Maar ze voelden geforceerd en waren foutgevoelig. Met echte class inheritance in iOS 26 verdwijnt die complexiteit gelukkig.

Je Eerste Model-Hiërarchie Opzetten

Genoeg theorie, laten we aan de slag gaan. We bouwen een evenementen-app waarin we verschillende types evenementen willen bijhouden.

Stap 1: De basisklasse definiëren

We beginnen met de basisklasse Evenement met de properties die elk evenement deelt:

import SwiftData
import Foundation

@Model
class Evenement {
    var titel: String
    var datum: Date
    var locatie: String
    var notities: String?

    init(titel: String, datum: Date, locatie: String, notities: String? = nil) {
        self.titel = titel
        self.datum = datum
        self.locatie = locatie
        self.notities = notities
    }
}

Tot zover niets nieuws — dit is een standaard SwiftData-model dat je ook in eerdere versies zou schrijven.

Stap 2: Subklassen toevoegen

Nu wordt het interessant. We voegen twee subklassen toe: WerkEvenement en SociaalEvenement. Let goed op de @available-markering — die is essentieel en wordt makkelijk vergeten:

@available(iOS 26, *)
@Model
class WerkEvenement: Evenement {
    var klantNaam: String
    var projectCode: String
    var isDeclarabel: Bool

    init(titel: String, datum: Date, locatie: String,
         klantNaam: String, projectCode: String,
         isDeclarabel: Bool = true, notities: String? = nil) {
        self.klantNaam = klantNaam
        self.projectCode = projectCode
        self.isDeclarabel = isDeclarabel
        super.init(titel: titel, datum: datum,
                   locatie: locatie, notities: notities)
    }
}

@available(iOS 26, *)
@Model
class SociaalEvenement: Evenement {
    var aantalGasten: Int
    var dresscode: String?

    init(titel: String, datum: Date, locatie: String,
         aantalGasten: Int, dresscode: String? = nil,
         notities: String? = nil) {
        self.aantalGasten = aantalGasten
        self.dresscode = dresscode
        super.init(titel: titel, datum: datum,
                   locatie: locatie, notities: notities)
    }
}

Elke subklasse gebruikt het @Model-macro en erft van Evenement. De @available(iOS 26, *)-markering is verplicht — zonder krijg je compilerfouten. De subklassen krijgen automatisch alle properties van de ouderklasse (titel, datum, locatie, notities) en voegen hun eigen specifieke properties toe. Best netjes, toch?

Stap 3: De ModelContainer configureren

Het laatste puzzelstukje — en dit is waar veel mensen de mist in gaan: je moet de subklassen expliciet registreren in je ModelContainer:

import SwiftUI
import SwiftData

@main
struct EvenementenApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: [
            Evenement.self,
            WerkEvenement.self,
            SociaalEvenement.self
        ])
    }
}

Vergeet je de subklassen te registreren? Dan worden ze niet herkend door SwiftData en krijg je runtime-fouten. Ik heb hier zelf ook een keer een half uur naar zitten staren voordat het kwartje viel.

Queries met Subklassen: Filteren op Type

Dit is waar het pas echt leuk wordt. Een van de krachtigste aspecten van model inheritance in SwiftData is de mogelijkheid om te filteren op subklasse-type. Hiervoor gebruik je het is-keyword in je #Predicate.

Alle evenementen ophalen

Wanneer je een query uitvoert op de basisklasse, krijg je automatisch alle instanties terug — inclusief subklassen:

struct AlleEvenementenView: View {
    @Query(sort: \Evenement.datum)
    private var evenementen: [Evenement]

    var body: some View {
        List(evenementen) { evenement in
            VStack(alignment: .leading) {
                Text(evenement.titel)
                    .font(.headline)
                Text(evenement.locatie)
                    .font(.subheadline)
                    .foregroundStyle(.secondary)
            }
        }
        .navigationTitle("Alle Evenementen")
    }
}

Dit geeft gewone evenementen, werkevenementen én sociale evenementen terug. SwiftData handelt de polymorfie automatisch af — je hoeft er niet over na te denken.

Filteren op een specifiek subtype

Wil je alleen de werkevenementen zien? Gebruik het is-keyword in een #Predicate:

struct WerkEvenementenView: View {
    @Query(
        filter: #Predicate { $0 is WerkEvenement },
        sort: \Evenement.datum
    )
    private var werkEvenementen: [Evenement]

    var body: some View {
        List(werkEvenementen) { evenement in
            if let werk = evenement as? WerkEvenement {
                VStack(alignment: .leading) {
                    Text(werk.titel)
                        .font(.headline)
                    Text(werk.klantNaam)
                        .font(.subheadline)
                    Text(werk.projectCode)
                        .font(.caption)
                        .foregroundStyle(.secondary)
                }
            }
        }
        .navigationTitle("Werkevenementen")
    }
}

Een belangrijk detail: het is-keyword werkt op database-niveau. De filtering is dus efficiënt — er worden alleen de relevante rijen opgehaald, niet alle evenementen die vervolgens in-memory worden gefilterd. Dat scheelt behoorlijk bij grotere datasets.

Dynamisch filteren met een segmented control

In de praktijk wil je gebruikers vaak laten kiezen welk type ze willen zien. Hier is een compleet voorbeeld met een Picker dat je als basis kunt gebruiken:

enum EvenementFilter: String, CaseIterable {
    case alle = "Alle"
    case werk = "Werk"
    case sociaal = "Sociaal"

    var predicate: Predicate? {
        switch self {
        case .alle:
            return nil
        case .werk:
            return #Predicate { $0 is WerkEvenement }
        case .sociaal:
            return #Predicate { $0 is SociaalEvenement }
        }
    }
}

struct GefilterdeEvenementenView: View {
    @State private var filter: EvenementFilter = .alle

    var body: some View {
        NavigationStack {
            VStack {
                Picker("Filter", selection: $filter) {
                    ForEach(EvenementFilter.allCases, id: \.self) {
                        Text($0.rawValue)
                    }
                }
                .pickerStyle(.segmented)
                .padding()

                EvenementenLijst(filter: filter)
            }
            .navigationTitle("Evenementen")
        }
    }
}

struct EvenementenLijst: View {
    let filter: EvenementFilter

    @Query private var evenementen: [Evenement]

    init(filter: EvenementFilter) {
        self.filter = filter
        if let predicate = filter.predicate {
            _evenementen = Query(
                filter: predicate,
                sort: \Evenement.datum
            )
        } else {
            _evenementen = Query(sort: \Evenement.datum)
        }
    }

    var body: some View {
        List(evenementen) { evenement in
            EvenementRij(evenement: evenement)
        }
    }
}

Dit patroon — een enum voor filteropties gecombineerd met dynamische @Query-initialisatie — is herbruikbaar voor elke app die met model inheritance werkt. Ik zou aanraden om dit als een soort template te bewaren.

Query-optimalisatie met #Index en #Unique

Bij grotere datasets wil je je queries zo snel mogelijk maken. SwiftData biedt twee handige macro's die daarbij helpen: #Index en #Unique. Deze zijn beschikbaar sinds iOS 18 en werken prima samen met inheritance in iOS 26.

De #Index macro

De #Index-macro vertelt SwiftData dat het een geoptimaliseerde binaire index moet aanmaken voor specifieke properties. In plaats van lineair door alle rijen te zoeken, kan de database een binaire zoekopdracht uitvoeren. Bij grote tabellen maakt dat een wereld van verschil:

@Model
class Evenement {
    #Index([\.datum])
    #Index([\.titel])

    var titel: String
    var datum: Date
    var locatie: String
    var notities: String?

    init(titel: String, datum: Date, locatie: String, notities: String? = nil) {
        self.titel = titel
        self.datum = datum
        self.locatie = locatie
        self.notities = notities
    }
}

Je kunt ook samengestelde indices maken voor queries die meerdere properties combineren:

#Index([\.datum, \.locatie])

Vooral nuttig als je vaak filtert op zowel datum als locatie tegelijk.

De #Unique macro

Met #Unique garandeer je dat bepaalde combinaties van properties uniek zijn in je database. Bij een conflict voert SwiftData automatisch een upsert uit — het bestaande record wordt bijgewerkt in plaats van dat er een duplicaat ontstaat:

@available(iOS 26, *)
@Model
class WerkEvenement: Evenement {
    #Unique([\.projectCode, \.datum])

    var klantNaam: String
    var projectCode: String
    var isDeclarabel: Bool

    // init...
}

In dit voorbeeld kan er per projectcode per datum maximaal één werkevenement bestaan. Probeer je een duplicaat in te voegen? Dan wordt het bestaande record gewoon bijgewerkt. Heel handig om data-integriteit te waarborgen zonder zelf checks te schrijven.

Schema-migratie: Van Flat Modellen naar Inheritance

Oké, dit is het deel waar het even serieus wordt. Als je een bestaande app hebt met een plat datamodel (zonder inheritance), moet je een migratie uitvoeren wanneer je overschakelt naar inheritance. SwiftData biedt hiervoor het VersionedSchema-systeem.

Stap 1: Je huidige schema vastleggen

Definieer eerst een versioned schema voor je huidige modelstructuur:

enum EvenementSchemaV1: VersionedSchema {
    static var versionIdentifier = Schema.Version(1, 0, 0)
    static var models: [any PersistentModel.Type] {
        [Evenement.self]
    }

    @Model
    class Evenement {
        var titel: String
        var datum: Date
        var locatie: String
        var notities: String?
        var type: String // "werk", "sociaal", of "algemeen"
        var klantNaam: String?
        var projectCode: String?
        var aantalGasten: Int?

        init(titel: String, datum: Date, locatie: String,
             type: String = "algemeen", notities: String? = nil) {
            self.titel = titel
            self.datum = datum
            self.locatie = locatie
            self.type = type
            self.notities = notities
        }
    }
}

Stap 2: Het nieuwe schema met inheritance

Vervolgens definieer je het nieuwe schema met de inheritance-structuur:

@available(iOS 26, *)
enum EvenementSchemaV2: VersionedSchema {
    static var versionIdentifier = Schema.Version(2, 0, 0)
    static var models: [any PersistentModel.Type] {
        [Evenement.self, WerkEvenement.self, SociaalEvenement.self]
    }

    @Model
    class Evenement {
        var titel: String
        var datum: Date
        var locatie: String
        var notities: String?

        init(titel: String, datum: Date, locatie: String,
             notities: String? = nil) {
            self.titel = titel
            self.datum = datum
            self.locatie = locatie
            self.notities = notities
        }
    }

    @Model
    class WerkEvenement: Evenement {
        var klantNaam: String
        var projectCode: String
        var isDeclarabel: Bool

        init(titel: String, datum: Date, locatie: String,
             klantNaam: String, projectCode: String,
             isDeclarabel: Bool = true, notities: String? = nil) {
            self.klantNaam = klantNaam
            self.projectCode = projectCode
            self.isDeclarabel = isDeclarabel
            super.init(titel: titel, datum: datum,
                       locatie: locatie, notities: notities)
        }
    }

    @Model
    class SociaalEvenement: Evenement {
        var aantalGasten: Int
        var dresscode: String?

        init(titel: String, datum: Date, locatie: String,
             aantalGasten: Int, dresscode: String? = nil,
             notities: String? = nil) {
            self.aantalGasten = aantalGasten
            self.dresscode = dresscode
            super.init(titel: titel, datum: datum,
                       locatie: locatie, notities: notities)
        }
    }
}

Belangrijk: neem alle subklassen op in de models-array. Vergeet je er eentje, dan herkent SwiftData het type niet en gaat je migratie fout. Dat is niet het soort bug dat je in productie wilt ontdekken.

Stap 3: Het migratieplan

Nu definieer je het migratieplan dat SwiftData vertelt hoe het van V1 naar V2 moet migreren:

@available(iOS 26, *)
enum EvenementMigratiePlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] {
        [EvenementSchemaV1.self, EvenementSchemaV2.self]
    }

    static var stages: [MigrationStage] {
        [migreerV1NaarV2]
    }

    static let migreerV1NaarV2 = MigrationStage.custom(
        fromVersion: EvenementSchemaV1.self,
        toVersion: EvenementSchemaV2.self,
        willMigrate: nil,
        didMigrate: { context in
            // Bestaande evenementen converteren naar het juiste subtype
            let alleEvenementen = try context.fetch(
                FetchDescriptor()
            )

            for evenement in alleEvenementen {
                // Hier kun je logica toevoegen om bestaande
                // records te classificeren op basis van hun
                // oorspronkelijke 'type'-veld
            }

            try context.save()
        }
    )
}

Stap 4: De container configureren met het migratieplan

Tot slot pas je je ModelContainer aan om het migratieplan te gebruiken:

@main
struct EvenementenApp: App {
    let container: ModelContainer

    init() {
        do {
            container = try ModelContainer(
                for: Evenement.self,
                     WerkEvenement.self,
                     SociaalEvenement.self,
                migrationPlan: EvenementMigratiePlan.self
            )
        } catch {
            fatalError("Kan ModelContainer niet initialiseren: \(error)")
        }
    }

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

SwiftData handelt de rest af. Het detecteert de huidige schemaversie, zoekt het juiste migratiepad, en voert de migratie uit — inclusief eventuele tussenstappen als de gebruiker meerdere versies heeft overgeslagen. Dat stukje magic is echt goed geregeld door Apple.

Lightweight vs. custom migratie

Niet elke migratie heeft custom code nodig. SwiftData kan veel voorkomende wijzigingen automatisch afhandelen via een lightweight migratie:

  • Properties toevoegen, hernoemen of verwijderen
  • Relaties wijzigen
  • Subklassen toevoegen aan een bestaande hiërarchie

Het toevoegen van inheritance aan een bestaand model kan in veel gevallen als lightweight migratie worden uitgevoerd. Apple's eigen SampleTrips-app laat dit mooi zien: de migratie van schema V3 (zonder inheritance) naar V4 (met inheritance) is een lightweight stap — geen custom code nodig.

Je hebt alleen een custom migratie nodig als je bestaande data wilt transformeren. Denk aan het converteren van records uit een platte tabel naar specifieke subklassen op basis van bepaalde criteria.

Prestatie-overwegingen: Single Table Inheritance

Er is één technisch detail dat je echt moet begrijpen voordat je model inheritance overal gaat inzetten: SwiftData gebruikt Single Table Inheritance (STI). Klinkt misschien wat abstract, dus laat me het even uitleggen.

Wat houdt STI in?

Bij Single Table Inheritance worden de basisklasse en al haar subklassen opgeslagen in één enkele SQLite-tabel. De tabel bevat kolommen voor alle properties van alle subklassen. Als een WerkEvenement properties heeft die een SociaalEvenement niet heeft, staan die kolommen er toch — ze zijn simpelweg NULL voor sociale evenementen.

De voordelen:

  • Eenvoudige queries: Eén query op één tabel haalt alle types op
  • Polymorfisme: Je kunt gemakkelijk van basisklasse naar subklasse casten
  • Snelle joins: Geen complexe tabel-joins nodig

De nadelen:

  • Sparse kolommen: Veel NULL-waarden als subklassen sterk verschillen
  • Grotere indices: De index dekt alle rijen, ook de irrelevante types
  • Tabelgroei: Bij veel subklassen met veel unieke properties kan de tabel behoorlijk breed worden

Wanneer wordt inheritance een probleem?

In de meeste gevallen is STI prima. Maar wees voorzichtig wanneer:

  • Je meer dan 5-10 subklassen hebt met elk veel unieke properties
  • Je tienduizenden records per subklasse hebt en ze sterk van elkaar verschillen
  • Je CloudKit-synchronisatie gebruikt — er zijn bekende beperkingen met inheritance en CloudKit (meer hierover in de FAQ)

Een handige vuistregel: als je subklassen meer dan 70% van hun properties delen, is inheritance een goede keuze. Delen ze nauwelijks iets? Overweeg dan afzonderlijke modellen met relaties. Dat is geen falen — dat is gewoon het juiste gereedschap kiezen voor de klus.

Best Practices voor SwiftData Inheritance

Na het doorwerken van alle technische details, hier de belangrijkste richtlijnen samengevat:

  1. Begin altijd met een versioned schema. Ook als je nu nog geen migraties nodig hebt. Het kost vrijwel niets extra en bespaart je hoofdpijn in de toekomst.
  2. Gebruik inheritance alleen bij echte "is-a"-relaties. Een Hond is een Dier — prima. Een Bestelling is geen Klant — gebruik dan een relatie.
  3. Vergeet de @available-markering niet. Zonder @available(iOS 26, *) compileert je subklasse niet als SwiftData-model. Dit is echt een klassieker.
  4. Registreer alle subklassen in je ModelContainer. De meest voorkomende fout bij beginners, en ook de meest frustrerende om te debuggen.
  5. Gebruik #Index voor veelgebruikte query-properties. Zeker bij grotere datasets maakt dit een merkbaar verschil in snelheid.
  6. Test je migraties grondig. Maak een kopie van je productiedatabase en test de migratie voordat je een update uitbrengt. Serieus — doe dit.
  7. Houd je hiërarchie ondiep. Eén of twee niveaus diep is ideaal. Diepere hiërarchieën maken je code complexer en je database minder efficiënt.

Veelgestelde Vragen

Werkt SwiftData model inheritance ook op macOS en andere Apple-platforms?

Jazeker. Model inheritance is beschikbaar op alle platforms die iOS 26 ondersteunen: iPadOS 26, macOS Tahoe, watchOS 26, tvOS 26 en visionOS 26. De API is identiek op elk platform, dus je hoeft je code niet aan te passen per besturingssysteem.

Kan ik SwiftData inheritance combineren met CloudKit-synchronisatie?

In principe wel, maar er zijn bekende aandachtspunten. Sommige ontwikkelaars melden problemen wanneer je relaties optioneel moet maken voor CloudKit-compatibiliteit in combinatie met inheritance. Mijn advies: test CloudKit-synchronisatie grondig voordat je deze combinatie in productie brengt. Houd ook de Apple Developer Forums in de gaten voor updates.

Wat is het verschil tussen een lightweight en een custom migratie bij inheritance?

Een lightweight migratie wordt automatisch afgehandeld door SwiftData — denk aan eenvoudige wijzigingen zoals het toevoegen van een subklasse. Een custom migratie is nodig wanneer je bestaande data moet transformeren, bijvoorbeeld het converteren van records van een plat model naar specifieke subklassen. Bij een custom migratie schrijf je zelf de logica in een MigrationStage.custom-closure.

Hoeveel subklassen kan ik maximaal hebben?

Er is geen hard technisch maximum. Maar vanwege Single Table Inheritance wordt aangeraden om het beperkt te houden. Meer dan tien subklassen met elk veel unieke properties kan leiden tot brede tabellen met veel NULL-waarden — en dat gaat ten koste van de prestaties. Houd het bij voorkeur plat en overzichtelijk.

Moet ik mijn minimum deployment target verhogen naar iOS 26?

Niet per se. Je kunt @available(iOS 26, *) gebruiken om subklassen conditioneel beschikbaar te maken. Gebruikers op oudere iOS-versies werken dan met het basismodel zonder inheritance. Dit vereist wel extra logica om beide scenario's te ondersteunen, dus weeg af of die extra complexiteit het waard is voor jouw specifieke situatie.

Over de Auteur Editorial Team

Our team of expert writers and editors.