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:
- 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.
- Gebruik inheritance alleen bij echte "is-a"-relaties. Een
Hondis eenDier— prima. EenBestellingis geenKlant— gebruik dan een relatie. - Vergeet de @available-markering niet. Zonder
@available(iOS 26, *)compileert je subklasse niet als SwiftData-model. Dit is echt een klassieker. - Registreer alle subklassen in je ModelContainer. De meest voorkomende fout bij beginners, en ook de meest frustrerende om te debuggen.
- Gebruik #Index voor veelgebruikte query-properties. Zeker bij grotere datasets maakt dit een merkbaar verschil in snelheid.
- Test je migraties grondig. Maak een kopie van je productiedatabase en test de migratie voordat je een update uitbrengt. Serieus — doe dit.
- 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.