@Attribute(storageMethod:) makes Core Data storage choices explicit.
This guide explains:
- why the feature exists
- what each storage method means
- which types each method supports
- what restrictions are intentional in the current implementation
- how this feature relates to
PersistentModeland typed path mapping
In plain Core Data, developers often end up writing manual bridging code to get a better Swift API.
Typical examples:
- expose
Double?,Float?, or other optional scalar values without falling back toNSNumber? - expose an enum instead of a raw string or integer
- expose a custom
Codablevalue instead of rawData - expose a richer value type instead of a transformable payload
- expose a composition-like value instead of manually managing several flattened fields
The traditional solution is usually:
- keep one stored Core Data field
- add a computed property on top of it
- manually encode, decode, or transform in the getter and setter
That works, but it has two recurring problems.
One important pain point comes from the classic NSManagedObject + @NSManaged style itself:
- non-optional scalar properties are usually straightforward
- optional scalar properties are much less pleasant to express directly
- developers often fall back to
NSNumber?or hand-written bridging for values that should really be modeled in Swift asDouble?,Float?,Int?, and similar types
SwiftData improved this experience by making Swift-facing model declarations feel more natural. CoreDataEvolution addresses the same problem for Core Data by generating the KVC/KVO-facing access layer for you, so your source model can stay closer to the Swift types you actually want to use.
Every model ends up re-implementing the same patterns:
- decode in
get - encode in
set - fallback behavior on failure
- type conversion rules
This is repetitive and easy to drift across models.
Once the nicer Swift-facing property becomes a computed bridge, it is no longer a real persisted attribute.
That means it cannot participate naturally in:
- store-backed
NSSortDescriptor %K-basedNSPredicate- typed path mapping
This is the same pain point discussed in TypedPathGuide.md.
SwiftData does support conveniences such as:
- raw-value-backed enums
Codable- transformable-like storage
But its storage behavior is not always explicit from the declaration alone.
In particular, Codable storage is easy to misunderstand:
- it feels high-level in source
- but the underlying persistence layout is not obvious
- and schema evolution can become awkward later
For example, if a Codable type expands into multiple stored members or its encoded shape changes
over time, that can become difficult to reason about in a long-lived Core Data schema.
CoreDataEvolution takes a different approach:
- the storage strategy is declared explicitly
- the generated code matches that strategy directly
- the developer chooses the mechanism instead of guessing the framework's internal choice
storageMethod exists to make storage semantics explicit and predictable.
Instead of hand-writing conversion boilerplate, you declare what you want:
@Attribute(storageMethod: .raw)
var status: Status? = .draftor:
@Attribute(storageMethod: .codable)
var config: ItemConfig? = nilor:
@Attribute(storageMethod: .composition)
var location: GeoPoint? = nilThis gives you:
- explicit source-level intent
- generated getter/setter logic
- consistent validation rules
- compatibility with the macro-generated key/path layer
Currently supported:
.default.raw.codable.transformed(...).composition
General rule for custom storage:
.raw,.codable,.transformed(...), and.compositionare custom storage methods.codable,.transformed(...), and.compositioncurrently require optional declarations- for those three storage methods, the only supported explicit default is
nil .rawremains the only custom storage path that may still align with a model-backed primitive default
.default means the Swift property maps directly to a Core Data primitive attribute.
This is the implicit default for primitive types.
Example:
var title: String = ""
var count: Int64 = 0
var createdAt: Date? = nilCurrently supported types for .default:
StringBoolInt16Int32Int64IntFloatDoubleDecimalDateDataUUIDURL- optionals of the above
Note about Int:
- Core Data does not have a native
Intattribute kind Intis supported here as a Swift-facing convenience type- the underlying Core Data integer storage is still
Integer 16,Integer 32, orInteger 64 - if integer width matters, prefer
Int16,Int32, orInt64explicitly
- non-primitive types are not allowed with
.default - if the property is non-optional, it must have a default value
- that Swift default value should match the model default value
If you need more detail about model default values, see PersistentModelGuide.md.
.raw is for RawRepresentable types, usually enums.
Example:
enum Status: String {
case draft
case published
}
@Attribute(storageMethod: .raw)
var status: Status? = .draftUse this when:
- the stored Core Data field is primitive
- the Swift API should be an enum or another raw-backed type
- the Swift type must conform to
RawRepresentable .rawis not inferred automatically- you must declare it explicitly
.raw is the exception among custom storage methods.
Unlike .codable, .transformed(...), and .composition, .raw may still be used with
non-optional properties, as long as the type is RawRepresentable and the property still follows
the normal non-optional default-value rule.
.codable stores the property through Codable encoding and decoding.
Example:
struct ItemConfig: Codable, Equatable {
var retryCount: Int = 0
}
@Attribute(storageMethod: .codable)
var config: ItemConfig? = nilUse this when:
- the value is a single logical object
Datastorage is acceptable- you want explicit serialization semantics in source code
- the type must conform to
Codable .codableis not inferred automatically- the property must currently be optional
- the only supported explicit default is
nil
This makes the storage choice visible in source.
You do not have to guess whether the framework is:
- storing a payload blob
- flattening members
- or doing something more implicit
.transformed(...) is for ValueTransformer-backed storage.
Example:
@Attribute(storageMethod: .transformed(name: "ColorTransformer"))
var color: NSColor? = nilUse this when:
- you already rely on a
ValueTransformer - the storage format is already defined elsewhere
- you need compatibility with an existing Core Data model
- the property must currently be optional
- the only supported explicit default is
nil - decode failure behavior can be customized with
decodeFailurePolicy - the transformer must be registered before the property is first accessed
Two source forms are currently supported:
.transformed(MyTransformer.self).transformed(name: "MyTransformerName")
Use .transformed(MyTransformer.self) when:
- you own the transformer type
- it conforms to
CDRegisteredValueTransformer - it exposes the same registration name used by the Core Data model
Use .transformed(name: "...") when:
- you want the declaration to match the Core Data model directly
- the transformer is already identified by registration name
- you are using source generated by
cde-tool
For schema-backed Core Data models, the attribute type should match the transformer's stored output.
That means:
- if your transformer returns
NSString, model the field asString - if your transformer returns
NSData, model the field asBinary Data - only use Core Data
Transformablewhen the model is intentionally relying on a transformable payload path, such asNSSecureUnarchiveFromData
This is the most important rule for .transformed(...):
- the Swift-facing type is your property type
- the Core Data field type is the transformer's persisted output type
Example:
final class StringListTransformer: ValueTransformer, CDRegisteredValueTransformer {
static let transformerName = NSValueTransformerName("StringListTransformer")
override class func transformedValueClass() -> AnyClass { NSString.self }
}
@Attribute(storageMethod: .transformed(StringListTransformer.self))
var tags: [String]? = nilIn this example, the Core Data field should be modeled as String, not Transformable.
The generated accessor resolves the transformer through
ValueTransformer(forName:) using the registration name published by the type.
This matches Core Data's model-backed lookup more closely than constructing a new transformer
instance on every access.
The same declaration can also be written in the model-aligned form:
@Attribute(storageMethod: .transformed(name: "StringListTransformer"))
var tags: [String]? = nilThis path is especially useful for existing schemas that already store payloads through a stable transformer contract.
For collection payloads such as:
[Int][String][String: String]
do not treat them as plain primitive storage.
If the model already uses the system secure-unarchive transformer, keep that setup explicit:
- the Core Data model should use the system
NSSecureUnarchiveFromDatatransformer (or an equivalent custom transformer) - the Swift declaration should mirror that choice with
.transformed(...)
Example:
@Attribute(storageMethod: .transformed(name: "NSSecureUnarchiveFromData"))
var numbers: [Int]? = nilIn that specific case, the schema-backed field is typically modeled as Transformable.
Because the system transformer is already registered by Foundation, name: is the more direct and
model-aligned form here.
Register custom transformers before first access. Recommended registration points include:
- app launch
- test bootstrap
- fixture setup
- any earlier static initialization path
If you need tighter control over allowed classes or a pre-existing transformer name, prefer a dedicated transformer subclass instead of relying on implicit transformable behavior.
.composition is for structured value types that should participate in the macro-generated path
system as a single logical property.
In CoreDataEvolution, composition is the source-level term. It corresponds to Core Data's
composite attribute concept at the model layer.
Like the other custom storage methods that encode or transform payloads, .composition must
currently be declared as optional and may only use nil as an explicit default.
Example:
@Composition
struct GeoPoint {
var latitude: Double = 0
var longitude: Double = 0
}
@Attribute(storageMethod: .composition)
var location: GeoPoint? = nilThis is the most opinionated storage method in the current design.
It exists because it solves two problems at the same time:
- avoids repeated manual bridging code
- keeps composition leaf paths available for typed mapping
That means you can still write:
Item.path.location.latitudeinstead of giving up path-based sort/filter support.
This storage method is the CoreDataEvolution-facing representation of Core Data's composite attribute model introduced in the WWDC 2023 era.
Conceptually, a composite attribute sits between three older approaches:
- flattening several primitive attributes by hand
- creating a separate entity and relationship
- storing the value as one transformable payload
The Core Data composite approach is attractive because it keeps subfields queryable while still letting the model describe them as one logical grouped value.
For a good overview of Core Data's composite attributes, see:
Important Core Data facts:
- composite attributes are a model-level Core Data feature
- SQLite stores them in an expanded field layout, not as one opaque blob
- subfields can participate in predicates and sorts
- Xcode models them as a composite type, but Core Data does not generate a custom Swift value type for you automatically
That last point is exactly where CoreDataEvolution adds value:
- Core Data gives you the storage model
@Compositiongives you the Swift value type@Attribute(storageMethod: .composition)connects the two- typed paths keep subfield sort/predicate usage available in source
In this package, .composition is intentionally explicit.
You do not rely on hidden framework synthesis. Instead you declare:
- the Swift struct with
@Composition - the property with
storageMethod: .composition
and the macros generate:
- encode/decode helpers
- field table metadata
- typed subpaths such as
Item.path.location.latitude
This keeps the storage strategy visible in source and makes later maintenance easier.
The type must be a @Composition struct.
Current composition rules:
- only
struct - no generics
- only stored
varproperties - only primitive field types
- no nested composition
- no
.raw,.codable, or.transformedinside composition fields - field renaming is supported through
@CompositionField(persistentName: ...)
If the property is backed by a real .xcdatamodeld, the entity must use a real Core Data
composite attribute.
In practice, the model should contain:
- a top-level attribute such as
location attributeType = Composite- a referenced composite type such as
GeoPoint - the composite's leaf attributes, such as
latitudeandlongitude
In Xcode's model editor, that means:
- create one entity attribute whose type is
Composite - point that attribute at a named composite type
- declare the leaf fields inside that composite type
Do not model schema-backed .composition as Transformable. A transformable dictionary payload is
only used by the runtime-only test/debug model builder, not by the real .xcdatamodeld workflow.
Also do not flatten those leaf fields directly onto the entity while keeping the Swift source as a
single composition property. A declaration such as var location: GeoPoint? expects the Core Data
model to expose a top-level location composite attribute.
decodeFailurePolicy applies to storage methods that actually decode or transform values:
.raw.codable.transformed(...)
Supported policies:
.fallbackToDefaultValue.debugAssertNil
Example:
@Attribute(
storageMethod: .codable,
decodeFailurePolicy: .fallbackToDefaultValue
)
var config: ItemConfig? = nilFor .codable and .transformed(...), .fallbackToDefaultValue currently falls back to nil.
Those storage methods are limited to optional declarations and do not support non-nil source
defaults, so there is no separate model-backed value to reconstruct after a decode failure.
This policy does not apply to plain primitive .default storage.
transient is a trait, not a storage method:
@Attribute(.transient)
var cachedSummary: String = ""It still matters here because it changes how the attribute participates in the Core Data model.
Current rule:
transientonly supports.default
So these are rejected:
@Attribute(.transient, storageMethod: .raw)
var state: Status? = nil
@Attribute(.transient, storageMethod: .composition)
var location: GeoPoint? = nilUse this rough decision tree:
- If the type is already a supported primitive ->
.default - If the type is
RawRepresentable->.raw - If the type is a logical payload object and
Datastorage is acceptable ->.codable - If an existing model already uses
Transformableor a custom transformer ->.transformed(...) - If the type is a structured value you want to expose through typed subpaths ->
.composition
With explicit storageMethod, you keep:
- one declaration of intent
- one generated conversion pattern
- one validation surface
- one key/path mapping layer
Instead of hand-writing:
- raw backing field
- computed property bridge
- sort/predicate string exceptions
- decode fallback behavior
over and over again.
- See PersistentModelGuide.md for the full macro overview.
- See TypedPathGuide.md for the key/path mapping layer used by sort and predicate construction.
storageMethod and typed path mapping are closely related:
- storage controls how the value is persisted
- typed path mapping controls how the persisted field path is exposed safely in code
Together, they let you keep a better Swift-facing model API without giving up store-backed sort and predicate support.