Swift Concurrency Notes (with version cues)

This note focuses on practical Swift concurrency patterns and calls out the Swift versions where features became available or tightened.

Version Quick Reference

  • Swift 5.5 (Xcode 13 / iOS 15): async/await, Task, TaskGroup, actors, MainActor, AsyncSequence.
  • Swift 5.7 (Xcode 14): stricter Sendable checking opt-in via -strict-concurrency (warnings); better actor-isolation diagnostics.
  • Swift 6+ (Xcode 16): strict concurrency checking on by default in new projects; typed throws available without feature flags; prefer these defaults for new codebases.

If you build with an older toolchain, gate newer features with #if compiler(>=6.0) or keep a note in the file header.

Structured vs Unstructured Tasks

  • Prefer structured concurrency: async let and withTaskGroup tie child lifetimes to the parent scope.
  • Use unstructured Task { ... } sparingly for fire-and-forget work; always inject cancellation checks to avoid leaks.
1
2
3
4
5
func loadProfile() async throws -> Profile {
    async let user = api.user()
    async let posts = api.posts()
    return Profile(user: try await user, posts: try await posts)
}

Cancellation

  • Task cancellation is cooperative. Check Task.isCancelled or call try Task.checkCancellation() inside loops or longer operations.
  • Propagate cancellation early from child tasks to avoid wasted work.
1
2
3
4
for try await item in stream {
    try Task.checkCancellation()
    process(item)
}

Actors and Isolation

  • Actors serialize access to their mutable state. Mark UI-facing APIs with @MainActor.
  • Use nonisolated for read-only members, and nonisolated(unsafe) only when you can prove thread-safety manually (avoid unless necessary).
1
2
3
4
5
actor Counter {
    private var value = 0
    func increment() { value += 1 }
    func current() -> Int { value }
}

Sendable and Data Races

  • Mark value types that cross concurrency domains as Sendable. Swift 6 treats many violations as errors.
  • Use @unchecked Sendable only when you have your own synchronization; prefer immutable structs/enums to avoid it entirely.

Typed Throws (Swift 6+)

  • Swift 6 toolchains let you declare the concrete error type a function can throw. Callers can switch exhaustively on error cases and avoid as?.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
enum LoginError: Error { case offline, invalidCredentials }

func login(email: String, password: String) throws(LoginError) -> Session {
    guard isOnline else { throw .offline }
    guard isValid(email, password) else { throw .invalidCredentials }
    return Session(token: "abc")
}

do {
    let session = try login(email: "a@b.com", password: "pw")
    print(session)
} catch LoginError.offline {
    // can recover based on the precise type
} catch {
    // fallback for unexpected errors (e.g., when bridging from ObjC)
}

AsyncSequence / Combine Bridge

  • AsyncSequence fits most streaming needs; prefer it over manual callbacks.
  • To bridge Combine publishers to AsyncSequence, use values on AnyPublisher (iOS 15+).
1
2
3
for await value in publisher.values {
    handle(value)
}

Testing Concurrency

  • Use XCTestExpectation less; prefer async tests: func testFoo() async throws.
  • For isolation, inject dependencies with protocols and provide synchronous fakes in tests.
  • Add targeted tests to ensure typed throws surfaces remain stable after refactors (XCTAssertThrowsError with pattern matching on the typed error).