
Modern Swift
- 170 installs
- 222 repo stars
- Updated January 18, 2026
- johnrogers/claude-swift-engineering
Write idiomatic modern Swift using concurrency, structured error handling, SwiftUI patterns, and maintainable architecture for new iOS features and refactors.
About
The modern-swift skill teaches Claude Code current Swift engineering conventions, including async/await, actors, SwiftUI composition, and clean architecture choices for building maintainable, production-quality iOS applications.
- Swift concurrency patterns
- SwiftUI architecture
- Idiomatic error handling
- Protocol-oriented design
- Testable module structure
Modern Swift by the numbers
- 170 all-time installs (skills.sh)
- +2 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #511 of 1,039 Mobile Development skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/johnrogers/claude-swift-engineering --skill modern-swiftAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 170 |
|---|---|
| repo stars | ★ 222 |
| Last updated | January 18, 2026 |
| Repository | johnrogers/claude-swift-engineering ↗ |
What it does
Write idiomatic modern Swift using concurrency, structured error handling, SwiftUI patterns, and maintainable architecture for new iOS features and refactors.
Files
Modern Swift (6.2+)
Swift 6.2 introduces strict compile-time concurrency checking with async/await, actors, and Sendable constraints that prevent data races at compile time instead of runtime. This is the foundation of safe concurrent Swift.
Overview
Modern Swift replaces older concurrency patterns (completion handlers, DispatchQueue, locks) with compiler-enforced safety. The core principle: if it compiles with strict concurrency enabled, it cannot have data races.
Quick Reference
| Need | Use | NOT |
|---|---|---|
| Async operation | async/await | Completion handlers |
| Main thread work | @MainActor | DispatchQueue.main |
| Shared mutable state | actor | Locks, serial queues |
| Parallel tasks | TaskGroup | DispatchGroup |
| Thread safety | Sendable | @unchecked everywhere |
Core Workflow
When writing async Swift code: 1. Mark async functions with async, call with await 2. Apply @MainActor to view models and UI-updating code 3. Use actor instead of locks for shared mutable state 4. Check Task.isCancelled or call Task.checkCancellation() in loops 5. Enable strict concurrency in Package.swift for compile-time safety
Reference Loading Guide
ALWAYS load reference files if there is even a small chance the content may be required. It's better to have the context than to miss a pattern or make a mistake.
| Reference | Load When |
|---|---|
| [Concurrency Essentials](references/concurrency-essentials.md) | Writing async code, converting completion handlers, using await |
| [Swift 6 Concurrency](references/swift6-concurrency.md) | Using @concurrent, nonisolated(unsafe), or actor patterns |
| [Task Groups](references/task-groups.md) | Running multiple async operations in parallel |
| [Task Cancellation](references/task-cancellation.md) | Implementing long-running or cancellable operations |
| [Strict Concurrency](references/strict-concurrency.md) | Enabling Swift 6 strict mode or fixing Sendable errors |
| [Macros](references/macros.md) | Using or understanding Swift macros like @Observable |
| [Modern Attributes](references/modern-attributes.md) | Migrating legacy code or using @preconcurrency, @backDeployed |
| [Migration Patterns](references/migration-patterns.md) | Modernizing delegate patterns or UIKit views |
Common Mistakes
1. `@unchecked Sendable` as a quick fix — Using @unchecked Sendable to silence compiler errors means you've opted out of safety. If the error persists after @unchecked, your code has a potential data race. Fix the underlying issue instead.
2. Missing `await` at call sites — Forgetting await when calling async functions is a compiler error, but checking Task.isCancelled in a loop without calling Task.checkCancellation() silently ignores cancellation.
3. Capturing `self` in async blocks without `weak` — Holding a strong reference to self in a long-running async task prevents deinit. Always use [weak self] in closures or use .task which auto-manages the lifecycle.
4. Not checking task cancellation — Long-running operations should regularly check Task.isCancelled or call Task.checkCancellation(), otherwise cancellation signals are ignored.
5. Forgetting `@MainActor` on UI code and test suites — Main test struct and view models that update @Published properties need @MainActor. Forgetting it silently allows cross-thread mutations. Apply @MainActor to: view models, view structs, main test structs, and any type that touches UI.
6. Actor re-entrancy surprises — await inside an actor method can release the lock temporarily. Another task may modify actor state. Design actor methods assuming state can change between await points.
Concurrency Essentials
Core patterns for async/await, @MainActor, actors, and Sendable in Swift 6.2.
Start Single-Threaded First
Apple Guidance (WWDC 2025): "Start by running all code on the main thread."
When to add complexity: 1. Stay single-threaded if UI is responsive (<16ms per frame) 2. Add async/await when network/file I/O would block UI 3. Add concurrency when CPU work freezes UI (profile first!) 4. Add actors when main actor contention causes bottlenecks
Concurrent code is more complex. Only introduce it when profiling proves it's needed.
Async/Await — NOT Completion Handlers
✅ Modern Pattern
func fetchUser(id: String) async throws -> User {
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode(User.self, from: data)
}
// Calling async functions
Task {
let user = try await fetchUser(id: "123")
}❌ Deprecated Pattern
// NEVER use completion handlers
func fetchUser(id: String, completion: @escaping (Result<User, Error>) -> Void) {
URLSession.shared.dataTask(with: url) { data, _, error in
// ...
}.resume()
}@MainActor — NOT DispatchQueue.main
✅ Modern Pattern
@MainActor
class ViewModel: ObservableObject {
var items: [Item] = []
func loadItems() async {
// Already on main actor — UI updates are safe
items = try await fetchItems()
}
}
// Or for individual properties
class Service {
@MainActor var uiState: UIState = .idle
}❌ Deprecated Pattern
// NEVER use DispatchQueue.main.async
DispatchQueue.main.async {
self.items = newItems
}Actor Isolation — NOT Locks
✅ Modern Pattern
actor DatabaseManager {
private var cache: [String: Data] = [:]
func getData(key: String) -> Data? {
cache[key]
}
func setData(_ data: Data, key: String) {
cache[key] = data
}
}
// Usage
let data = await database.getData(key: "user")❌ Deprecated Pattern
// NEVER use locks or serial queues
class DatabaseManager {
private let queue = DispatchQueue(label: "db")
private var cache: [String: Data] = [:]
func getData(key: String) -> Data? {
queue.sync { cache[key] }
}
}Sendable — Thread-Safe Types
✅ Conforming to Sendable
// Value types are implicitly Sendable
struct User: Sendable {
let id: String
let name: String
}
// Actors are implicitly Sendable
actor UserCache { }
// Classes require @unchecked Sendable (use sparingly)
final class ImmutableConfig: @unchecked Sendable {
let apiKey: String
let baseURL: URL
init(apiKey: String, baseURL: URL) {
self.apiKey = apiKey
self.baseURL = baseURL
}
}❌ Common Errors
// ERROR: Non-Sendable type crossing actor boundary
class MutableState { var count = 0 }
actor Counter {
// ❌ MutableState is not Sendable
func update(state: MutableState) { }
}Common Patterns
Network Request
func loadData() async throws -> Data {
try await URLSession.shared.data(from: url).0
}Background Work + UI Update
@MainActor
func refresh() async {
let data = await Task.detached {
// Heavy computation off main actor
await processData()
}.value
// Back on main actor automatically
self.items = data
}Macros (Swift 5.9+)
Swift macros enable compile-time code generation and transformation.
Two Types of Macros
Freestanding Macros
Start with #, expand to code at the call site.
// Usage
let url = #URL("https://example.com")
// Expands to compile-time validated URLAttached Macros
Start with @, modify or add to declarations.
// Usage
@OptionSet
struct Permission {
private enum Options: Int {
case read = 1
case write = 2
case delete = 4
}
}
// Expands to add conformance, properties, initializersCommon Freestanding Macros
#URL
// Compile-time URL validation
let api = #URL("https://api.example.com/users")
// Error if URL is invalid#selector, #keyPath
// Type-safe selectors (UIKit)
button.addTarget(self, action: #selector(handleTap), for: .touchUpInside)
// Type-safe key paths
let name = user[keyPath: #keyPath(User.name)]Common Attached Macros
@OptionSet
Generates RawRepresentable conformance for option sets.
@OptionSet<UInt8>
struct ShippingOptions {
private enum Options: Int {
case nextDay
case priority
case gift
}
}
// Generated: init, contains, insert, remove, etc.
let options: ShippingOptions = [.nextDay, .gift]@Observable (SwiftUI)
Generates observation infrastructure for SwiftUI.
@Observable
class ViewModel {
var count = 0
}
// No need for @Published or ObservableObjectMacro Roles
Macros are defined with specific roles that determine what they can do:
| Role | What It Does | Example |
|---|---|---|
@freestanding(expression) | Expands to an expression | #URL |
@attached(member) | Adds members to a type | @Observable |
@attached(peer) | Adds peer declarations | @Test generates async variant |
@attached(accessor) | Adds getters/setters | @Observable property wrapper |
@attached(memberAttribute) | Adds attributes to members | Apply @MainActor to all members |
@attached(conformance) | Adds protocol conformances | @OptionSet adds OptionSet |
When to Use Macros
✅ Good Use Cases
- Eliminating boilerplate (OptionSet, Observable)
- Compile-time validation (#URL, #require)
- Code generation from declarations
- Type-safe wrappers
❌ Avoid For
- Runtime logic (use functions)
- Simple code reuse (use functions/protocols)
- Complex transformations (hard to debug)
- Anything achievable with protocols/generics
Macro Expansion
Macros expand at compile time. View expansions in Xcode:
- Right-click macro usage
- "Expand Macro" to see generated code
@OptionSet
struct Permissions {
private enum Options: Int {
case read, write
}
}
// Expand to see:
// - OptionSet conformance
// - Static properties
// - Initializers
// - Insert/remove methodsCreating Macros (High-Level)
Macros are separate Swift packages:
1. Define the macro signature in your package 2. Implement using SwiftSyntax in a macro target 3. Test the expansion 4. Use in your code
This is advanced - most developers only use macros, not create them.
Key Principles
1. Additive only - Macros can't remove code 2. Deterministic - Same input = same output 3. Sandboxed - No file system, network, etc. 4. Inspectable - Always viewable via "Expand Macro"
Migration Patterns
Common patterns for migrating legacy Swift code to modern best practices.
Delegate → AsyncStream
✅ Modern Pattern
// Before: Delegate pattern
protocol LocationManagerDelegate: AnyObject {
func locationManager(_ manager: LocationManager, didUpdateLocation: Location)
}
class LocationManager {
weak var delegate: LocationManagerDelegate?
}
// After: AsyncStream
class LocationManager {
var locations: AsyncStream<Location> {
AsyncStream { continuation in
// Setup location updates
self.onLocationUpdate = { location in
continuation.yield(location)
}
continuation.onTermination = { _ in
// Cleanup
}
}
}
}
// Usage
for await location in locationManager.locations {
updateUI(with: location)
}Effort: ~3 hours per delegate Risk: Medium - changes API surface
---
UIKit → SwiftUI
✅ Modern Pattern
// Before: UIKit
class ProfileViewController: UIViewController {
@IBOutlet weak var nameLabel: UILabel!
@IBOutlet weak var avatarImageView: UIImageView!
override func viewDidLoad() {
super.viewDidLoad()
nameLabel.text = user.name
avatarImageView.load(url: user.avatarURL)
}
}
// After: SwiftUI
struct ProfileView: View {
let user: User
var body: some View {
VStack {
AsyncImage(url: user.avatarURL) { image in
image.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
ProgressView()
}
.frame(width: 100, height: 100)
.clipShape(Circle())
Text(user.name)
.font(.headline)
}
}
}Effort: ~8 hours per view controller Risk: High - requires understanding of both frameworks
Common UIKit → SwiftUI Mappings
| UIKit | SwiftUI |
|---|---|
UILabel | Text() |
UIImageView | Image() or AsyncImage() |
UIButton | Button() |
UITextField | TextField() |
UIStackView | VStack, HStack, ZStack |
UIScrollView | ScrollView |
UITableView | List |
UINavigationController | NavigationStack |
---
Migration Workflow
1. Analyze
- Identify all occurrences of the pattern using Grep
- Map dependencies and call sites
- Estimate effort and risk
2. Plan
- Create migration checklist with TodoWrite
- Identify test points
- Plan rollback strategy if needed
3. Execute
- Migrate one component at a time
- Add compatibility shims if needed
- Update call sites progressively
4. Verify
- Run existing tests after each change
- Test edge cases
- Check performance impact
---
Effort & Risk Table
| Migration Type | Typical Effort | Risk Level | Notes |
|---|---|---|---|
| Completion → async/await | ~2 hours/file | Low | Well-supported by compiler |
| DispatchQueue → Actor | ~4 hours/class | Medium | Requires understanding concurrency boundaries |
| Delegate → AsyncStream | ~3 hours/delegate | Medium | Changes API surface |
| UIKit → SwiftUI | ~8 hours/view controller | High | Requires both framework knowledge |
| Add Sendable | ~1 hour/type | Low | Compile-time verification |
---
Deprecated API Replacements
Always check Sosumi MCP server for current API status and replacements:
| Deprecated | Modern Replacement |
|---|---|
UIApplication.shared.keyWindow | UIApplication.shared.connectedScenes |
UIDevice.current.name | Privacy manifest required |
URLSession.dataTask | URLSession.data(from:) |
Use Sosumi to verify deprecation status and find migration guides for 2025.
Modern Attributes (Swift 5.9-6.2)
New attributes introduced in recent Swift versions.
@preconcurrency
Suppresses strict concurrency warnings for legacy code during migration.
On Imports
// Suppress warnings from dependencies not yet updated for Swift 6
@preconcurrency import LegacyNetworking
// Use LegacyNetworking types without Sendable warningsOn Protocols
// Allow non-Sendable types to conform during migration
@preconcurrency
protocol DataSource {
func fetchData() async -> Data
}
// Classes can conform without Sendable requirement
class LocalDataSource: DataSource {
func fetchData() async -> Data { ... }
}On Types
// Mark type as "will be Sendable eventually"
@preconcurrency
class LegacyManager {
var state: String = ""
}@backDeployed
Makes new API implementations available on older OS versions.
extension String {
// Available on iOS 13+, but implemented on iOS 17+
@backDeployed(before: iOS 17)
@available(iOS 13, *)
func trimmed() -> String {
trimmingCharacters(in: .whitespaces)
}
}
// iOS 13-16: Uses the provided implementation
// iOS 17+: Uses system implementation (if different)When to Use
- Library evolution
- Backporting system APIs
- Gradual feature rollout
package Access Control (Swift 5.9)
New access level between internal and public.
// MyLibrary/Sources/Core/User.swift
package struct User {
package let id: String
package let name: String
}
// MyLibrary/Sources/Networking/API.swift
package func fetchUser() -> User {
// Visible within MyLibrary package
}
// App/main.swift
import MyLibrary
// User and fetchUser are NOT visible hereAccess Levels Summary
private < fileprivate < internal < package < public < open| Level | Visible To |
|---|---|
private | Current declaration |
fileprivate | Current file |
internal | Current module |
package | Current package |
public | Importers (no subclass/override) |
open | Importers (can subclass/override) |
@available(*, noasync)
Prevents async usage of specific APIs.
@available(*, noasync)
func dangerousBlockingOperation() {
// Blocks thread - don't call from async context
Thread.sleep(forTimeInterval: 5)
}
// ❌ Compile error in async context
async {
dangerousBlockingOperation()
}Use Cases
- Mark blocking I/O
- Prevent deadlocks
- Legacy synchronous APIs
@_exported (Underscore Attributes)
Re-exports a module's public API.
// In your module
@_exported import Foundation
// Users importing your module get Foundation too
import MyModule
// Can use Foundation types without separate importWarning: @_exported is underscored = not stable API. Use sparingly.
Macro Attributes
See macros.md for:
@attached(member)@attached(peer)@attached(accessor)@attached(memberAttribute)@attached(conformance)@freestanding(expression)
Migration Patterns
Swift 5.x to Swift 6
// 1. Add @preconcurrency to imports
@preconcurrency import LegacySDK
// 2. Use package for internal APIs
package func helperMethod() { }
// 3. Mark blocking code
@available(*, noasync)
func blockingWork() { }Library Evolution
// Backport new features
@backDeployed(before: iOS 18)
@available(iOS 15, *)
func modernFeature() { }Strict Concurrency (Swift 6)
Swift 6's strict concurrency checking eliminates data races at compile time.
Enabling Strict Concurrency
Package.swift
.target(
name: "MyTarget",
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency")
]
)Build Settings (Xcode)
- SWIFT_STRICT_CONCURRENCY = complete
What Strict Mode Enforces
1. Sendable conformance for values crossing actor boundaries 2. Isolation checking for @MainActor and actor types 3. No implicit captures of non-Sendable types in async contexts 4. Proper annotations on global variables and functions
Typed Throws (Swift 6.2)
Specify the exact error type a function throws.
Basic Typed Throws
enum ValidationError: Error {
case tooShort
case invalidFormat
}
func validate(_ input: String) throws(ValidationError) {
guard input.count > 5 else {
throw ValidationError.tooShort
}
}
// Caller knows exact error type
do {
try validate("abc")
} catch {
// error is ValidationError, not any Error
switch error {
case .tooShort: print("Too short")
case .invalidFormat: print("Invalid")
}
}Never Throws
func parseInteger(_ string: String) throws(Never) -> Int {
// Compiler knows this never throws
Int(string) ?? 0
}
// No try needed
let value = parseInteger("123")Generic Throws
func transform<E: Error>(
_ value: String,
using: (String) throws(E) -> Int
) throws(E) -> Int {
try using(value)
}Common Strict Concurrency Fixes
Global Variables
// ❌ Error: Global mutable state
var sharedCache: [String: Data] = [:]
// ✅ Use actor
actor SharedCache {
private var cache: [String: Data] = [:]
}
// ✅ Or @MainActor for UI state
@MainActor
var currentTheme: Theme = .lightClosures Capturing Non-Sendable
class ViewModel {
var items: [Item] = []
func load() {
// ❌ Error: Capturing non-Sendable self
Task {
self.items = await fetch()
}
}
}
// ✅ Make ViewModel @MainActor
@MainActor
class ViewModel {
var items: [Item] = []
func load() {
Task {
self.items = await fetch()
}
}
}Non-Sendable Function Parameters
// ❌ Error: Non-Sendable closure
func runAsync(_ action: () -> Void) async {
action()
}
// ✅ Require Sendable
func runAsync(_ action: @Sendable () -> Void) async {
action()
}Sendable Inference
Swift 6 automatically infers Sendable for:
- Structs with all Sendable stored properties
- Enums with all Sendable associated values
- Actors
- Final classes with only immutable Sendable properties
// Automatically Sendable
struct User {
let id: String
let name: String
}
// NOT automatically Sendable (has var)
struct MutableUser {
var name: String
}@unchecked Sendable for External Types
When your type contains types from external packages that aren't yet Sendable, use @unchecked Sendable with a TODO comment:
@Reducer public struct FeatureTracking {
public struct Tracker: Sendable {
// TODO: @unchecked Sendable - Contains LegacyRecord (LegacySDK) and CLLocation (CoreLocation)
// which are not marked Sendable. Revisit when LegacySDK is modernized to Swift 6.
public enum Event: Equatable, @unchecked Sendable {
case operationRequested(
record: LegacyRecord, // External type — not Sendable
location: CLLocation? // Apple type — not Sendable
)
case operationSuccess
case operationFailure(String)
}
}
}When to Use @unchecked Sendable
| Scenario | Use @unchecked Sendable? |
|---|---|
| External type not Sendable (CLLocation, etc.) | ✅ Yes, with TODO |
| Apple framework type not Sendable | ✅ Yes, with TODO |
| Your own mutable class | ❌ No, make it an actor |
| Immutable reference type | ✅ Yes, if truly immutable |
Type with var properties | ❌ No, use actor or redesign |
Common External Types Requiring @unchecked
CLLocation,CLLocationCoordinate2D(CoreLocation)- Types from legacy Obj-C frameworks not yet audited for Sendable
- Third-party SDK types
@preconcurrency import Alternative
For imports from packages not yet migrated, use @preconcurrency import:
@preconcurrency import LegacySDK // Suppresses Sendable warnings for LegacyRecord, LegacyUser, etc.
import CoreLocation // CLLocation still requires @unchecked SendablePrefer @preconcurrency for entire modules. Use @unchecked Sendable for individual types when the import approach isn't sufficient.
Migration Strategy
1. Enable strict concurrency in one module at a time 2. Fix global mutable state first (use actors or @MainActor) 3. Add @MainActor to view models and UI classes 4. Add @Sendable to closure parameters 5. Use @preconcurrency for legacy dependencies (see modern-attributes.md) 6. Add TODO comments to @unchecked Sendable uses for future cleanup
Swift 6 Concurrency (Swift 6.2+)
Advanced patterns: @concurrent, nonisolated(unsafe), actor isolation.
Progressive Journey: Start Single-Threaded
Apple (WWDC 2025): "Start by running all code on the main thread."
Single-Threaded → Async/Await → @concurrent → Actors
↓ ↓ ↓ ↓
Start here Hide latency Background Move data
(network) CPU work off mainWhen to advance: Profile first. Add complexity only when needed.
@concurrent Attribute (Swift 6.2+)
Forces function to always run on background thread pool.
@concurrent
func decodeImage(_ data: Data) async -> Image {
// Always runs on background — good for image processing, parsing
return processImageData(data)
}
// Usage — automatically offloads
let image = await decodeImage(data)Requirements: Swift 6.2, Xcode 16.2+, iOS 18.2+
Breaking Main Actor Ties
@MainActor class ImageModel {
var cache: [URL: Image] = [:]
@concurrent
func decode(_ data: Data, url: URL) async -> Image {
if let img = cache[url] { return img } // ❌ Error: main actor access!
return processImageData(data)
}
}Fix: Move access to caller (preferred):
func fetchAndDisplay(url: URL) async throws {
if let img = cache[url] { view.displayImage(img); return } // ✅ On main actor
let data = try await URLSession.shared.data(from: url).0
let image = await decode(data) // @concurrent — no cache access needed
view.displayImage(image)
}nonisolated vs @concurrent
| Attribute | Runs On | Use Case |
|---|---|---|
nonisolated | Caller's actor | Library APIs — caller decides |
@concurrent | Background pool | Always-background work |
nonisolated(unsafe) Escape Hatch
Use when you know access is safe but compiler cannot prove it.
class LegacyCache {
nonisolated(unsafe) var sharedState: [String: Data] = [:] // ⚠️ Prove safety first
}Consider first: Make it an actor, add @MainActor, or use @unchecked Sendable.
Static Comparators Pattern
For static sorting comparators, prefer static let with @Sendable closures over nonisolated(unsafe) static var:
// ❌ Before: Requires nonisolated(unsafe)
extension SortableItem {
nonisolated(unsafe) static var dateAscending: (SortableItem, SortableItem) -> Bool = { lhs, rhs in
lhs.date < rhs.date
}
}
// ✅ After: Use static let with @Sendable
extension SortableItem {
static let dateAscending: @Sendable (SortableItem, SortableItem) -> Bool = { lhs, rhs in
lhs.date < rhs.date
}
static let priorityDescending: @Sendable (SortableItem, SortableItem) -> Bool = { lhs, rhs in
lhs.priority > rhs.priority
}
}
// Usage — works with standard library sorting
let sorted = items.sorted(by: SortableItem.dateAscending)Why this works: static let closures are evaluated once and immutable. Adding @Sendable proves they capture no mutable state.
Actor for Main Actor Contention
// ❌ Problem: Network manager on main actor causes thread hopping
@MainActor class ImageModel {
let network = NetworkManager() // Also @MainActor
func fetch(url: URL) async throws {
let conn = await network.open(for: url) // ❌ Hops to main
}
}// ✅ Fix: Extract to separate actor
actor NetworkManager {
private var connections: [URL: Connection] = [:]
func open(for url: URL) -> Connection {
connections[url] ?? Connection()
}
}| Use Case | Solution |
|---|---|
| UI code, view models | @MainActor class |
| Non-UI subsystem | actor |
| Shared cache/database | actor |
Delegate Value Capture Pattern
When nonisolated delegate needs to update @MainActor state:
nonisolated func delegate(_ param: SomeType) {
let value = param.value // Step 1: Capture BEFORE Task
Task { @MainActor in
self.property = value // Step 2: Safe on MainActor
}
}Isolated Protocol Conformances (Swift 6.2+)
protocol Exportable { func export() }
// ✅ Conform with explicit isolation
extension PhotoProcessor: @MainActor Exportable {
func export() { exportAsPNG() } // Safe: both on MainActor
}Sendable Strategies
| Strategy | When |
|---|---|
| Value types (struct/enum) | Preferred — always |
@MainActor class | UI-related classes |
| Finish mutations before sending | Classes modified then passed |
@unchecked Sendable | Last resort — immutable classes only |
// Finish mutations before sending
@concurrent func processImage() async {
let image = loadImage()
image.scale(by: 0.5) // All mutations here
await view.displayImage(image) // ✅ Send AFTER done
}Quick Decision Tree
UI unresponsive?
├─ Network/file I/O? → async/await
├─ CPU work? → @concurrent
└─ Main actor contention? → Extract to actor
"Main actor-isolated accessed from nonisolated"
├─ In delegate? → Value capture pattern
├─ In async? → @MainActor or Task { @MainActor in }
└─ In @concurrent? → Move access to callerTask Cancellation
Cooperative cancellation patterns in Swift 6.2 structured concurrency.
Cooperative Model
Swift tasks use cooperative cancellation:
- Cancellation is requested, not forced
- Tasks must check for cancellation and respond
- No automatic interruption
checkCancellation vs isCancelled
Task.checkCancellation()
Throws CancellationError if cancelled. Use in throwing contexts.
func processItems(_ items: [Item]) async throws {
for item in items {
try Task.checkCancellation()
await process(item)
}
}Task.isCancelled
Returns Bool. Use for graceful cleanup in non-throwing contexts.
func processItems(_ items: [Item]) async {
for item in items {
if Task.isCancelled {
print("Cancelled, stopping early")
return
}
await process(item)
}
}withTaskCancellationHandler
Runs cleanup when task is cancelled.
func downloadFile(url: URL) async throws -> Data {
let download = URLSession.shared.dataTask(with: url)
return try await withTaskCancellationHandler {
try await download.value
} onCancel: {
download.cancel()
}
}Cancellation Patterns
Long-Running Loop
func monitorEvents() async throws {
while !Task.isCancelled {
let event = try await fetchNextEvent()
try Task.checkCancellation()
await handle(event)
}
}TaskGroup with Cancellation
func fetchWithTimeout(ids: [String]) async throws -> [User] {
try await withThrowingTaskGroup(of: User.self) { group in
// Add tasks
for id in ids {
group.addTask {
try await fetchUser(id: id)
}
}
// Cancel all if one fails
var users: [User] = []
do {
for try await user in group {
users.append(user)
}
} catch {
group.cancelAll()
throw error
}
return users
}
}Timeout Pattern
func withTimeout<T>(
seconds: TimeInterval,
operation: @escaping @Sendable () async throws -> T
) async throws -> T {
try await withThrowingTaskGroup(of: T.self) { group in
group.addTask {
try await operation()
}
group.addTask {
try await Task.sleep(for: .seconds(seconds))
throw TimeoutError()
}
let result = try await group.next()!
group.cancelAll()
return result
}
}Best Practices
1. Check frequently in loops and long operations 2. Propagate cancellation to child tasks 3. Clean up resources in cancellation handlers 4. Don't ignore cancellation - respond appropriately 5. Use checkCancellation() in throwing code for cleaner errors
Task Groups & Structured Concurrency
Patterns for parallel execution with TaskGroup and async-let in Swift 6.2.
TaskGroup — Structured Concurrency
✅ Modern Pattern
func fetchAllUsers(ids: [String]) async throws -> [User] {
try await withThrowingTaskGroup(of: User.self) { group in
for id in ids {
group.addTask {
try await fetchUser(id: id)
}
}
var users: [User] = []
for try await user in group {
users.append(user)
}
return users
}
}❌ Deprecated Pattern
// NEVER use DispatchGroup
let group = DispatchGroup()
var users: [User] = []
for id in ids {
group.enter()
fetchUserOldStyle(id: id) { user in
users.append(user)
group.leave()
}
}async-let — Fixed Parallel Tasks
Use when you know the exact number of parallel operations at compile time.
func loadDashboard() async throws -> Dashboard {
async let user = fetchUser()
async let posts = fetchPosts()
async let stats = fetchStats()
return try await Dashboard(
user: user,
posts: posts,
stats: stats
)
}TaskGroup Patterns
Collecting Results in Order
func fetchInOrder(ids: [String]) async throws -> [User] {
try await withThrowingTaskGroup(of: (Int, User).self) { group in
for (index, id) in ids.enumerated() {
group.addTask {
(index, try await fetchUser(id: id))
}
}
var results = [(Int, User)]()
for try await result in group {
results.append(result)
}
return results.sorted { $0.0 < $1.0 }.map(\.1)
}
}Limited Parallelism
func processBatch(_ items: [Item]) async throws {
try await withThrowingTaskGroup(of: Void.self) { group in
var iterator = items.makeIterator()
// Start initial batch of 5
for _ in 0..<5 {
if let item = iterator.next() {
group.addTask { try await process(item) }
}
}
// As tasks complete, start new ones
while let item = iterator.next() {
try await group.next()
group.addTask { try await process(item) }
}
try await group.waitForAll()
}
}Early Exit on Error
func fetchUntilError(ids: [String]) async throws -> [User] {
try await withThrowingTaskGroup(of: User.self) { group in
for id in ids {
group.addTask { try await fetchUser(id: id) }
}
var users: [User] = []
// First error throws and cancels remaining tasks
for try await user in group {
users.append(user)
}
return users
}
}