To preserve atomic compare-and-replace when moving a Swift model to an actor, keep the comparison and assignment inside one synchronous actor-isolated method. Callers use await, but the method itself must not suspend between checking and replacing.

This walkthrough is for a Mac developer migrating a small desktop model previously protected by a lock. The concrete task is to replace a synthetic shape label only if its current value still matches an expected value. The example operates entirely in memory and uses no network requests, external records, or persistence.

Define the value contract before changing the model

For this example, compareAndReplace(expected:replacement:) has three rules: compare the current label with expected; on equality, assign replacement and return true; otherwise, return false without changing the label. The comparison uses Swift value equality. The standard library’s Equatable documentation defines that comparison through == and distinguishes value equality from class-instance identity.

The comparison must use the value current when the operation executes. An earlier snapshot can supply the expected value, but it cannot establish that the replacement should succeed later. That decision belongs inside the operation that owns the mutation.

Current value Expected value Replacement Result Value afterward
"Circle" "Circle" "Square" true "Square"
"Square" "Circle" "Triangle" false "Square"
"Square" "Square" "Square" true "Square"

The last row is intentional: success means the comparison matched and the assignment was accepted. It does not mean the visible value changed. Changing that return convention during migration would change the contract.

In a lock-based implementation, the entire comparison and assignment must occupy the same protected critical section. Apple documents Foundation’s NSLock as a way to protect a critical section so it runs atomically. For the actor migration, preserve that operation boundary: move the whole decision into the actor, rather than distributing its parts among callers.

Here, “atomic” means another operation on this actor cannot interleave between the comparison and assignment. This example makes no claim about hardware atomic instructions or lock-free execution.

Why the caller’s await does not break the operation

A synchronous actor method can require await when called from outside its isolation domain. The caller may need to suspend while waiting to enter the actor. Once the method executes, its ordinary synchronous body has no suspension point at which another task can enter and change the same actor’s state. Swift documents this distinction in SE-0306: Actors.

The language guide recommends encapsulating updates in synchronous actor methods because those methods cannot contain potential suspension points. Apple’s actor session likewise explains that synchronous actor code completes without another task interrupting its actor-isolated execution. These are the guarantees used to derive this implementation. See Concurrency and Protect mutable state with Swift actors.

The useful boundary is therefore one actor call containing both the condition and its resulting mutation. A caller can read a snapshot first, perform a local calculation, and then submit a conditional replacement. The actor must check the condition again when processing that submission.

What goes wrong with separate reads and writes?

Suppose a migration exposes separate snapshot and unconditional replacement methods. One caller reads "Circle". Another caller changes the label to "Square". The first caller then writes "Triangle" based on its earlier read. Every individual method could respect actor isolation, yet the overall sequence would fail the intended compare-and-replace contract.

This illustrative schedule explains the defect without depending on a particular task scheduler. Actor isolation protects each operation; the programmer still needs to choose an operation large enough to preserve the application’s condition.

Build a complete local example on your Mac

Use an installed Swift 6 toolchain. Swift’s official macOS installation guide describes installation through Swiftly and development with Xcode. In Terminal, check the selected compiler before proceeding:

swiftc --version

Save the following complete program as CompareReplace.swift in a working folder. It uses only the Swift standard library, which is available without an explicit import. The file defines the actor, its initial state, all sample inputs, and an asynchronous entry point.

actor ShapeLabelModel {
    private var label: String

    init(initial: String) {
        label = initial
    }

    func snapshot() -> String {
        label
    }

    func compareAndReplace(
        expected: String,
        replacement: String
    ) -> Bool {
        guard label == expected else {
            return false
        }
        label = replacement
        return true
    }
}

@main
struct CompareReplaceDemo {
    static func main() async {
        let model = ShapeLabelModel(initial: "Circle")

        let matched = await model.compareAndReplace(
            expected: "Circle", replacement: "Square"
        )
        let afterMatch = await model.snapshot()
        precondition(matched && afterMatch == "Square")

        let stale = await model.compareAndReplace(
            expected: "Circle", replacement: "Triangle"
        )
        let afterStale = await model.snapshot()
        precondition(!stale && afterStale == "Square")

        let unchanged = await model.compareAndReplace(
            expected: "Square", replacement: "Square"
        )
        precondition(unchanged)

        let contested = ShapeLabelModel(initial: "Circle")
        async let first = contested.compareAndReplace(
            expected: "Circle", replacement: "Square"
        )
        async let second = contested.compareAndReplace(
            expected: "Circle", replacement: "Triangle"
        )
        let (firstWon, secondWon) = await (first, second)
        let finalLabel = await contested.snapshot()

        precondition(firstWon != secondWon)
        precondition(
            finalLabel == (firstWon ? "Square" : "Triangle")
        )

        let cycle = ShapeLabelModel(initial: "Circle")
        let oldSnapshot = await cycle.snapshot()
        let movedAway = await cycle.compareAndReplace(
            expected: "Circle", replacement: "Square"
        )
        let movedBack = await cycle.compareAndReplace(
            expected: "Square", replacement: "Circle"
        )
        let matchedAgain = await cycle.compareAndReplace(
            expected: oldSnapshot, replacement: "Triangle"
        )
        let afterCycle = await cycle.snapshot()
        precondition(movedAway && movedBack && matchedAgain)
        precondition(afterCycle == "Triangle")

        print("Matching replacement: \(matched)")
        print("Stale replacement: \(stale)")
        print("Equal replacement: \(unchanged)")
        print("Exactly one competing replacement succeeded.")
        print("A restored value matches the old snapshot.")
    }
}

Compile and run it from that folder. The -parse-as-library option accommodates the explicit @main entry point, while -swift-version 6 selects Swift 6 language mode.

swiftc -swift-version 6 -parse-as-library CompareReplace.swift -o compare-demo
./compare-demo

The expected output, derived from the program’s checks, is:

Matching replacement: true
Stale replacement: false
Equal replacement: true
Exactly one competing replacement succeeded.
A restored value matches the old snapshot.

The code was inspected against the documentation, but was not executed here because this environment has no Swift compiler. The included preconditions provide local checks when you run it; they are not a claim of completed execution.

What the competing calls establish

The two async let declarations create child tasks without waiting for the first result before starting the second. That use of structured concurrency follows the language guide’s explanation of concurrent asynchronous calls. Both callers expect "Circle", and each proposes a different replacement.

For this specific setup, exactly one succeeds: whichever executes first changes the value away from "Circle"; the other then fails its comparison. The test permits either winner. Swift does not guarantee that tasks awaiting an actor execute in the order they originally awaited it, as documented in SE-0306.

The example also separates awaited calls from precondition expressions. It first stores each result in a local constant, then checks that constant. This keeps asynchronous work outside the synchronous assertion expressions.

Keep asynchronous preparation outside the final comparison

A future version might calculate a proposed label asynchronously using other synthetic local work. Complete that preparation before calling the synchronous compare-and-replace method. Pass the original expected value and the calculated replacement together; handle false as a rejected attempt.

An actor method that checks equality, awaits preparation, and then assigns unconditionally has a gap. During its suspension, another task can change the actor’s state. The resumed method would be relying on a condition that may no longer hold. Swift’s documented actor reentrancy model explicitly permits this interleaving.

If preparation lives in an asynchronous actor method, invoke the synchronous comparison method after preparation returns. That final method must perform a fresh comparison. A check performed before suspension can help avoid unnecessary work, but cannot authorize the eventual assignment.

On a mismatch, retain the proposed result if the desktop interface needs to show it, obtain a fresh snapshot, and choose whether to recalculate or report the conflict. Automatically replacing the expected value and retrying would change the request from “replace the value I observed” into “keep trying against newer values.” Make that behavior an explicit product decision.

Do not carry the old Foundation lock across an await. Apple requires an NSLock to be unlocked on the same thread that locked it. The actor design shown here achieves the needed outcome through a short synchronous isolated operation, without retaining a lock across asynchronous work.

Preserve equality semantics and recognize their limits

The cycle check demonstrates a subtle boundary. A caller snapshots "Circle"; the model changes to "Square", then back to "Circle". A comparison against the old snapshot succeeds because the contract compares the current value, not the sequence of changes leading to it.

If your actual requirement is “replace only if no accepted mutation has occurred since my snapshot,” value comparison alone is insufficient. A supported design is to return a snapshot containing both a value and a revision token, then check the token and update the value together inside one synchronous actor method. That is a stronger, different contract. Define token advancement and exhaustion behavior before implementing it; do not silently introduce it during a value-preserving migration.

For larger models, use a snapshot value whose equality covers every field relevant to replacement. Swift can synthesize Equatable for a structure when all stored properties conform, according to the Equatable reference. An equality implementation that ignores a relevant field would allow replacements despite changes to that field.

Keep values crossing the actor boundary suitable for concurrent use. The Sendable documentation explains that sendable values can pass between concurrency domains and that sendable structures must contain sendable members. Wrapping an arbitrary mutable reference in a structure does not establish that property.

For this local model, the migration is complete when the actor owns the mutable label, every conditional replacement enters through the combined operation, and callers interpret its Boolean result according to the original contract. Keep the final comparison and assignment together, then let the surrounding desktop workflow decide what a rejected attempt means.

Sources