Swift’s powerful type system empowers us to create semantically explicit and safe data models. Yet when we move to SwiftData or Core Data, the constraints of their underlying storage mechanisms often force us to compromise on type expressiveness. Those concessions blur our domain models’ intent and plant hidden seeds of instability.
This article explores how, within the restrictions of persistence layers, we can leverage ingenious type wrappers and conversions to build data models that are simultaneously Type-safe, semantically clear, and highly efficient.
Double vs. Double? : Bridging Type Safety and Storage Mechanisms
Optional
is one of Swift’s finest gems: it turns “non‑existence” into an explicit type expression. In data modelling this is crucial, because it lets us distinguish two radically different semantic states: “value is zero” versus “no value at all.”
In SwiftData, declaring an optional numeric property is straightforward, allowing the model to convey business semantics naturally:
@Model
class People {
var name: String = ""
var weight: Double? // Perfectly expresses “may have no weight record”
}
When we turn to Core Data, however, this elegant declaration runs into trouble. Even if we mark weight
as Optional in the model editor, the property still cannot be declared as Double?
—not even when we hand‑code the model.
The root cause lies in Core Data’s internals: every numeric value (Float, Int, etc.) stored in SQLite is converted to NSNumber
. Because Swift’s primitive types (Double
, Float
, Int
) do not map one‑to‑one onto NSNumber
, only NSNumber
itself can be declared optional:
extension People {
@NSManaged public var weight: NSNumber? // Satisfies Core Data, violates Swift style
}
To reconcile Core Data’s storage rules with Swift’s Type-safety ideals, we can adopt a property‑wrapper‑style strategy. First rename the raw property to weightRaw
, then provide callers a Swift‑idiomatic API via a computed property and access‑control:
extension People {
@NSManaged public var name: String
// Expose a Swift‑style, Type-safe API
public var weight: Double? {
get { weightRaw?.doubleValue }
set { weightRaw = newValue as NSNumber? }
}
}
extension People {
// Internal property—implementation detail hidden from callers
@NSManaged var weightRaw: NSNumber?
}
Keen readers may notice that code generated by the model editor still declares name
as String?
even when we mark it non‑optional. Hence, when hand‑writing model code, we must explicitly declare it String
.
The reason SwiftData can declare
Double?
directly is that, in its model definition, it deftly maps the public property to a backing store—exactly the trick we just implemented by hand. This is one of SwiftData’s notable improvements over Core Data.
With this approach we satisfy the storage layer’s requirements while offering upper‑layer code a Type-safe, semantically clear API: the best of both worlds.
String vs. NonEmpty<String>: Let the Type System Vouch for Data Validity
Although we made name
non‑optional, a plain String
cannot fully express the business rules for a valid name. An empty string (""
) or one containing only whitespace (" "
) meets the technical “non‑optional” requirement, yet clearly violates “valid name” semantics.
What we need is a type‑level guarantee that a string is non‑empty. The Point‑Free library NonEmpty provides such an elegant solution:
import NonEmpty
extension People {
public var name: NonEmptyString {
get { NonEmpty(stringLiteral: nameRaw) }
set { nameRaw = newValue.rawValue }
}
}
extension People {
@NSManaged var nameRaw: String
}
This embeds the “name must be non‑empty” rule in the type system itself, allowing the compiler to catch potential errors at compile time.
In practice, requirements for a name often exceed mere “non‑empty”: length limits, character‑set constraints, and more. We can encode richer validation logic in a custom type:
/// A validated name structure ensuring the name meets length requirements.
public struct ValidatedName {
/// Minimum length
public static let lengthLowerBound = 3
/// Maximum length
public static let lengthUpperBound = 20
/// Raw string value of the name
public let rawValue: String
/// Create and validate a name
public init(_ name: String) throws NameError {
let clean = try Self.validate(name)
rawValue = clean
}
/// Validate the name against rules
static func validate(_ name: String) throws NameError -> String {
let trimmed = name.toSingleLineCleanedString() // Strip illegal chars
guard let trimmed else { throw NameError.nameIsEmpty }
guard trimmed.count >= lengthLowerBound,
trimmed.count < lengthUpperBound
else {
throw NameError.nameLengthIsInvalid(
lowerBound: lengthLowerBound,
upperBound: lengthUpperBound
)
}
return trimmed
}
/// Name‑validation error type
public enum NameError: Equatable, Sendable, Error {
case nameIsEmpty
case nameLengthIsInvalid(lowerBound: Int, upperBound: Int)
}
}
/// Extend ValidatedName with a “trusted” initializer
extension ValidatedName {
/// Create from data known to be validated (e.g. fetched from DB)
/// - Note: Use only when the data is guaranteed valid.
public init(trust validatedValue: String) {
rawValue = validatedValue
}
}
extension String {
// String helper according to business rules
func toSingleLineCleanedString() -> String? {
let result = trimmingCharacters(in: .whitespacesAndNewlines)
return result.isEmpty ? nil : result
}
}
Now the model can use this custom type:
extension People {
public var name: ValidatedName {
get { ValidatedName(trust: nameRaw) }
set { nameRaw = newValue.rawValue }
}
}
// Usage example
let people = People(context: viewContext)
people.name = try ValidatedName(" ") // error: name is empty
As Alex Ozun stresses in his article Making illegal states unrepresentable, illegal states are a frequent source of complexity and unintended bugs. By encoding constraints in the type system, we eliminate such states at compile time rather than discovering them at runtime.
This Type-safe modelling approach applies not only to Core Data but also to SwiftData and other modelling scenarios.
With this “types‑first” mindset, we can create a clear mapping between public and internal properties and even aggregate scattered raw attributes into rich composite types. In Model Inheritance in Core Data, for instance, we demonstrated how to hide storage details from API consumers:
// Enumerate specialised content types for the ItemData entity
public enum ItemDataContent: Equatable, Sendable {
case singleValue(eventDate: Date, value: Double)
case singleOption(eventDate: Date, optionID: UUID)
case valueWithOption(eventDate: Date, value: Double, optionID: UUID)
case dualValues(eventDate: Date, pairValue: ValidatedPairDouble)
case valueWithInterval(pairDate: ValidatedPairDate, value: Double)
case optionWithInterval(pairDate: ValidatedPairDate, optionID: UUID)
case interval(pairDate: ValidatedPairDate)
}
// MARK: – Public properties
extension ItemData {
@NSManaged public var createTimestamp: Date
@NSManaged public var uid: UUID
@NSManaged public var item: Item?
@NSManaged public var memo: Memo?
// Aggregate multiple raw attributes into a rich composite type
public var dataContent: ItemDataContent? {
get { dataContentGetter(type: type) }
set { dataContentSetter(content: newValue) }
}
}
// Hidden internal attributes
extension ItemData {
@NSManaged var startDate: Date?
@NSManaged var endDate: Date?
@NSManaged var value1: NSNumber?
// …other internals
}
Thus we keep storage details encapsulated and present callers with a Type-safe, semantically meaningful API.
Striking a Balance Between API Convenience and Query Efficiency
Even though SwiftData and Core Data model declarations are inching closer to Swift’s native type system, we cannot entirely escape the hard limits of the underlying SQLite store. In certain scenarios developers must fine‑tune the trade‑off between API convenience and storage/query efficiency.
Consider multi‑select options. Suppose users can pick several items from a set (option IDs are Int8
). How do we offer a neat API and enable performant queries?
The most intuitive design is an array:
extension DataContent {
public var optionSelections: [Int8] {
get { /* conversion from storage */ } // e.g. stored as array
set { /* conversion into storage */ }
}
}
// Usage
dataContent.optionSelections = [2, 3, 6, 1]
Storing an array, however, poses serious query bottlenecks. For example, finding records containing the combination [1, 6]
cannot be expressed directly in SQL. Converting the numbers to a delimited string is possible but slow at scale.
Given that each multi‑select will contain at most ten options, we can encode the result as a single Int64
using bitwise operations. This keeps the API simple while slashing storage space:
extension DataContent {
// Public array interface
public var optionSelections: [Int] {
get { optionIDsNumber.toArray() }
set { optionIDsNumber = optionIDsToInt64(optionIDs: newValue) }
}
// Stored as a bitmask
private var optionIDsNumber: Int64
}
func optionIDsToInt64(optionIDs: [Int]) -> Int64 {
var result: Int64 = 0
for id in optionIDs where id >= 0 && id <= 63 {
// Set the bit corresponding to each ID
result |= (1 << id)
}
return result
}
SQLite naturally supports efficient bitwise operations, making this strategy perfect for high‑performance queries:
// Predicate to find records containing IDs 2, 3, and 10
let optionIDs = [2, 3, 10]
let bitmask = optionIDsToInt64(optionIDs: optionIDs)
let predicate = NSPredicate(
format: "(optionIDsNumber & %@) == %@",
NSNumber(value: bitmask),
NSNumber(value: bitmask)
)
Such optimisations require a deep understanding of SwiftData/Core Data internals. Because schema changes are expensive, consider future query needs and data growth during the design phase to avoid technical debt.
Constructors: The Safety Gate for Model Creation
Another key improvement SwiftData makes over Core Data is its mandatory initializer‑declaration mechanism. Developers must explicitly define how to create a model, letting the compiler prevent missing assignments and enforce strict ordering.
Convenient Constructors for Composite Types
Recall our “merge many into one” tactic—we wrapped multiple raw properties in ItemDataContent
. A well‑designed constructor lightens API users’ cognitive load:
extension ItemData {
/// Create a new item *without* inserting it into a context.
/// - Parameters:
/// - createTimestamp: Creation time
/// - uid: Unique identifier
/// - dataContent: Composite data content
public convenience init(
createTimestamp: Date,
uid: UUID,
dataContent: ItemDataContent // Only one composite param
) {
self.init(entity: Self.entity(), insertInto: nil) // Mirrors SwiftData
self.createTimestamp = createTimestamp
self.uid = uid
self.dataContent = dataContent
}
}
This constructor hides complex internal‑assignment logic; developers supply business‑level parameters only. The composite type’s own validation safeguards data integrity.
Controlling Relationship Timing
Our constructor mirrors SwiftData by not inserting the new instance into the NSManagedObjectContext
(insertInto: nil
). This allows an explicit, orderly relationship‑building flow:
- Use a custom constructor to create instance a of type A.
- Explicitly insert a into its context.
- Use a custom constructor to create instance b of type B.
- Explicitly insert b into its context.
- Finally establish the relationship between a and b.
let item = Item(
name: try ValidatedName("New Item"),
createTimestamp: Date()
)
viewContext.insert(item) // Explicit insertion
let itemData = ItemData(
createTimestamp: Date(),
uid: UUID(),
dataContent: .singleValue(eventDate: Date(), value: 98.6)
)
viewContext.insert(itemData) // Explicit insertion
// Build relationship
itemData.item = item
This explicitness boosts readability and avoids common mistakes like linking objects not yet in a context.
Multiple Constructors, One Cohesive API
A well‑designed model should expose multiple constructors tailored to different scenarios. Each one keeps parameters clear and enforces coherence at compile time. When internals evolve, adjust the constructors—call‑site code remains untouched.
In model design, a thoughtful constructor is the first line of defence for data consistency and safety. It reduces misuse and grants flexibility for future evolution.
Don’t Fear the Effort
Perhaps you already know many of these techniques but hesitate, worried about extra workload. Remember: the data model is the foundation of the entire app. It directly determines code safety, maintainability, and flexibility for future evolution.
Effort invested in model design is not a burden—it is a hedge against technical debt. A well‑constructed model continuously saves time, reduces bugs, and markedly raises development efficiency.
Since we’ve chosen Swift—a language both powerful and expressive—why not unleash its full strength and let the type system stand as a robust sentinel for your business rules?