Overview
Idiomatic, production-grade Swift 6: SwiftPM packages, value semantics, optionals, protocol-oriented generics, and structured concurrency under strict data-race checking. Assumes the reader knows the syntax. The value is in what is easy to get wrong: protocol-extension dispatch, actor reentrancy, Sendable escape hatches, continuation misuse, Codable defaults, and two test frameworks that share one swift test command.
Tooling
SwiftPM is the build tool and package manager. Xcode projects (.xcodeproj) build with xcodebuild; keep libraries in a SwiftPM package even when an app wraps them, so they build and test on Linux and in CI without a simulator.
| Task | Command |
|---|---|
| New package | swift package init --type library (or --type executable) |
| Add a dependency | swift package add-dependency https://github.com/apple/swift-argument-parser --from 1.5.0 (6.0+) |
| Wire it to a target | swift package add-target-dependency ArgumentParser spool-cli --package swift-argument-parser |
| Resolve / update | swift package resolve / swift package update (rewrites Package.resolved) |
| Build with tests | swift build --build-tests |
| Test | swift test (--parallel for XCTest; Swift Testing runs tests in parallel by default) |
| List test ids | swift test list |
| Release build | swift build -c release (whole-module optimization) |
| Format | swift format --in-place --recursive Sources Tests (swift-format ships with the 6.0 toolchain) |
| Lint | swift format lint --strict --recursive Sources Tests; swiftlint lint --strict where .swiftlint.yml exists |
// swift-tools-version: 6.0
// The tools version line selects the manifest API AND the Swift 6 language mode for every target.
import PackageDescription
let package = Package(
name: "Spool",
platforms: [.macOS(.v14), .iOS(.v17)],
products: [.library(name: "Spool", targets: ["Spool"])],
dependencies: [
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.5.0"),
],
targets: [
.target(name: "Spool"),
.executableTarget(name: "spool-cli", dependencies: [
"Spool",
.product(name: "ArgumentParser", package: "swift-argument-parser"),
]),
.testTarget(name: "SpoolTests", dependencies: ["Spool"]),
]
)
- Commit
Package.resolvedfor applications and executables. Libraries may leave it out: consumers resolve their own graph. - Migrating an older target:
swiftSettings: [.swiftLanguageMode(.v5), .enableUpcomingFeature("StrictConcurrency")]keeps Swift 5 mode while surfacing the data-race diagnostics as warnings; remove both once they are fixed. - Keep executables thin. Logic lives in a library target so tests
@testable importit.
The canonical gate:
swift format lint --strict --recursive Sources Tests
swift build --build-tests -Xswiftc -warnings-as-errors
swift test --parallel
If -warnings-as-errors trips on a dependency's code, drop the flag and fail CI on warning: lines under your own Sources/ and Tests/ instead.
Value Types and Copy-on-Write
- Default to
structandenum. Useclasswhen identity or shared mutable state is the point (a connection, a cache, a UIKit object). A struct copy mutates independently, which removes aliasing bugs and makesSendableconformance free when every stored property isSendable. - A struct holding a class reference copies the reference. Both copies share the object; value semantics stop at that field.
Array,Dictionary,SetandStringare copy-on-write: a copy is O(1) and the first mutation of a shared buffer copies it. A struct that wraps its own class storage must implement CoW itself.- Exclusivity: overlapping
inoutaccess to one variable is a compile error when the compiler can see it and a runtime trap ("Simultaneous accesses") when it cannot.
final class Storage { var bytes: [UInt8] = [] }
struct Buffer {
private var storage = Storage()
mutating func append(_ byte: UInt8) {
if !isKnownUniquelyReferenced(&storage) { // shared: copy before writing
let copy = Storage()
copy.bytes = storage.bytes
storage = copy
}
storage.bytes.append(byte)
}
}
var counts = [1, 2, 3]
swap(&counts[0], &counts[1]) // error: overlapping accesses to 'counts'
counts.swapAt(0, 1) // the API for this case
Optionals and Errors
- Unwrap with
if let value,guard let value else { return }(5.7 shorthand),??and optional chaining.guardkeeps the success path unindented. !,try!andas!assert a fact the program itself guarantees. Data from outside the program (environment, files, network, user input) is never such a fact.- Model failures as an
enumconforming toErrorwith associated values.try?discards the reason; use it only where the reason does not matter. - Typed throws (6.0):
throws(SpoolError)fits module-internal code and closed error sets. Keep untypedthrowson public API: a typed error set is part of the signature and cannot grow without breaking callers. preconditionandfatalErrorsurvive-O;assertis compiled out of release builds. Never validate input withassert.
enum ConfigError: Error, Equatable {
case missing(key: String)
case invalid(key: String, value: String)
}
func port(from env: [String: String]) throws(ConfigError) -> Int {
guard let raw = env["PORT"] else { throw .missing(key: "PORT") }
guard let value = Int(raw), (1...65_535).contains(value) else {
throw .invalid(key: "PORT", value: raw)
}
return value
}
do {
let p = try port(from: ProcessInfo.processInfo.environment)
print("listening on \(p)")
} catch ConfigError.missing(let key) {
print("set \(key)")
} catch {
print("bad config: \(error)")
}
Protocols and Generics
some Pis one concrete type chosen at compile time: static dispatch, specialization, no boxing.any Pis an existential box with dynamic dispatch and possible heap allocation. Default tosome Por a generic parameter; useany Pfor heterogeneous storage.- Primary associated types constrain both forms:
some Collection<Int>,any AsyncSequence<Event, Never>. - Conditional conformance:
extension Array: Drawable where Element: Drawable {}. - Conforming a type you do not own to a protocol you do not own warns in Swift 6; mark it
extension URL: @retroactive Identifiableonly when you accept that the owner may add the same conformance later. - ⚠ Only protocol requirements dispatch dynamically. A method defined only in a protocol extension is picked by the static type, so a conforming type's same-named method is ignored through
any Por a genericT: P.
protocol Greeter {
func greet() -> String // requirement: dynamic dispatch
}
extension Greeter {
func greet() -> String { "hello" }
func farewell() -> String { "bye" } // extension only: static dispatch
}
struct Loud: Greeter {
func greet() -> String { "HELLO" }
func farewell() -> String { "BYE" }
}
let g: any Greeter = Loud()
g.greet() // "HELLO"
g.farewell() // "bye": the extension method, chosen at compile time
Structured Concurrency
async letruns a fixed number of children concurrently;withThrowingTaskGroupruns a dynamic number. When the scope exits by error, remaining children are cancelled and awaited.- Cancellation is cooperative. Check
try Task.checkCancellation()in loops;Task.sleepthrowsCancellationError. - An unstructured
Task { }inherits the caller's actor and priority but no cancellation.Task.detachedinherits nothing. Keep the handle of every unstructured task and cancel it when its owner goes away. - Actors serialize access to their state, and they are reentrant: at every
awaitother calls may run and change that state. Re-check invariants after each suspension. @MainActorisolates UI state and view models.Sendable: structs and enums whose members areSendableconform implicitly (inside the module). A class conforms when it isfinalwith onlyletSendableproperties, or when a lock guards its state and it is marked@unchecked Sendablewith a comment naming the lock.Mutexfrom theSynchronizationmodule (6.0, macOS 15 / iOS 18) is that lock.- Swift 6 language mode turns data-race diagnostics into errors.
@preconcurrency import LegacyKitquiets diagnostics from a module that has not adoptedSendableyet. - Swift 6.2 adds
.defaultIsolation(MainActor.self)per target and theNonisolatedNonsendingByDefaultupcoming feature, under which a nonisolatedasyncfunction runs on the caller's actor unless marked@concurrent. Read the target'sswiftSettingsbefore reasoning about where an async function runs.
func loadDashboard(user: User.ID) async throws -> Dashboard {
async let profile = api.profile(user) // both requests start here
async let orders = api.orders(user)
return try await Dashboard(profile: profile, orders: orders)
}
func fetchAll(_ ids: [Item.ID]) async throws -> [Item] {
try await withThrowingTaskGroup(of: Item.self) { group in
for id in ids {
group.addTask { try await api.item(id) }
}
var items: [Item] = []
for try await item in group { items.append(item) }
return items // a child's error cancels the rest
}
}
actor DownloadCache {
private var cache: [URL: Data] = [:]
private var inFlight: [URL: Task<Data, Error>] = [:]
func data(for url: URL) async throws -> Data {
if let hit = cache[url] { return hit }
if let running = inFlight[url] { return try await running.value }
let task = Task { try await download(url) }
inFlight[url] = task // record before suspending
defer { inFlight[url] = nil }
let data = try await task.value // other callers run during this await
cache[url] = data
return data
}
}
Bridging callback APIs: a checked continuation must be resumed exactly once on every path. Resuming twice traps; never resuming leaks the task and logs SWIFT TASK CONTINUATION MISUSE.
func fetch(_ url: URL) async throws -> Data {
try await withCheckedThrowingContinuation { continuation in
legacyClient.fetch(url) { result in
continuation.resume(with: result) // Result<Data, Error>: one resume on every path
}
}
}
Modules and Access Control
- Each target is a module.
internal(the default) is module-wide;publicexposes API;openalso allows subclassing and overriding outside the module;package(5.9) spans the modules of one package;fileprivateandprivatenarrow further. - Tests reach
internalsymbols with@testable import Spool. Do not widen access for a test. - Resources:
resources: [.process("Fixtures")]on the target, read throughBundle.module. @unknown defaultin aswitchover an enum from another module (SDK enums, resilient libraries) keeps the code compiling when the enum gains a case, and warns so you handle it.
SwiftUI Essentials
- Views are values that SwiftUI recreates often. Keep
bodycheap and free of side effects. - State ownership:
@Statefor view-owned values and for an@Observablemodel the view creates;@Bindingfor a child that writes its parent's value;@Bindablefor bindings into an@Observablemodel;@Environmentfor injected shared objects.@StateObjectand@ObservedObjectremain forObservableObjectbefore iOS 17. .task { }runs async work tied to the view's lifetime and cancels it when the view disappears;.task(id:)restarts when the id changes. Prefer it toonAppear { Task { } }, which never cancels.
import Observation
import SwiftUI
@MainActor
@Observable
final class SpoolModel {
var entries: [String] = []
var failure: String?
func reload(client: SpoolClient) async {
do { entries = try await client.list() } catch { failure = error.localizedDescription }
}
}
struct SpoolView: View {
let client: SpoolClient
@State private var model = SpoolModel()
var body: some View {
List(model.entries, id: \.self) { Text($0) }
.overlay { if let failure = model.failure { Text(failure) } }
.task { await model.reload(client: client) } // cancelled when the view goes away
}
}
Testing
Two frameworks run under one swift test:
| XCTest | Swift Testing (6.0 toolchain) | |
|---|---|---|
| Declare | final class FooTests: XCTestCase with func testX() |
@Test func x(), optionally in a @Suite type |
| Assert | XCTAssertEqual, XCTAssertThrowsError, XCTUnwrap |
#expect(a == b), #expect(throws:), try #require(x) |
| Setup | setUpWithError / tearDownWithError, addTeardownBlock |
the suite's init (a fresh instance per test), deinit on a class suite |
| Parameterize | loop inside one test | @Test(arguments: [...]), one result per argument |
| Parallelism | serial unless --parallel |
parallel by default; .serialized trait to opt out |
import XCTest
@testable import Spool
final class SpoolWriterTests: XCTestCase {
private var dir: URL!
override func setUpWithError() throws {
dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
}
override func tearDownWithError() throws {
try FileManager.default.removeItem(at: dir)
}
func testAppendsOneRecord() throws {
let writer = try SpoolWriter(path: dir.appendingPathComponent("spool").path)
try writer.append(Data("x".utf8))
let segment = try XCTUnwrap(writer.segments.first)
XCTAssertEqual(segment.size, 1)
}
func testDrainsAsync() async throws {
let writer = try SpoolWriter(path: dir.appendingPathComponent("spool").path)
let drained = try await writer.drain()
XCTAssertEqual(drained, 0)
}
}
import Foundation
import Testing
@testable import Spool
@Suite struct RotationTests {
let dir: URL
init() throws {
dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
}
@Test(arguments: [1, 64, 4096])
func rotatesAtLimit(limit: Int) throws {
let spool = try Spool(directory: dir, maxBytes: limit)
try spool.append(Data(repeating: 0, count: limit + 1))
#expect(spool.segments.count == 2)
}
@Test func unknownSegmentThrows() throws {
let spool = try Spool(directory: dir, maxBytes: 10)
#expect(throws: SpoolError.self) { try spool.segment(named: "absent") }
}
}
- Async XCTest:
func testX() async throws; wait for expectations withawait fulfillment(of: [exp], timeout: 1).wait(for:)is markednoasync: it blocks the thread an async test runs on. - Inject clocks (
any Clock<Duration>) and file-system roots instead of sleeping or touching real paths. - Run one test:
swift test --filter 'SpoolTests.SpoolWriterTests/testAppendsOneRecord'; skip with--skip.
Loom Test Runner Adapter
Adapter. swift-test. A directory containing Package.swift is a swift package, and loom's detection always picks swift-test for it. loom project detect prints the kind, runner and skill per package. An Xcode-only project without Package.swift is not detected as swift.
Single-test command. Loom runs each contract with the package directory as the working directory:
swift test --filter '{test}'
The test field. Module.Class/testMethod, where Module is the test target's module name. swift test list prints ids in this form. Example: SpoolTests.SpoolWriterContractTests/testRejectsSymlinkedSpool.
No-match behaviour. Documented: swift was not installed on the host that captured runner fixtures, so loom's parser for swift-test was written from the runner's documented output. Do not rely on the exit code. Loom reads the XCTest summary Executed N tests, with F failures; Executed 0 tests means nothing was selected, so a contract test whose name does not match fails the freeze. A plan criterion that runs swift test --filter ... and executes zero tests fails the same way (selected zero tests (swift-test)).
Writing contract tests.
- SwiftPM compiles every
.swiftfile underTests/<TestTarget>/into that test target. Put a contract in the existing test target, in a file named for its subject:Tests/SpoolTests/SpoolWriterContractTests.swift. - Write contract tests as XCTest methods. The adapter's
testformat and its parser are XCTest's; a Swift Testing@Testfunction prints a separate summary that the adapter does not read, so a filter that selects only Swift Testing tests can read as zero tests executed. --filteris an unanchored regular expression.testRejectsSymlinkalso selectstestRejectsSymlinkedSpool, so choose a method name that no other test id in the package contains.- Any other file the contract session changes (a shared fixture, a new test target in
Package.swift) must match aharnessglob, or the freeze rejects it. - Resolve and build once (
swift build --build-tests) before writing contracts, so the freeze run compiles only your test target instead of fetching and building dependencies inside its 300-second limit..build/must be in.gitignore(swift package initwrites it); the freeze treats every other untracked path as a change. - Loom cannot batch-select Swift tests by file, so impact-selected tests are skipped with a note for Swift packages; the full suite still runs in integration-verify.
import Foundation
import XCTest
@testable import Spool
final class SpoolWriterContractTests: XCTestCase {
func testRejectsSymlinkedSpool() throws {
let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
addTeardownBlock { try? FileManager.default.removeItem(at: dir) }
let target = dir.appendingPathComponent("target")
let link = dir.appendingPathComponent("spool")
try Data().write(to: target)
try FileManager.default.createSymbolicLink(at: link, withDestinationURL: target)
XCTAssertThrowsError(try SpoolWriter(path: link.path)) { error in
XCTAssertEqual(error as? SpoolError, .symlinked(path: link.path))
}
XCTAssertEqual(try Data(contentsOf: target), Data())
}
}
contracts:
- id: rejects-symlinked-spool
file: Tests/SpoolTests/SpoolWriterContractTests.swift
test: SpoolTests.SpoolWriterContractTests/testRejectsSymlinkedSpool
scenario: the spool path is a symlink to an empty file in a fresh temporary directory
rejects: a SpoolWriter that opens the path with default flags, follows the link and writes through it
Build failures. Swift is compiled: a contract test that does not compile yet (it calls an API the stage has not written) counts as red at freeze time; compiler error: lines mark the run as a build failure. One file that fails to compile fails the whole test target, so every test in that target stays red until the implementation compiles.
Anti-Patterns
// Force unwrap on data from outside the program
let port = Int(env["PORT"]!)! // BAD: crashes on a missing or bad value
guard let raw = env["PORT"], let port = Int(raw) else { // GOOD
throw ConfigError.missing(key: "PORT")
}
// Fire-and-forget task: its error vanishes and nothing can cancel it
Task { try await sync() } // BAD
syncTask = Task { try await sync() } // GOOD: stored, cancelled in deinit/onDisappear
// Blocking a cooperative-pool thread to wait for async work
let done = DispatchSemaphore(value: 0) // BAD: can starve the pool and deadlock
Task { await work(); done.signal() }
done.wait()
// Retain cycle: self owns the closure, the closure owns self
timer.handler = { self.tick() } // BAD
timer.handler = { [weak self] in self?.tick() } // GOOD
Quick swaps: @unchecked Sendable to silence a diagnostic → fix the type or guard it with a lock and say which; DispatchQueue.main.async inside async code → @MainActor isolation; class with no identity → struct; implicitly unwrapped optionals outside XCTest fixtures and IB outlets → a non-optional set in init; catch { } → handle, rethrow, or log with context; stringly-typed errors → an Error enum.
Expert Practices
Language Gotchas
Stringis a collection of grapheme clusters.countis O(n),"e\u{301}".count == 1while.utf8.count == 3, and subscripts take aString.Indexfrom the same string. Work on.utf8for byte protocols.- Integer overflow traps in release builds too.
Int.max + 1crashes. Use&+/&*for intended wrapping andaddingReportingOverflowon untrusted sizes.Int(someDouble)traps on NaN, infinity and out-of-range values; useInt(exactly:). lazy varis not thread-safe. Two threads can both run the initializer. Astatic letis lazily initialized exactly once, safely.deferruns at scope exit in reverse order, once per loop iteration when declared in a loop body.weakbecomesnil;unownedcrashes when the object is gone. Useunownedonly when the lifetime relation is guaranteed.- A custom
==needs a matchinghash(into:). Synthesized conformances cover every stored property; override both or neither.
Concurrency Gotchas
- Global and static mutable state is an error in Swift 6 mode. Make it a
let, move it into an actor, or guard it with aMutex.nonisolated(unsafe) varis a last resort that needs a comment explaining why no race is possible. MainActor.assumeIsolated { }asserts at runtime that a callback runs on the main thread; it traps otherwise. Prefer making the callback API@MainActor.AsyncStreambuffers without bound by default. PassbufferingPolicy: .bufferingNewest(n)for fast producers, and release resources incontinuation.onTermination.Task.detachedis rarely right. It drops priority, task-local values and actor context; anonisolatedasync function or a@concurrentone (6.2) usually expresses the intent.- Actor-isolated state read before an
awaitmay be stale after it. Write the "check then act" sequence without a suspension point in between, or record the in-flight work (seeDownloadCacheabove).
Codable
JSONDecoder's default date strategy is.deferredToDate: aDoubleof seconds since 2001-01-01. ISO 8601 strings need.iso8601, and that strategy rejects fractional seconds; decode those with.customandDate.ISO8601FormatStyle(includingFractionalSeconds: true).- Synthesized decoding ignores property defaults.
var retries = 3still throwskeyNotFoundwhen the key is absent; aletwith an initial value is never decoded at all (the compiler warns). Make the field optional or writeinit(from:)withdecodeIfPresent(...) ?? 3. - An unknown raw value throws
dataCorrupted. For enums the server may extend, decode through a type with anunknown(String)case. keyDecodingStrategy = .convertFromSnakeCasesaves hand-writtenCodingKeysfor snake-case payloads.
Performance
finalclasses andprivatemembers let the compiler devirtualize calls; release builds use whole-module optimization.- Generic and
somecode specializes;anyboxes values and dispatches through witness tables. Keepanyout of hot loops. reserveCapacitybefore appending a known count; iterate.utf8or.unicodeScalarsfor byte-level parsing.@inlinablelets client modules specialize a generic function, and publishes its body as part of the module's interface. Use it on small, stable functions.- Profile before tuning: Instruments on Apple platforms,
perfon Linux, the package-benchmark plugin for regression tracking.
Security
- Open without following links. A symlink check followed by a separate
openraces with an attacker swapping the path. PassO_NOFOLLOWtoopen(2); it fails withELOOPon a symlink.
import Foundation // re-exports Darwin or Glibc
func openSpool(atPath path: String) throws(SpoolError) -> Int32 {
let fd = open(path, O_WRONLY | O_CREAT | O_APPEND | O_NOFOLLOW, 0o600)
guard fd >= 0 else {
if errno == ELOOP { throw .symlinked(path: path) }
throw .notWritable(path: path)
}
return fd
}
- Processes: set
Process.executableURLand pass anargumentsarray. Never build a/bin/sh -cstring from input. - Randomness and crypto:
SystemRandomNumberGenerator(the default forInt.random(in:)) draws from the platform's secure source. Use CryptoKit or swift-crypto for hashes, HMAC and AEAD, and verify MACs withHMAC<SHA256>.isValidAuthenticationCode(_:authenticating:using:), which compares in constant time. - Secrets belong in the Keychain on Apple platforms.
UserDefaultsis a plain property list on disk. - Format strings:
String(format:)andNSPredicate(format:)with user-controlled format text are injection points. Keep the format literal and pass values as arguments. - TLS: never answer a
URLSessionauthentication challenge by trusting every certificate; pin with the server trust APIs when the threat model needs it.
Verification Checklists
Before marking Swift work done:
-
swift format lint --strictclean (or SwiftLint where the project configures it) -
swift build --build-testswith zero warnings in your own targets -
swift testgreen; new behaviour has tests, parameterized where inputs vary - No
!,try!oras!on data from outside the program; noassertused for validation - Public API uses untyped
throws; typed throws only on closed, internal error sets - Dependencies added with
swift package add-dependency;Package.resolvedcommitted for applications -
Codablemodels tested against real payloads (dates, missing keys, unknown enum values) - No shell strings built from input; secrets not stored in
UserDefaults
Concurrency review:
- Swift 6 language mode, or strict concurrency enabled on targets still in Swift 5 mode
- Every
@unchecked Sendableandnonisolated(unsafe)names the lock or invariant that makes it safe - Every unstructured
Taskhas a stored handle and a cancellation path - Actor methods re-check state after each
await; no check-then-act across a suspension - Continuations resume exactly once on every path; no semaphores waiting on async work
Loom contracts:
- Contract tests are XCTest methods in the package's test target
- Each
testvalue is theModule.Class/testMethodid fromswift test list, and no other test id contains it - Dependencies resolved and built before the freeze; each contract fails now, by assertion or by not compiling