Dev Tooling

Write Modern Swift

write-swift

How to write modern Swift well — value types, Swift 6 data-race safety and approachable concurrency, protocols and generics, API design, ARC and performance, Swift Testing and macros, including the language features agents don't know yet.

swiftconcurrencyioscode-quality
Install
mkdir -p ~/.claude/skills && curl -fsSL https://skill.metacog.co.kr/dist/write-swift.zip \
  -o /tmp/write-swift.zip && unzip -oq /tmp/write-swift.zip -d ~/.claude/skills
Files2
Size42.2 KB
Bundled foldersnone
LicenseMIT

Authored by jeonck (MIT) · Browse files on GitHub · Download zip

When Claude uses it

How to write modern Swift well — modeling with value types, Swift 6 data-race safety and approachable concurrency (@concurrent, main-actor-by-default, actors, task groups), protocols and generics (some vs any), API design, performance and ARC, Swift Testing, macros, and the modern language features agents don't know about yet. Use when writing, reviewing, or migrating Swift, or when a concurrency error, a hang, a data race, a retain cycle, or a performance problem needs fixing.

SKILL.md

Write Swift

How to write Swift the way the language wants to be written, current through Swift 6.4.

Toolchain baseline: Swift 6.3 (current release as of August 2026). Everything here compiles on 6.3 unless marked ⚠, which flags unreleased Swift 6.4 features. Concurrency guidance assumes the Swift 6.2 model — if the project is on 6.1 or earlier, §3's rules about async and @concurrent do not apply.

The through-line: Swift is a progressive-disclosure language. Start with the simplest, most static, most single-threaded thing that works, and buy dynamism — concurrency, reference semantics, existentials, unsafe pointers — only where you can point at the reason. Every rule below is an application of that.

Model this hierarchy of defaults. Move down a level only with a reason you can state:

| Need | Reach for | Move down only when | | ------------ | ----------------------- | ---------------------------------------------------------- | | Data | struct / enum | you need identity, sharing, or inheritance | | Abstraction | concrete type | you have repeated code across types | | Polymorphism | some P (generic) | you need heterogeneous storage → any P | | Execution | main actor, synchronous | profiling shows a hang → async → @concurrent → actor | | Memory | Array, String | profiling shows the cost → InlineArray, Span | | Safety | safe API | C interop or a measured hot path → Unsafe* |


1. Model data with value types

Value types are the default in Swift, not a special case.

struct Material {                       // value semantics preserved
  var roughness: Double
  private var _texture: Texture         // a class

  var color: Color {
    get { _texture.color }
    set {
      if !isKnownUniquelyReferenced(&_texture) { _texture = Texture(copying: _texture) }
      _texture.color = newValue
    }
  }
}

Noncopyable types (~Copyable) express unique ownership: a file descriptor, a bank transfer, an open resource. Suppressing the copy turns "you must not run this twice" from an assertion into a compile error, and makes deinit on a struct meaningful. Mark the finishing method consuming so the compiler proves it's the last use. Parameter ownership becomes explicit: borrowing (read-only, the default), consuming (takes it away), inout/mutating (temporary write access).


2. Errors and optionals — make the failure paths visible

Swift error handling rests on three points: sources of error are marked so they can't surprise you; errors carry enough context to act on; and recoverable errors are different from programmer mistakes.


3. Concurrency: stay single-threaded until profiling says otherwise

This is the section agents get wrong most often, because the model changed in Swift 6.2.

Start every app entirely on the main thread. Single-threaded code goes a long way, and most apps never need to leave it.

The progression, in order. Do not skip steps.

  1. Single-threaded on the main actor. No concurrency at all. Fine for most apps.
  2. async/await to hide latency (network, disk). Still no concurrency of your own — SDK APIs like URLSession.data(from:) offload on your behalf.
  3. @concurrent to move _your_ expensive work off the main thread — only after Instruments shows a hang.
  4. actor to move _state_ off the main actor — only when too much main-actor state is forcing tasks to hop back constantly.

Turn on the right build settings first. Enable Approachable Concurrency in every project. For app modules and UI-facing modules, also set Default Actor Isolation to MainActor — it's the default for new app projects in Xcode 26, and it deletes most of your @MainActor annotations. In a package: swiftSettings: [.defaultIsolation(MainActor.self)]. Do not set main-actor-by-default for a general-purpose library — libraries should ship nonisolated APIs and let clients decide where work runs.

The rule that changed

In Swift 6.2, marking a function async does _not_ move it off the current actor. It runs where it was called from. This is what makes "the most natural code to write" data-race free by default.

nonisolated struct PhotoProcessor {          // decoupled from the main actor
  @concurrent                                // guaranteed to run in the background
  func process(_ data: Data) async -> ProcessedPhoto {
    async let sticker = extractSticker(data)  // two independent jobs, in parallel
    async let colors  = extractColors(data)
    return await ProcessedPhoto(sticker: sticker, colors: colors)
  }
}

Actor reentrancy

Actors guarantee mutual exclusion, not transactions. Between two awaits on the same actor, other work runs.


4. Sendable and sharing data

Sendable marks a type safe to share across isolation domains. The compiler checks it at every task and actor boundary.

When you hit a data-race error, work down this list:

  1. Don't share it. Move the shared object into a local so each concurrent job gets its own instance. (This is the fix for the overwhelming majority of real errors.)
  2. Make it a Sendable value type, so "sharing" is really copying.
  3. Isolate it to an actor — the main actor, or your own.
  4. Only then reach for Mutex/Atomic from the Synchronization module (store them in let properties), or @unchecked Sendable.

Global and static variables are the most common source of errors. In order of preference: make it a let; put it on @MainActor; wrap it in a Mutex; nonisolated(unsafe). Note globals in Swift are initialized lazily _and_ atomically — unlike C.

Bridging old callback APIs: annotate delegate protocols with @MainActor if you own them. If you don't, mark the method nonisolated and use MainActor.assumeIsolated { } — it asserts rather than hopping, so it traps loudly instead of racing silently. @preconcurrency on the conformance is the shorthand for the same thing. Use @preconcurrency import to temporarily silence sendability warnings from a module that hasn't migrated; the warnings come back — correctly — once it does.


5. Structured concurrency

Always prefer structured tasks.

Structured tasks (async let, task groups) are scoped like local variables: they can't outlive the block, they're awaited automatically, and they inherit cancellation, priority, and task-local values through the task tree. Unstructured tasks (Task { }, Task.detached) give you none of that automatically.

Cancellation is cooperative. Cancelling sets a flag; it stops nothing. Check Task.isCancelled or try Task.checkCancellation() before starting expensive work, and in synchronous helpers too. For work that's suspended rather than running (an AsyncSequence's next()), use withTaskCancellationHandler — and remember the handler runs immediately and concurrently with the body, so the state it touches needs real synchronization (an atomic or a lock, not an actor — you can't guarantee ordering on an actor).

Bound your concurrency. Don't fan out one child per item over an unbounded list. Start N children, then add a new one each time one finishes.

Task-local values (@TaskLocal) propagate context — a request ID, a trace span — down the task tree without threading a parameter through every signature. Make them optional so unbound reads have a sensible default.

Bridging callbacks: withCheckedContinuation / withCheckedThrowingContinuation. The contract is resume exactly once on every path — never resuming hangs the caller forever; resuming twice is a fatal error. For delegate APIs that fire later, store the continuation and nil it out when you resume. (Swift 6.4 — unreleased — adds a Continuation type that checks single-resumption at compile time.)

AsyncSequence: iterate with for await / for try await. Adapt an existing handler- or delegate-based API with AsyncStream / AsyncThrowingStream — construct the source inside the closure, yield from the handler, and clean up in onTermination.


6. Concurrency in SwiftUI

.visualEffect { [pulse] effect, proxy in    // copy the Bool, don't capture self
  effect.blur(radius: pulse ? 2 : 0)
}

7. Protocols and generics

Don't start with a class. Don't start with a protocol either.

The workflow: write concrete types → notice repeated code across them → factor the shared capability into a protocol → write generic code against it. Overloads with near-identical bodies are the signal that it's time to generalize.

some vs any


8. API design — clarity at the point of use

Clarity at the point of use is the goal that outranks every other one here.


9. Performance — measure, then choose

Low-level Swift performance is dominated by four costs. Know which one you're paying.

  1. Function calls — argument copies, static vs dynamic dispatch, call-frame allocation, and blocked optimization.
  2. Memory layout — inline vs out-of-line storage; dynamically sized types.
  3. Allocation — global (free), stack (cheap: one subtraction), heap (expensive: search plus locking).
  4. Copies — retains/releases and recursive struct copies.

But do the algorithmic work first. Every time you write a loop, try replacing it with a call to an algorithm. The largest wins are almost never micro-optimizations:

Concrete levers, roughly in order of what they buy:

Async functions keep their state on a per-task slab allocator rather than the C stack, and split into partial functions at each suspension point. The cost profile is similar to sync functions with slightly higher call overhead — which is another reason not to make something async that has nothing to await.

Hops to and from the main actor cost a real context switch. Batch: push the loop _into_ loadArticles/updateUI so they take arrays, rather than hopping twice per iteration.


10. ARC and object lifetime


11. Testing — Swift Testing by default

Use Swift Testing for new tests. XCTest remains required for exactly three things: UI automation (XCUIApplication), performance metrics (XCTMetric), and tests that must be written in Objective-C or that catch Objective-C exceptions.


12. Macros

Reach for a macro when you're writing code the compiler could derive — and only then.


13. Logging and debugging


14. Unsafe code and interop


15. Modern syntax you should be using

Agents routinely write the older, longer form of all of these.

Rows marked ⚠ are Swift 6.4, which has not shipped. The current release is 6.3.x. Their proposals are accepted and implemented in main, so they are safe to plan around and unsafe to write today — check the project's toolchain before using one, and prefer the older form if it targets 6.3 or earlier.

| Instead of | Write | Since | | --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | ----- | | Nested ternaries; an immediately-called closure to initialize a let | if/switch expressions | 5.9 | | Overloads for 1, 2, 3… arguments | parameter packs (each T), and for over a pack | 5.9 | | ObservableObject + @Published on every property | @Observable | 5.9 | | Polling an object for changes | Observations { ... } — an AsyncSequence of transactional updates | 6.2 | | NotificationCenter with stringly-typed userInfo | concrete notification types (MainActorMessage / AsyncMessage) | 6.2 | | Process + pipes for scripting | the Subprocess package (AsyncBufferSequence.strings() for line-by-line output; 1.0 lands with 6.4) | 6.2+ | | Hand-rolled string index math | Swift Regex — literals for brevity, RegexBuilder for structure | 5.7 | | [String] of fixed size in a hot path | InlineArray<N, T> | 6.2 | | withUnsafeBufferPointer | .span / .bytes (RawSpan) / OutputSpan | 6.2 | | Manual Task.isCancelled juggling to finish a write | Task cancellation shield (SE-0504) | 6.4 ⚠ | | Rebuilding a dictionary by hand to use the key | mapKeyedValues | 6.4 ⚠ | | @available(iOS ..., macOS ..., tvOS ..., watchOS ..., visionOS ...) | @available(anyAppleOS ...) | 6.4 ⚠ | | Rocket.SaturnV when a type shadows a module | module selector Rocket::SaturnV | 6.3 | | Blanket "warnings as errors" | @diagnose per declaration / warning group | 6.4 ⚠ | | @unchecked Sendable because of a weak var | weak let; or state non-sendability with ~Sendable | 6.4 ⚠ | | Manually parsing binary formats with pointers | Swift Binary Parsing (ParserSpan, overflow-checked parsing initializers) | 6.2 | | Awkward test function names | raw identifiers: ` @Test func fruits have a tropical climate() ` | 6.0 |

Also worth knowing: Swift Regex parsers compose with Foundation's real parsers (.date(...), .currency(...)) — never hand-roll date or number parsing inside a regex. Make the locale explicit rather than inheriting the system's. And use NegativeLookahead or Local (atomic groups) to stop a pattern backtracking across a whole input.


16. Migrating an existing codebase to Swift 6

The order matters, and mixing steps is how migrations stall.

  1. Build with the new compiler first. Source compatibility means this should just work, in Swift 5 mode.
  2. Per target, enable complete concurrency checking (Swift 5 mode + all Swift 6 warnings). Start with the UI/app layer, not the frameworks below it — much of it is already main-actor-annotated by the SDK, so the fix rate is high.
  3. Fix the warnings, cheapest first. Expect hundreds of warnings from a handful of root causes: var globals that should be let, free functions that belong on @MainActor, one public struct that needs : Sendable. A single line can clear dozens.
  4. Flip the target to the Swift 6 language mode to lock the work in.
  5. Move to the next target and repeat.
  6. Refactor afterwards, separately. Never combine a significant refactor with enabling data-race safety — you'll have to back out both.

You can turn strict checking back off and ship; every fix you made is a genuine improvement that survives. Enable Approachable Concurrency and, for app modules, main-actor-by-default _before_ you start — both dramatically reduce the number of errors you'll see, and Xcode ships migration tooling that applies many of the changes for you (swift.org/migration).


Quick Reference

| Need | Reach for | Not | | ---------------------------------- | ------------------------------- | ----------------------------------------- | | A data type | struct / enum | class without identity or sharing | | Shared mutable state | actor, or @MainActor class | class + a lock you must remember | | Move work off the main thread | @concurrent func … async | Task.detached, DispatchQueue.global() | | A library API's isolation | nonisolated | @MainActor, @concurrent | | Fixed number of parallel jobs | async let | N unstructured Tasks | | Dynamic number of parallel jobs | withTaskGroup (bounded) | one task per element, unbounded | | Children that return nothing | withDiscardingTaskGroup | withTaskGroup you never drain | | Work tied to a UI event | Task { } inside the callback | making the callback async | | Fixing a data race | stop sharing the object | @unchecked Sendable | | A shared model class | non-Sendable, or @MainActor | Sendable + manual locking | | Blocking primitive across await | nothing — restructure | DispatchSemaphore, NSCondition | | Polymorphism | some P | any P unless you need storage | | Heterogeneous collection | [any P] | a class hierarchy | | Shared behavior, no customization | constrained extension | a new protocol | | A customization point | protocol requirement | a method only in an extension | | Breaking a reference cycle | restructure to a tree | weak + withExtendedLifetime | | Removing matching elements | removeAll(where:) — O(n) | remove(at:) in a loop — O(n²) | | Direct access to contiguous memory | .span | withUnsafeBufferPointer | | Fixed-size buffer in a hot path | InlineArray<N, T> | Array | | A new test | @Test + #expect | XCTestCase + XCTAssertEqual | | The same test over many inputs | @Test(arguments:) | a for loop, or copy-paste | | Halting a test on failure | try #require | continueAfterFailure = false | | A temporarily broken test | withKnownIssue | .disabled, or commenting it out | | Diagnostics in shipping code | Logger + a correlation ID | print | | Deciding to optimize | Instruments on a profiled test | intuition |