AI Tools Compared

Claude vs Copilot for Swift Development 2026

Swift’s type system, structured concurrency model, and SwiftUI’s declarative syntax are all demanding targets for AI code completion. This comparison covers day-to-day development workflows across modern Swift patterns.

Test 1: Async/Await Networking

Prompt:

Write a Swift async function that fetches a list of User objects from https://api.example.com/users.
Decode JSON, handle URLSession errors and HTTP status codes separately, and use structured concurrency.
User has id: Int, name: String, email: String.

Claude Output

import Foundation

struct User: Decodable {
    let id: Int
    let name: String
    let email: String
}

enum APIError: Error, LocalizedError {
    case httpError(statusCode: Int, data: Data)
    case decodingError(Error)
    case networkError(Error)

    var errorDescription: String? {
        switch self {
        case .httpError(let code, _):
            return "HTTP error: \(code)"
        case .decodingError(let error):
            return "Decoding failed: \(error.localizedDescription)"
        case .networkError(let error):
            return "Network error: \(error.localizedDescription)"
        }
    }
}

func fetchUsers() async throws -> [User] {
    let url = URL(string: "https://api.example.com/users")!

    let (data, response): (Data, URLResponse)
    do {
        (data, response) = try await URLSession.shared.data(from: url)
    } catch {
        throw APIError.networkError(error)
    }

    guard let httpResponse = response as? HTTPURLResponse else {
        throw APIError.networkError(URLError(.badServerResponse))
    }

    guard (200..<300).contains(httpResponse.statusCode) else {
        throw APIError.httpError(statusCode: httpResponse.statusCode, data: data)
    }

    do {
        return try JSONDecoder().decode([User].self, from: data)
    } catch {
        throw APIError.decodingError(error)
    }
}

Claude produces a clean, idiomatic Swift result. Separate error cases for network vs HTTP vs decoding, proper guard statements, and typed throws. The (200..<300) range check is idiomatic Swift.

Copilot Output

Copilot generates similar code but tends to use a single catch block with String(describing: error), losing the error type information. It also often puts the JSONDecoder inside the same do-catch as URLSession, making error attribution ambiguous. The structure is functional but less precise.

Verdict: Claude — better error type separation, more idiomatic Swift patterns.

Test 2: SwiftUI View with Observable

Prompt:

Write a SwiftUI view showing a list of products. Use @Observable macro (Swift 5.9+, not ObservableObject).
ProductStore has a products array and a loadProducts() async method.
Show a loading spinner while loading, error message on failure, and a NavigationLink to a detail view.

Claude Output

import SwiftUI

struct Product: Identifiable, Decodable {
    let id: Int
    let name: String
    let price: Double
    let imageURL: URL?
}

@Observable
class ProductStore {
    var products: [Product] = []
    var isLoading = false
    var error: String?

    func loadProducts() async {
        isLoading = true
        error = nil
        defer { isLoading = false }

        do {
            let (data, _) = try await URLSession.shared.data(
                from: URL(string: "https://api.example.com/products")!
            )
            products = try JSONDecoder().decode([Product].self, from: data)
        } catch {
            self.error = error.localizedDescription
        }
    }
}

struct ProductListView: View {
    @State private var store = ProductStore()

    var body: some View {
        NavigationStack {
            Group {
                if store.isLoading {
                    ProgressView("Loading products…")
                        .frame(maxWidth: .infinity, maxHeight: .infinity)
                } else if let errorMessage = store.error {
                    ContentUnavailableView(
                        "Failed to load",
                        systemImage: "exclamationmark.triangle",
                        description: Text(errorMessage)
                    )
                } else {
                    List(store.products) { product in
                        NavigationLink(value: product) {
                            ProductRowView(product: product)
                        }
                    }
                }
            }
            .navigationTitle("Products")
            .navigationDestination(for: Product.self) { product in
                ProductDetailView(product: product)
            }
            .task {
                await store.loadProducts()
            }
        }
    }
}

struct ProductRowView: View {
    let product: Product

    var body: some View {
        HStack {
            AsyncImage(url: product.imageURL) { image in
                image.resizable().aspectRatio(contentMode: .fit)
            } placeholder: {
                Color.gray.opacity(0.3)
            }
            .frame(width: 50, height: 50)
            .cornerRadius(8)

            VStack(alignment: .leading) {
                Text(product.name).font(.headline)
                Text(product.price, format: .currency(code: "USD"))
                    .font(.subheadline)
                    .foregroundStyle(.secondary)
            }
        }
    }
}

Claude correctly uses @Observable (not @ObservableObject), @State private var store = ProductStore() (not @StateObject), ContentUnavailableView (iOS 17+), and .task {} for async loading. It uses navigationDestination(for:) — the navigation-value pattern, not the old NavigationLink(destination:).

Copilot still defaults to @StateObject and ObservableObject in many cases, and uses the deprecated NavigationLink(destination:) pattern.

Verdict: Claude — significantly more up-to-date with Swift 5.9+ APIs.

Test 3: Swift Package Manager Configuration

Prompt:

Write a Package.swift for a library called SwiftNetworking.
Targets: library (SwiftNetworking), test target (SwiftNetworkingTests).
Dependencies: swift-log from Apple, swift-argument-parser for a CLI example target.
Minimum Swift: 5.9. Supports macOS 13+, iOS 16+.

Claude Output

// swift-tools-version: 5.9
import PackageDescription

let package = Package(
    name: "SwiftNetworking",
    platforms: [
        .macOS(.v13),
        .iOS(.v16),
    ],
    products: [
        .library(
            name: "SwiftNetworking",
            targets: ["SwiftNetworking"]
        ),
        .executable(
            name: "networking-cli",
            targets: ["NetworkingCLI"]
        ),
    ],
    dependencies: [
        .package(
            url: "https://github.com/apple/swift-log.git",
            from: "1.5.0"
        ),
        .package(
            url: "https://github.com/apple/swift-argument-parser.git",
            from: "1.3.0"
        ),
    ],
    targets: [
        .target(
            name: "SwiftNetworking",
            dependencies: [
                .product(name: "Logging", package: "swift-log"),
            ]
        ),
        .executableTarget(
            name: "NetworkingCLI",
            dependencies: [
                "SwiftNetworking",
                .product(name: "ArgumentParser", package: "swift-argument-parser"),
            ],
            path: "Sources/CLI"
        ),
        .testTarget(
            name: "SwiftNetworkingTests",
            dependencies: ["SwiftNetworking"]
        ),
    ]
)

Both Claude and Copilot handle SPM well. Claude uses .executableTarget (correct for Swift 5.4+) while older Copilot suggestions sometimes use .target with @main. Minor difference in practice.

Test 4: Actor Concurrency

Prompt:

Write a Swift actor for a thread-safe cache with a maximum capacity (LRU eviction).
Keys are String, values are generic. Include get, set, and clear methods.

Claude correctly uses actor keyword with nonisolated for Sendable conformance and properly models the LRU ordering. Copilot sometimes uses class with NSLock instead of an actor — semantically equivalent but not idiomatic modern Swift.

Claude’s Actor Implementation

actor LRUCache<Value: Sendable> {
    private let capacity: Int
    private var cache: [String: Value] = [:]
    private var accessOrder: [String] = []

    init(capacity: Int) {
        self.capacity = max(1, capacity)
    }

    func get(_ key: String) -> Value? {
        guard let value = cache[key] else { return nil }
        // Move to most recently used
        accessOrder.removeAll { $0 == key }
        accessOrder.append(key)
        return value
    }

    func set(_ key: String, value: Value) {
        if cache[key] != nil {
            accessOrder.removeAll { $0 == key }
        } else if cache.count >= capacity {
            // Evict least recently used
            let lruKey = accessOrder.removeFirst()
            cache.removeValue(forKey: lruKey)
        }
        cache[key] = value
        accessOrder.append(key)
    }

    func clear() {
        cache.removeAll()
        accessOrder.removeAll()
    }

    nonisolated var description: String {
        "LRUCache(capacity: \(capacity))"
    }
}

The actor model here is significant: callers must use await for every access, which guarantees thread safety at the type system level rather than through manual locking. Copilot’s NSLock version works but forces you to remember to acquire/release the lock correctly everywhere.

Test 5: Combine Publisher Chain

Prompt:

Write a Swift Combine pipeline that takes a search text publisher, debounces it by 300ms,
filters out strings shorter than 2 characters, maps to an API call returning AnyPublisher,
and handles errors by replacing with an empty array.
import Combine
import Foundation

struct SearchResult: Decodable {
    let id: Int
    let title: String
}

class SearchViewModel: ObservableObject {
    @Published var searchText: String = ""
    @Published var results: [SearchResult] = []
    @Published var isLoading: Bool = false

    private var cancellables = Set<AnyCancellable>()
    private let apiClient: APIClient

    init(apiClient: APIClient) {
        self.apiClient = apiClient
        setupSearch()
    }

    private func setupSearch() {
        $searchText
            .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
            .filter { $0.count >= 2 }
            .removeDuplicates()
            .handleEvents(receiveOutput: { [weak self] _ in
                self?.isLoading = true
            })
            .flatMap { [weak self] query -> AnyPublisher<[SearchResult], Never> in
                guard let self = self else {
                    return Just([]).eraseToAnyPublisher()
                }
                return self.apiClient.search(query: query)
                    .catch { _ in Just([]) }
                    .eraseToAnyPublisher()
            }
            .receive(on: DispatchQueue.main)
            .sink { [weak self] results in
                self?.isLoading = false
                self?.results = results
            }
            .store(in: &cancellables)
    }
}

Claude’s pipeline correctly uses .flatMap (not .switchToLatest + .map, which requires extra type gymnastics) and wraps the error in .catch { _ in Just([]) } to produce a Never-failing publisher that composes cleanly with @Published. Copilot tends to suggest .switchToLatest() which requires explicit type annotations that beginners often miss.

Test 6: XCTest with async/await

Prompt:

Write an XCTest for the fetchUsers() function from Test 1 that mocks URLSession.
Test success case returning two users, and 404 error case.
Use async/await in the test — no XCTestExpectation.
import XCTest
@testable import SwiftNetworking

final class FetchUsersTests: XCTestCase {
    var mockSession: MockURLSession!

    override func setUp() {
        super.setUp()
        mockSession = MockURLSession()
    }

    func testFetchUsersSuccess() async throws {
        let json = """
        [{"id": 1, "name": "Alice", "email": "alice@example.com"},
         {"id": 2, "name": "Bob", "email": "bob@example.com"}]
        """.data(using: .utf8)!

        mockSession.stub(data: json, response: HTTPURLResponse(
            url: URL(string: "https://api.example.com/users")!,
            statusCode: 200, httpVersion: nil, headerFields: nil
        )!)

        let users = try await fetchUsers(session: mockSession)

        XCTAssertEqual(users.count, 2)
        XCTAssertEqual(users[0].name, "Alice")
        XCTAssertEqual(users[1].email, "bob@example.com")
    }

    func testFetchUsersThrowsOnHTTP404() async {
        mockSession.stub(data: Data(), response: HTTPURLResponse(
            url: URL(string: "https://api.example.com/users")!,
            statusCode: 404, httpVersion: nil, headerFields: nil
        )!)

        do {
            _ = try await fetchUsers(session: mockSession)
            XCTFail("Expected APIError.httpError to be thrown")
        } catch APIError.httpError(let statusCode, _) {
            XCTAssertEqual(statusCode, 404)
        } catch {
            XCTFail("Unexpected error type: \(error)")
        }
    }
}

This test pattern — async throws on the test function, no XCTestExpectation — is how modern Swift tests work. Copilot often still generates XCTestExpectation + fulfill() patterns, which work but are verbose and can mask timeout failures.

Built by theluckystrike — More at zovo.one