Projected write companions for SwiftData queries and models in SwiftUI.
Overview · Install · Quick start · Usage · Requirements · Documentation · Contributing · License
Note
swift-data-writable is pre-1.0. Minor releases may include source-breaking
changes, so depend on it with .upToNextMinor(from:).
swift-data-writable adds a write side to the SwiftData properties a SwiftUI
view already reads. Attach @Writable to a @Query array or a model property,
and the attached peer macro generates a $property companion that inserts, deletes,
reorders, and edits models through the view's ModelContext. Like a property
wrapper's projected value, the companion sits next to the property it extends,
while @Query stays the read-side source of truth.
- Query companions that insert and delete through
ModelContext, including a function value for SwiftUI.onDelete. - Persisted
.onMovereordering through a comparable ordering key path. - Single and optional model companions whose relationship chains stay
writable, such as
$book.tags[0].documents. - Independent autosave and error policies, with domain-owned transaction functions around each mutation.
- A bridge from SwiftUI
@Bindableto the same write helpers. - Projections bound to the
ModelContextthey use, not to the main actor.
Use plain SwiftData directly when the task is schema design, migration, container setup, CloudKit sync, query behavior without a SwiftUI editing surface, or custom store behavior.
Add the package and the SwiftDataWritable product to Package.swift:
dependencies: [
.package(
url: "https://github.com/swift-library/swift-data-writable.git",
.upToNextMinor(from: "0.1.1")
),
],
targets: [
.target(
name: "YourApp",
dependencies: [
.product(name: "SwiftDataWritable", package: "swift-data-writable"),
]
),
]In an Xcode project, add the same URL with File > Add Package Dependencies and
choose the Up to Next Minor Version rule. @Writable is a Swift macro, so
Xcode asks you to trust the package before its first build.
Attach @Writable to a @Query array and use the generated $persons
companion for edits:
import SwiftData
import SwiftDataWritable
import SwiftUI
@Model
final class Person {
var name: String
init(name: String) {
self.name = name
}
}
struct PeopleView: View {
@Writable
@Query(sort: \Person.name)
private var persons: [Person]
var body: some View {
List {
ForEach(persons) { person in
Text(person.name)
}
.onDelete(perform: $persons.remove)
}
.toolbar {
Button("Add") {
$persons.append(Person(name: "New"))
}
}
}
}
@main
struct PeopleApp: App {
var body: some Scene {
WindowGroup {
PeopleView()
}
.modelContainer(for: Person.self)
}
}$persons is a WritableModelCollection<[Person]> backed by the view's
modelContext. append inserts the new model into the context, and remove
deletes the models at the offsets that .onDelete passes. The @Query
snapshot refreshes after the context changes. These calls do not save on their
own: call try $persons.save(), enable autosave, or let SwiftData's
own context autosave persist the change.
The examples below use Person, Book, Tag, Document, and Folder
models, where a book has tags: [Tag], a tag has documents: [Document], and
a document has an optional folder: Folder?.
The shape of the property decides which companion the macro generates:
| Property | Generated $property |
|---|---|
@Writable @Query var items: [Model] |
WritableModelCollection<[Model]> |
@Writable(autosave: true, throws: true) @Query var items: [Model] |
ThrowsWritableModelCollection<[Model]> |
@Writable(mutableBy: \Model.order) @Query var items: [Model] |
KeyPathWritableModelCollection<[Model]> |
@Writable(autosave: true, throws: true, mutableBy: \Model.order) @Query var items: [Model] |
KeyPathThrowsWritableModelCollection<[Model]> |
@Writable var model: Model |
WritableModel<Model> |
@Writable(autosave: true, throws: true) var model: Model |
ThrowsWritableModel<Model> |
@Writable var model: Model? |
WritableModel<Model>? |
@Writable @Bindable var model: Model |
None; SwiftUI keeps $model |
throws: true selects the ThrowsWritable* variant of each surface, and
adding autosave: or transaction: keeps the same surface type. From a model
companion, $model.children projects a relationship array as
WritableRelationshipCollection<Model, [Child]>, and $model.child projects a
single relationship as WritableModel<Child> or WritableModel<Child>?.
Relationship projections from a throwing surface stay throwing.
The model and relationship element types must conform to PersistentModel.
The macro validates the property syntax, and the generated generic constraints
let the compiler check the model conformance. The
writable combinations reference
describes each shape and method in detail.
A query companion writes through ModelContext:
@Writable
@Query(sort: \Person.name)
private var persons: [Person]append(_:),insert(_:), andappend(contentsOf:)callModelContext.insert.delete(_:)anddeleteAll()callModelContext.delete;deleteAll()deletes every model in the current snapshot.remove(atOffsets:)deletes the models at offsets in the current snapshot, and$persons.removeis the same operation as a function value for.onDelete(perform:).$persons[0]returns aWritableModel<Person>for one model in the snapshot.save()callscontext.save()and throws on failure.write { context in ... }andwrite { persons, context in ... }run a throwing write closure, the second with the current snapshot. The two-argument form returns the closure's result.
The @Query snapshot refreshes later, after the context changes. The
expansion is roughly:
private var _personsWritableContext = SwiftDataWritable._WritableModelContextReader()
private var `$persons`: WritableModelCollection<[Person]> {
WritableModelCollection(
value: persons,
context: _personsWritableContext.context,
autosave: false
)
}The context peer reads SwiftUI's modelContext environment value, and the
macro gives it a unique generated name. The companion uses the property's
access level.
ThrowsWritableModelCollection provides the same methods, but its mutations
throw. Because its remove(atOffsets:) throws, wrap it in a closure for
.onDelete:
.onDelete { offsets in
try? $persons.remove(atOffsets: offsets)
}Use @Writable(mutableBy:) when SwiftUI .onMove should persist ordering by
rewriting an existing comparable field:
@Writable(mutableBy: \Person.priority)
@Query(sort: \Person.priority)
private var persons: [Person]
var body: some View {
List {
ForEach(persons) { person in
Text(person.name)
}
.onMove(perform: $persons.move)
}
}The key path must be a writable key path to a Comparable value.
move(fromOffsets:toOffset:) reorders the current snapshot and reassigns the
original key path values to the moved models. It does not synthesize dense
integer values, so the @Query(sort:) key path and the mutableBy key path
should describe the same stored order. The companion also provides every query
collection method. KeyPathThrowsWritableModelCollection has a throwing
move, so call it from an .onMove closure with try.
@Writable on a model property generates a WritableModel:
@Writable
private var book: Book
try $book.write { book, _ in
book.title = "Updated"
}WritableModel provides save(), write { context in ... }, and
write { model, context in ... }; the two-argument form returns the closure's
result. An optional model generates an optional companion, so use optional
chaining:
@Writable
private var selection: Person?
try $selection?.write { person, _ in
person.name = "Updated"
}A model companion projects its relationships, and subscripts keep the chain writable:
@Writable
private var book: Book
@Writable
private var document: Document
$book.tags.append(tag)
$book.tags[0].documents.append(document)
try $book.tags[0].documents[0].write { document, _ in
document.title = "Updated"
}
try $document.folder?.write { folder, _ in
folder.name = "Manual"
}A relationship collection provides append(_:), append(contentsOf:),
remove(_:), remove(atOffsets:), removeAll(), a subscript, save(), and
write, and $book.tags.remove works with .onDelete(perform:). These
methods mutate the owner's relationship array directly. They do not call
context.insert or context.delete, so $tags.append(tag) on a query
companion inserts into the context, while $book.tags.append(tag) changes
book.tags.
This follows SwiftData's graph-root rule: insert and save the attached owner
graph, and SwiftData traverses related models automatically. When the owner is
already attached to a ModelContext, a new related model is persisted by
relationship mutation plus save:
let tag = Tag(name: "Swift")
$book.tags.append(tag)
try $book.save()That is still relationship membership. The projection does not promise query-style insertion, deletion, validation, inverse maintenance, or side effects.
Single relationship projections such as $document.folder read the related
model without mutating the parent relationship, and they keep the autosave
setting and transaction of the chain. They exist only for PersistentModel
relationships, so ordinary fields remain model properties or @Bindable
bindings.
autosave decides whether a mutation attempts context.save() after it
changes the context or owner relationship. throws decides whether an
automatic save error reaches the caller:
@Writable(autosave: true) var book: Book // best-effort autosave
@Writable(autosave: true, throws: true) var tag: Tag // autosave failure is thrown
$persons.append(Person(name: "A")) // mutate only
try $persons.save() // explicit save, always throws on failureOrdinary Writable* mutation methods, such as append, delete, and move,
do not throw. With autosave: true, a save or transaction failure after the
mutation is swallowed. Explicit save() still throws. write { ... } throws
because its body can throw and a transaction can reject the mutation before it
runs.
ThrowsWritable* mutation methods throw. With autosave: true, a save failure
is thrown. @Writable(throws: true) without autosave: true is valid, but
mutations do not save; explicit save() remains the save point.
autosave: and throws: take literal true or false values. SwiftData's
own context autosave policy is unchanged, so with autosave: false a change
can still be persisted by SwiftData.
A transaction function owns the save boundary around each mutation. When
autosave is true and a function is supplied, swift-data-writable calls it
with the current ModelContext, the value, and the mutation closure, and does
not also call context.save(). Without autosave: true, the function is not
called.
enum PeopleDomain {
static func savePeople(
_ context: ModelContext,
_ persons: [Person],
_ mutation: () throws -> Void
) throws {
try mutation()
try context.save()
}
}
@Writable(autosave: true, throws: true, transaction: PeopleDomain.savePeople)
@Query(sort: \Person.name)
private var persons: [Person]The macro infers the value type from the property. A model property's function
receives the model. A query collection's function receives an array of the
affected models, which is the whole snapshot for write, deleteAll(), and
move. Relationship projections pass the model at the root of the chain.
Pass a non-overloaded function directly. Swift type-checks macro arguments
before expansion, so a bare overloaded function name has no property-type
context yet. When one name must support several value shapes, expose a
function-like value with callAsFunction overloads:
struct PeopleWriteback {
func callAsFunction(
_ context: ModelContext,
_ person: Person,
_ mutation: () throws -> Void
) throws {
try mutation()
try context.save()
}
func callAsFunction(
_ context: ModelContext,
_ persons: [Person],
_ mutation: () throws -> Void
) throws {
try mutation()
try context.save()
}
}
extension PeopleDomain {
static var save: PeopleWriteback { PeopleWriteback() }
}
@Writable(autosave: true, transaction: PeopleDomain.save)
private var person: PersonThe function runs in the same isolation context as the mutation and should
operate on the supplied ModelContext and value. On success it must invoke the
mutation synchronously. To reject a mutation, throw before invoking it;
returning successfully without invoking it is a programmer error that stops at
a runtime precondition. Propagate mutation errors instead of swallowing them.
On an ordinary surface, a rejection from a non-throwing method such as
append(_:) is silent, so use write or a throwing surface to observe it.
Use @Writable @Bindable when a view needs both SwiftUI field bindings and
model write helpers. The macro does not generate a second $person; SwiftUI
keeps $person as Bindable<Person>, and its writable and throwsWritable
members bridge to the write helpers:
@Writable
@Bindable
private var person: Person
TextField("Name", text: $person.name)
try $person.writable.write { person, _ in
person.name = "Updated"
}
try $person.writable(autosave: true).write { person, _ in
person.name = "Updated"
}
try $person.throwsWritable(autosave: true).write { person, _ in
person.name = "Updated"
}The bridge is a runtime API without macro expansion, so it takes an explicit
WritableTransaction<Value>:
let transaction = WritableTransaction<Person> { context, person, mutation in
try PeopleDomain.save(context, person, mutation)
}
try $person.throwsWritable(autosave: true, transaction: transaction).write { person, _ in
person.name = "Updated"
}The bridge reads person.modelContext, and a detached model throws
WritableModelError.detachedModel. It supports non-optional models only.
Projections are bound to the ModelContext they receive, not to the main
actor. Use them from the same actor or executor that owns that context and its
models. SwiftUI context lookup and the @Bindable bridge are @MainActor
because they are SwiftUI entry points.
The surface types have public initializers, so code outside a view, such as a
@ModelActor, can create a projection over its own context:
@ModelActor
actor PeopleImporter {
func rename(_ person: Person) throws {
let writable = ThrowsWritableModel(value: person, context: modelContext, autosave: true)
try writable.write { person, _ in
person.name = "Imported"
}
}
}WritableModel and ThrowsWritableModel do not define domain commands such as
rename, move, archive, or trash. Do not add thin wrappers for operations a
projection already expresses:
$book.tags.append(tag)
try $book.save()Add an extension on the surface that matches your error policy when the method carries domain meaning, such as creating related models, deduplicating, updating inverse relationships, maintaining ordering, validating ownership, or running side effects:
extension WritableModel where Model == Book {
@discardableResult
func attachTagIfMissing(named name: String) throws -> Tag {
try write { book, _ in
if let existing = book.tags.first(where: { $0.name == name }) {
return existing
}
let tag = Tag(name: name)
book.tags.append(tag)
return tag
}
}
}Write the same method in an extension on ThrowsWritableModel for throwing
companions. Relationship collections do not infer ownership fields such as
tag.book. A new related model does not need a manual context.insert; use
explicit ModelContext operations when the domain needs an independent
graph-root insert or delete, inverse ownership fields, validation, or side
effects.
The macro reports an error for these properties:
@Relationship,@Attribute, or@Transientfields. Project relationships from a writable owner model, and edit fields with@Bindableorwrite.- Arrays without
@Query, which have no SwiftData read-side source of truth. @Bindable @Querycombinations, and@Bindablewith an optional model.mutableBy:on a single model; reordering applies only to query collections.- Query results other than
[Model]orArray<Model>, such asSet<Model>. - Properties without an explicit type annotation.
swift-data-writable uses public ModelContext APIs such as insert, delete,
and save. Query execution, predicate behavior, sorting, SwiftData autosave,
and custom DataStore behavior remain owned by SwiftData.
- Swift 6.2 or later
- iOS 18 or later, macOS 15 or later, tvOS 18 or later, and watchOS 11 or later
The compiler minimum is maintained independently of the platform window. The
current system maintenance window is iOS 18, 26, and 27; macOS 15, 26, and 27;
tvOS 18, 26, and 27; and watchOS 11, 26, and 27. The macro implementation uses
SwiftSyntax 602, and Package.resolved records the revision used for
validation. The
versioning and release policy
describes compatibility and maintenance: the latest released line receives
fixes, and older-line backports are evaluated per issue.
- Writable combinations: every supported and rejected property shape, its generated surface, and the meaning of each method.
- Architecture: targets, the macro and runtime boundary, and save, relationship, and reordering semantics.
- API documentation: the DocC catalog.
- Versioning and release: compatibility, platform support, and maintenance.
- Release guide: validation and publication steps for maintainers.
- Changelog
Read CONTRIBUTING.md before opening a pull request, and run
Scripts/check before submitting changes. Report vulnerabilities through the
private route in SECURITY.md.
swift-data-writable is available under the Apache License 2.0 with the Swift Runtime Library Exception. See LICENSE and NOTICE. NOTICE also records SwiftSyntax, a package dependency whose own license and notices remain in its package.