Languages No. 121

Swift Concurrency skill for Claude Code

Reach Swift 6 mode with zero data-race warnings. Claude migrates Swift code to strict concurrency one module at a time, reading each isolation diagnostic for its real cause instead of reaching for @unchecked Sendable. It isolates UI models to MainActor, puts shared state in actors or a Mutex, replaces completion handlers with checked continuations, and understands how Swift 6.2 default isolation changes the rules.

  • nameswift-concurrency
  • size211 lines · ≈2,250 tokens
  • files1
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Migrates and writes Swift 6 strict-concurrency code — async/await, actors, MainActor isolation, Sendable conformance, sending parameters, structured tasks with async let and task groups, cancellation, and bridging callbacks with continuations, including Swift 6.2 approachable concurrency settings. Use when Xcode shows Sendable or actor-isolation errors, when moving to the Swift 6 language mode, replacing GCD or completion handlers, or debugging data races and UI updates off the main thread.

Try asking

  • Xcode shows 80 Sendable warnings after enabling strict concurrency — work through them
  • Convert this completion-handler networking layer to async/await
  • Is this actor-based image cache safe given reentrancy?

Use it when

  • Turning on complete concurrency checking or the Swift 6 language mode
  • Seeing MainActor, Sendable, or sending diagnostics
  • Replacing GCD queues or completion handlers with async/await
  • Designing shared mutable state across tasks

Skip it when

How to check the result

The module builds in Swift 6 mode with no concurrency warnings, every unchecked or unsafe annotation has a written reason, and tests pass under Thread Sanitizer.

The common mistake it prevents

Marking a mutable class @unchecked Sendable to make errors disappear, which hides a real data race from the compiler permanently.

~/.claude/skills/swift-concurrency/SKILL.md

--- name: swift-concurrency description: Migrates and writes Swift 6 strict-concurrency code — async/await, actors, MainActor isolation, Sendable conformance, sending parameters,… license: MIT ---

Done well means: the target builds in the Swift 6 language mode (or with complete strict-concurrency checking) with zero warnings, UI state is MainActor-isolated, shared mutable state lives in an actor or behind a Mutex, every Task {} has a reason and a cancellation story, and @unchecked Sendable appears only with a written justification.

Ground rules

  • Fix isolation, do not silence it. nonisolated(unsafe), @unchecked Sendable, and @preconcurrency are escape hatches for code you do not own or have proven safe.
  • Prefer structured concurrency (async let, withTaskGroup) over unstructured Task {}. Unstructured tasks do not inherit cancellation from the caller.
  • Value types (structs, enums with Sendable payloads) are the default way to cross isolation boundaries.
  • Do not wrap everything in actors. An actor is for state shared by concurrent callers; a view model touched only by UI is @MainActor.
  • Never block a concurrency thread: no DispatchSemaphore.wait, Thread.sleep, or synchronous network calls inside async code.

Inspect the configuration first

grep -nE "swift-tools-version|swiftLanguageMode|StrictConcurrency|enableUpcomingFeature|defaultIsolation" Package.swift
grep -nE "SWIFT_VERSION|SWIFT_STRICT_CONCURRENCY|SWIFT_DEFAULT_ACTOR_ISOLATION|SWIFT_APPROACHABLE_CONCURRENCY" *.xcodeproj/project.pbxproj
swift --version
SettingMeaning
Swift 5 mode + SWIFT_STRICT_CONCURRENCY = completeData-race diagnostics as warnings; migration stage
Swift 6 language modeSame diagnostics as errors
Default actor isolation = MainActor (Swift 6.2)Unannotated code in the module is MainActor; good for app targets, not for libraries
Approachable concurrency / NonisolatedNonsendingByDefault (6.2)Nonisolated async functions run on the caller’s actor unless marked @concurrent

Know which mode the module is in before interpreting any diagnostic; the same code behaves differently under 6.2’s defaults.

Migration workflow

- [ ] 1. Enable complete checking in Swift 5 mode, one module at a time, leaf modules first
- [ ] 2. Mark UI types (view models, coordinators, UIKit/AppKit subclasses) @MainActor
- [ ] 3. Make model/value types Sendable (usually automatic for structs of Sendable fields)
- [ ] 4. Convert shared mutable singletons/caches to actors or Mutex-protected classes
- [ ] 5. Replace completion handlers with async APIs; bridge legacy ones with continuations
- [ ] 6. Use @preconcurrency import only for third-party modules not yet annotated
- [ ] 7. Reach zero warnings, then flip the module to the Swift 6 language mode
- [ ] 8. Run tests with Thread Sanitizer enabled

Package.swift for a module in Swift 6 mode:

// swift-tools-version: 6.0
let package = Package(
    name: "Core",
    targets: [
        .target(name: "Core"),                     // tools 6.0 defaults targets to Swift 6 mode
        .target(name: "Legacy", swiftSettings: [
            .swiftLanguageMode(.v5),
            .enableUpcomingFeature("StrictConcurrency"),  // warnings while migrating
        ]),
    ]
)

Diagnostic to fix

Diagnostic (paraphrased)Usual fix
Main actor-isolated property accessed from nonisolated contextMark the caller @MainActor, or await MainActor.run {} at the boundary
Non-sendable type passed across actor boundaryMake it a struct / Sendable; or pass with sending; or copy the needed values
Capture of non-sendable in @Sendable closureCapture values, not the object; or isolate the closure to the actor that owns the object
Static property is not concurrency-safeMake it let of a Sendable type, isolate it to @MainActor, or move it into an actor
Actor-isolated method cannot satisfy nonisolated protocol requirementIsolated conformance (6.2, extension X: @MainActor P) or make the method nonisolated reading only immutable state
Sending value risks causing data racesThe value is used after being sent; stop using it, or copy before sending

Isolation patterns

@MainActor
@Observable
final class ProfileModel {
    private(set) var state: LoadState<Profile> = .idle
    private let client: ProfileClient                 // Sendable (struct or actor)

    init(client: ProfileClient) { self.client = client }

    func load(id: User.ID) async {
        state = .loading
        do {
            let profile = try await client.fetch(id)   // runs off main if client is nonisolated/@concurrent
            state = .loaded(profile)
        } catch is CancellationError {
            // view went away; leave state alone
        } catch {
            state = .failed(error.localizedDescription)
        }
    }
}

actor ImageCache {
    private var images: [URL: Data] = [:]
    private var inFlight: [URL: Task<Data, Error>] = [:]

    func data(for url: URL) async throws -> Data {
        if let cached = images[url] { return cached }
        if let task = inFlight[url] { return try await task.value }   // dedupe concurrent loads
        let task = Task { try await URLSession.shared.data(from: url).0 }
        inFlight[url] = task
        defer { inFlight[url] = nil }
        let data = try await task.value
        images[url] = data
        return data
    }
}

Actor reentrancy: state can change across every await inside an actor. Re-check invariants after awaiting, and record in-flight work (as above) instead of assuming nothing happened in between.

For synchronous, fine-grained shared state, Mutex from the Synchronization module (iOS 18 / macOS 15) avoids making callers async:

import Synchronization
final class Counter: Sendable {
    private let value = Mutex(0)
    func increment() -> Int { value.withLock { $0 += 1; return $0 } }
}

Structured tasks and cancellation

func loadDashboard() async throws -> Dashboard {
    async let user = api.user()
    async let feed = api.feed()
    return try await Dashboard(user: user, feed: feed)   // if one throws, the other is cancelled
}

func thumbnails(for urls: [URL]) async throws -> [URL: Image] {
    try await withThrowingTaskGroup(of: (URL, Image).self) { group in
        for url in urls.prefix(100) {
            group.addTask { (url, try await render(url)) }
        }
        var result: [URL: Image] = [:]
        for try await (url, image) in group { result[url] = image }
        return result
    }
}
  • Bound concurrency in task groups: add the first N tasks, then add one more each time one completes.
  • Cancellation is cooperative: call try Task.checkCancellation() in loops; URLSession async APIs already honor it.
  • In SwiftUI use .task { } / .task(id:); it cancels when the view disappears. Avoid Task {} in onAppear.
  • Store unstructured Task handles you may need to cancel and cancel them in deinit or when the owner stops.
  • Task.detached almost never; it drops priority, task-locals, and actor context.

Bridging legacy APIs

func currentLocation() async throws -> CLLocation {
    try await withCheckedThrowingContinuation { continuation in
        locator.requestLocation { result in
            continuation.resume(with: result)   // resume exactly once on every path
        }
    }
}
  • Resume exactly once. Missing a resume leaks the task forever; resuming twice crashes (checked continuation) or corrupts (unsafe).
  • Delegate or multi-value callbacks become AsyncStream with onTermination cleanup.
  • Many Apple APIs already have async variants; check before writing a continuation.

Gotchas

  • @MainActor on a class does not make its nonisolated or Task.detached code run on main.
  • Task {} created inside a @MainActor method inherits MainActor; heavy work inside it blocks the UI. Move the work into a nonisolated or @concurrent async function.
  • Under Swift 6.2 approachable concurrency, a plain nonisolated async function runs on the caller’s actor; mark CPU-heavy functions @concurrent to leave the main actor.
  • @unchecked Sendable on a class with mutable state and no lock is a data race the compiler can no longer report.
  • Global let constants of non-Sendable class types are errors; global var always is.
  • Combine sink and NotificationCenter closures run on whatever thread posts; hop with receive(on:) or use the async sequence variants.

Verification

  1. Build with Swift 6 language mode (or complete checking): zero concurrency warnings.
  2. grep -rn "@unchecked Sendable\|nonisolated(unsafe)\|@preconcurrency" Sources — each hit has a comment explaining why it is safe.
  3. Run the test suite with Thread Sanitizer (swift test --sanitize=thread, or the Xcode scheme diagnostic) and the Main Thread Checker enabled.
  4. Manually exercise cancel paths: leave a screen mid-load and confirm no state update or crash happens afterwards.

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc