SwiftUI — State & Data Flow

Pick the wrong wrapper and the view either never updates or resets constantly.

  • ios
  • ipados
  • macos
  • tvos
  • watchos
  • visionos
  • SwiftUI

Sources: SwiftUI — Apple Developer Documentation · Observation — Apple Developer Documentation

Property wrapper decision table

Left column is what you should reach for first.

WrapperOwns the value?Use when
@Stateyesview-local value type, or (iOS 17+) a view-owned @Observable object
@Bindingnochild needs read/write access to a parent's state
@Observable + let/@Statedependsreference-type model (iOS 17+). Replaces ObservableObject
@Bindablenoyou need $ bindings into an @Observable object you don't own
@Environmentnosystem values (\.dismiss, \.colorScheme) or injected @Observable models
@StateObjectyeslegacy — view creates an ObservableObject; created once per view identity
@ObservedObjectnolegacy — object passed in from outside
@EnvironmentObjectnolegacy — ObservableObject from the environment
@AppStorage / @SceneStorageyesUserDefaults / state restoration
@FocusStateyeskeyboard focus for text fields

Observation (iOS 17+) vs ObservableObject

The single biggest modern-SwiftUI talking point.

Modern — @Observable
@Observable
final class Cart {
  var items: [Item] = []
  var isLoading = false
  var total: Decimal { items.reduce(0) { $0 + $1.price } }
}

struct CartView: View {
  @State private var cart = Cart()        // view owns it
  var body: some View {
    List(cart.items) { ... }
    ChildView(cart: cart)                 // plain let, no wrapper needed
  }
}

struct ChildView: View {
  @Bindable var cart: Cart                // only when you need $bindings
  var body: some View { Toggle("Gift", isOn: $cart.isGift) }
}

// Tracks per-property: a view only re-renders if it READ that property.
Legacy — ObservableObject
final class Cart: ObservableObject {
  @Published var items: [Item] = []
  @Published var isLoading = false
}

struct CartView: View {
  @StateObject private var cart = Cart()  // created ONCE
  var body: some View {
    List(cart.items) { ... }
    ChildView(cart: cart)
  }
}

struct ChildView: View {
  @ObservedObject var cart: Cart          // passed in; do NOT create here
  var body: some View { ... }
}

// objectWillChange fires for ANY @Published change -> whole view re-renders.

State ↔ Binding

`$` gives you the projected value.

struct Parent: View {
  @State private var name = ""
  @State private var isOn = false

  var body: some View {
    VStack {
      Child(name: $name)                // pass a binding down
      Toggle("On", isOn: $isOn)
      Text(name)
    }
  }
}

struct Child: View {
  @Binding var name: String
  var body: some View { TextField("Name", text: $name) }
}

// Derived / computed binding
let upper = Binding(
  get: { name.uppercased() },
  set: { name = $0.lowercased() }
)
// Optional -> non-optional binding
Binding($optionalName) ?? .constant("")

Environment injection

iOS 17+ `@Observable` works with plain `@Environment`.

@Observable final class Session { var user: User? }

// Inject
ContentView()
  .environment(session)                  // @Observable
  .environmentObject(legacyStore)        // ObservableObject (legacy)

// Consume
struct ProfileView: View {
  @Environment(Session.self) private var session
  @Environment(\.dismiss) private var dismiss
  @Environment(\.colorScheme) private var scheme

  var body: some View {
    Text(session.user?.name ?? "Guest")
    Button("Close") { dismiss() }
  }
}

// Custom environment value (iOS 17+ macro)
extension EnvironmentValues {
  @Entry var accentTheme: Theme = .default
}

Identity & lifetime rules

Where most SwiftUI bugs come from.

  • @State initialisers run every body evaluation, but the value is kept — never rely on the initialiser running once for side effects.
  • Creating an ObservableObject with @ObservedObject var vm = VM() recreates it on every render. Use @StateObject.
  • Changing a view's structural identity (branch of if, position in a ForEach) destroys its @State.
  • Give ForEach stable IDs (Identifiable), never array indices for mutable collections.
  • .id(value) deliberately forces a fresh identity — useful to reset a subview.
  • .task(id:) cancels and restarts when the id changes; it's tied to view lifetime.
  • onAppear/onDisappear can fire multiple times (e.g. in NavigationStack); prefer .task for async work.

Async work in a view

struct FeedView: View {
  @State private var items: [Item] = []
  @State private var phase: Phase = .loading

  var body: some View {
    List(items) { ItemRow($0) }
      .task {                              // auto-cancelled on disappear
        do { items = try await api.feed() ; phase = .loaded }
        catch { phase = .failed(error) }
      }
      .refreshable {                       // pull-to-refresh, async by design
        items = (try? await api.feed()) ?? items
      }
      .task(id: selectedCategory) { ... }  // restart when the id changes
  }
}

SwiftUI — Views, Layout & Animation

Layout system, lists, modifiers, animation, performance.

  • ios
  • ipados
  • macos
  • tvos
  • watchos
  • visionos
  • SwiftUI

Sources: SwiftUI — Apple Developer Documentation

Layout containers

SwiftUI layout = parent proposes a size, child chooses, parent places.

VStack(alignment: .leading, spacing: 8) { ... }
HStack(alignment: .firstTextBaseline) { ... }
ZStack(alignment: .bottomTrailing) { ... }

ScrollView { LazyVStack(pinnedViews: [.sectionHeaders]) { ... } }
ScrollView(.horizontal) { LazyHStack { ... } }

LazyVGrid(columns: [GridItem(.adaptive(minimum: 100), spacing: 8)]) { ... }

Grid {                                    // iOS 16+, true 2D alignment
  GridRow { Text("A"); Text("B") }
  GridRow { Text("C").gridCellColumns(2) }
}

ViewThatFits { WideLayout(); NarrowLayout() }
Spacer(); Divider()

Sizing & alignment

The order of modifiers matters — each one wraps the previous view.

Text("Hi")
  .padding(12)                 // pad first...
  .background(.blue)           // ...then background covers the padding
  .frame(maxWidth: .infinity, alignment: .leading)
  .frame(height: 44)
  .clipShape(.rect(cornerRadius: 12))

// .frame(width:height:) fixes a size
// .frame(maxWidth: .infinity) expands to fill the proposal
// .fixedSize() tells the child to use its ideal size (fixes truncated text)
// .layoutPriority(1) wins space in a stack

GeometryReader { proxy in                 // greedy: fills all offered space
  Circle().frame(width: proxy.size.width * 0.5)
}

Lists & collections

List {
  Section("Today") {
    ForEach(items) { item in
      NavigationLink(value: item) { ItemRow(item: item) }
        .swipeActions { Button("Delete", role: .destructive) { delete(item) } }
    }
    .onDelete { indexSet in items.remove(atOffsets: indexSet) }
    .onMove { from, to in items.move(fromOffsets: from, toOffset: to) }
  }
}
.listStyle(.insetGrouped)
.searchable(text: $query, prompt: "Search")
.navigationDestination(for: Item.self) { ItemDetail(item: $0) }
.toolbar { ToolbarItem(placement: .topBarTrailing) { EditButton() } }

// Editable rows: bind directly into the collection (iOS 15+)
ForEach($items) { $item in TextField("Name", text: $item.name) }

Navigation (vanilla SwiftUI)

NavigationStack(path: $path) {                    // path: [Route] or NavigationPath
  RootView()
    .navigationDestination(for: Route.self) { route in
      switch route {
      case .detail(let id): DetailView(id: id)
      case .settings:       SettingsView()
      }
    }
}
NavigationLink("Open", value: Route.detail(id))   // value-based, decoupled

path.append(Route.settings)      // programmatic push
path.removeLast()                // pop
path = NavigationPath()          // pop to root

NavigationSplitView { Sidebar() } detail: { Detail() }   // iPad / Mac

Animation & transitions

// Implicit — animates any change to values this view depends on
Circle().scaleEffect(scale).animation(.spring(duration: 0.3), value: scale)

// Explicit — animate the state change itself
withAnimation(.easeInOut(duration: 0.25)) { isExpanded.toggle() }

// Transitions for insertion/removal
if isShown {
  Banner().transition(.move(edge: .top).combined(with: .opacity))
}

// Matched geometry (hero animations)
@Namespace private var ns
Image(...).matchedGeometryEffect(id: "hero", in: ns)

// Phase / keyframe animation (iOS 17+)
.phaseAnimator([1.0, 1.2]) { view, phase in view.scaleEffect(phase) }
.sensoryFeedback(.success, trigger: didSave)

Reusable building blocks

// ViewModifier
struct CardStyle: ViewModifier {
  func body(content: Content) -> some View {
    content.padding(16)
      .background(.background.secondary, in: .rect(cornerRadius: 14))
      .shadow(radius: 2, y: 1)
  }
}
extension View { func card() -> some View { modifier(CardStyle()) } }

// @ViewBuilder for conditional content
@ViewBuilder
func statusView(_ s: Status) -> some View {
  switch s {
  case .loading: ProgressView()
  case .error(let e): Text(e.localizedDescription).foregroundStyle(.red)
  case .loaded(let items): List(items) { ItemRow($0) }
  }
}

// Generic container
struct Card<Content: View>: View {
  @ViewBuilder let content: () -> Content
  var body: some View { VStack { content() }.card() }
}

// PreferenceKey: child -> parent communication
struct HeightKey: PreferenceKey {
  static var defaultValue: CGFloat = 0
  static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
    value = max(value, nextValue())
  }
}

Performance rules

  • Keep body cheap and pure — it can run many times per frame.
  • Split big views into small View structs so re-render scope stays tiny.
  • Use Lazy* stacks/grids for long content; List already recycles.
  • Avoid AnyView — it erases identity and defeats the diffing engine.
  • Avoid heavy work or Date() inside body.
  • GeometryReader in a stack collapses siblings; prefer .containerRelativeFrame or onGeometryChange (iOS 18).
  • Equatable conformance + .equatable() can short-circuit expensive diffs.
  • Instruments → *SwiftUI* template shows view body counts and cause-of-update.

SwiftUI vs UIKit

Translate any UIKit answer into SwiftUI and back.

  • ios
  • ipados
  • tvos
  • visionos
  • SwiftUI
  • UIKit

Sources: SwiftUI — Apple Developer Documentation · UIKit — Apple Developer Documentation

Concept mapping

UIKitSwiftUI
UIViewControllerView struct (no controller object)
viewDidLoad.task { } / .onAppear { } (fires each appearance)
viewWillAppear.onAppear
deinit / viewDidDisappear.onDisappear, or .task auto-cancel
UITableView + data sourceList / ForEach
UICollectionView + compositional layoutLazyVGrid / Grid
UIStackViewVStack / HStack
Auto Layout constraintslayout modifiers (frame, padding, alignment)
UINavigationControllerNavigationStack
UISplitViewControllerNavigationSplitView
present(_:animated:).sheet / .fullScreenCover
UIAlertController.alert / .confirmationDialog
Target-action / IBActionButton { } closure
Delegate & data source protocolsclosures + declarative data
NotificationCenter for state@Observable / @Environment
UIView.animatewithAnimation / .animation(_:value:)
reloadData()state change → automatic diff
prepareForSeguepass values into the destination View's init
UIRefreshControl.refreshable { }
UISearchController.searchable(text:)

A list screen, both ways

UIKit
final class ItemsVC: UITableViewController {
  private var items: [Item] = [] {
    didSet { tableView.reloadData() }
  }

  override func viewDidLoad() {
    super.viewDidLoad()
    tableView.register(Cell.self, forCellReuseIdentifier: "c")
    Task { items = (try? await api.items()) ?? [] }
  }

  override func tableView(_ t: UITableView,
                          numberOfRowsInSection s: Int) -> Int { items.count }

  override func tableView(_ t: UITableView,
                          cellForRowAt ip: IndexPath) -> UITableViewCell {
    let cell = t.dequeueReusableCell(withIdentifier: "c", for: ip)
    cell.textLabel?.text = items[ip.row].name
    return cell
  }

  override func tableView(_ t: UITableView, didSelectRowAt ip: IndexPath) {
    navigationController?.pushViewController(
      DetailVC(item: items[ip.row]), animated: true)
  }
}
SwiftUI
struct ItemsView: View {
  @State private var items: [Item] = []

  var body: some View {
    NavigationStack {
      List(items) { item in
        NavigationLink(item.name, value: item)
      }
      .navigationDestination(for: Item.self) { DetailView(item: $0) }
      .task { items = (try? await api.items()) ?? [] }
    }
  }
}

Embedding UIKit in SwiftUI

`UIViewRepresentable` — know the 3 members + Coordinator.

struct TextViewRepresentable: UIViewRepresentable {
  @Binding var text: String

  func makeUIView(context: Context) -> UITextView {
    let tv = UITextView()
    tv.delegate = context.coordinator
    return tv
  }

  func updateUIView(_ tv: UITextView, context: Context) {
    if tv.text != text { tv.text = text }      // guard against feedback loops
  }

  func makeCoordinator() -> Coordinator { Coordinator(text: $text) }

  final class Coordinator: NSObject, UITextViewDelegate {
    let text: Binding<String>
    init(text: Binding<String>) { self.text = text }
    func textViewDidChange(_ tv: UITextView) { text.wrappedValue = tv.text }
  }
}

// Also: UIViewControllerRepresentable (same shape, makeUIViewController/updateUIViewController)

Embedding SwiftUI in UIKit

let host = UIHostingController(rootView: ProfileView(user: user))

// As a child view controller
addChild(host)
host.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(host.view)
NSLayoutConstraint.activate([
  host.view.topAnchor.constraint(equalTo: view.topAnchor),
  host.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
  host.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
  host.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
host.didMove(toParent: self)

// As a cell (iOS 16+)
cell.contentConfiguration = UIHostingConfiguration { RowView(item: item) }

Interview talking points

  • SwiftUI is declarative + state-driven: you describe *what* the UI is for a given state; the framework diffs and applies changes.
  • Views are cheap value types recreated constantly; the persistent thing is the view *identity* and its attached state.
  • UIKit is imperative + reference-based: you mutate a long-lived view object tree and must keep it in sync by hand.
  • SwiftUI's weak spots: fine-grained scroll control, complex text editing, deep customisation → drop down via Representable.
  • UIKit's diffable data source (NSDiffableDataSourceSnapshot) is the imperative world catching up to SwiftUI's diffing.
  • Both can coexist screen-by-screen; migration is usually leaf-first.

TCA — Core Loop

State - Action - Reducer - Store - Effect. Everything else is composition.

  • ios
  • ipados
  • macos
  • tvos
  • watchos
  • visionos
  • swift-composable-architecture

Sources: The Composable Architecture (Point-Free)

The 5 nouns

Memorise this; every TCA answer starts here.

  • State — a value type holding everything the feature needs to render.
  • Action — an enum of *everything that can happen* (user intent + system results).
  • Reducer — (inout State, Action) -> Effect<Action>. Pure, synchronous, testable.
  • Store — runtime that holds state, runs the reducer, executes effects.
  • Effect — the *only* place async / side effects live. Feeds actions back in.
  • Golden rule: reducers never do async work; effects never mutate state.

Minimal feature

The skeleton you should be able to type from memory.

import ComposableArchitecture

@Reducer
struct Counter {
  @ObservableState
  struct State: Equatable {
    var count = 0
    var fact: String?
    var isLoading = false
  }

  enum Action {
    case incrementTapped
    case decrementTapped
    case factButtonTapped
    case factResponse(Result<String, Error>)
  }

  @Dependency(\.numberFact) var numberFact

  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .incrementTapped:
        state.count += 1
        return .none

      case .decrementTapped:
        state.count -= 1
        return .none

      case .factButtonTapped:
        state.isLoading = true
        return .run { [count = state.count] send in
          await send(.factResponse(Result { try await numberFact.fetch(count) }))
        }

      case let .factResponse(.success(fact)):
        state.isLoading = false
        state.fact = fact
        return .none

      case .factResponse(.failure):
        state.isLoading = false
        return .none
      }
    }
  }
}

The view + entry point

`StoreOf<F>` is `Store<F.State, F.Action>`.

struct CounterView: View {
  let store: StoreOf<Counter>          // @Bindable var store  when you need bindings

  var body: some View {
    Form {
      // With @ObservableState you read state directly - no WithViewStore needed.
      Text("\(store.count)")
      Button("-") { store.send(.decrementTapped) }
      Button("+") { store.send(.incrementTapped) }
      if store.isLoading { ProgressView() }
      if let fact = store.fact { Text(fact) }
    }
  }
}

// App entry
@main
struct MyApp: App {
  static let store = Store(initialState: Counter.State()) {
    Counter()
      ._printChanges()               // debug: logs every action + state diff
  }
  var body: some Scene {
    WindowGroup { CounterView(store: Self.store) }
  }
}

Action naming

Actions describe *facts*, not commands to the store.

Do — user intent + results
enum Action {
  case onAppear
  case saveButtonTapped
  case emailChanged(String)
  case userResponse(Result<User, Error>)
  case timerTicked
}
Don't — imperative / logic-y
enum Action {
  case setLoadingTrue        // that's state, not an event
  case fetchUserAndSave      // two things
  case reloadEverything      // what happened?
  case doTheThing(() -> Void)// not Equatable, untestable
}

Effects cheat list

Everything `Effect` gives you.

return .none                                   // no side effect

return .send(.otherAction)                     // synchronous follow-up (avoid for logic sharing)

return .run { send in                          // async work
  let value = try await api.load()
  await send(.loaded(value))
} catch: { error, send in
  await send(.failed(error))
}

return .merge(.run { ... }, .run { ... })      // run in parallel
return .concatenate(.run { ... }, .run { ... })// run in order

return .run { _ in await analytics.log("x") }  // fire-and-forget

// Cancellation
enum CancelID { case search, timer }
return .run { ... }.cancellable(id: CancelID.search, cancelInFlight: true)
return .cancel(id: CancelID.search)

Debounce & long-running effects

Debounced search + a timer effect started/stopped by actions.

@Dependency(\.continuousClock) var clock

case let .queryChanged(q):
  state.query = q
  return .run { send in
    try await clock.sleep(for: .milliseconds(300))    // debounce
    await send(.searchResponse(try await api.search(q)))
  }
  .cancellable(id: CancelID.search, cancelInFlight: true)

case .startTimerTapped:
  return .run { send in
    for await _ in clock.timer(interval: .seconds(1)) {
      await send(.timerTicked)
    }
  }
  .cancellable(id: CancelID.timer)

case .stopTimerTapped:
  return .cancel(id: CancelID.timer)

Bindings (two-way SwiftUI state)

`BindableAction` + `BindingReducer` + `@Bindable`.

@Reducer
struct Settings {
  @ObservableState
  struct State: Equatable {
    var displayName = ""
    var notificationsOn = false
  }

  enum Action: BindableAction {
    case binding(BindingAction<State>)
    case saveTapped
  }

  var body: some ReducerOf<Self> {
    BindingReducer()                       // must come before your Reduce
    Reduce { state, action in
      switch action {
      case .binding(\.notificationsOn):    // react to one specific binding
        return .run { _ in await push.sync() }
      case .binding, .saveTapped:
        return .none
      }
    }
  }
}

struct SettingsView: View {
  @Bindable var store: StoreOf<Settings>
  var body: some View {
    Form {
      TextField("Name", text: $store.displayName)
      Toggle("Notifications", isOn: $store.notificationsOn)
    }
  }
}

Shared state (across features)

`@Shared` replaces passing the same value up and down the tree.

// In-memory, app-scoped
extension SharedKey where Self == InMemoryKey<Int> {
  static var streak: Self { .inMemory("streak") }
}

@ObservableState
struct State: Equatable {
  @Shared(.appStorage("hasOnboarded")) var hasOnboarded = false
  @Shared(.streak) var streak = 0
}

// Mutating shared state inside a reducer:
state.$streak.withLock { $0 += 1 }

TCA — Composition, Dependencies, Testing

How small features become a big app, and how you prove it works.

  • ios
  • ipados
  • macos
  • tvos
  • watchos
  • visionos
  • swift-composable-architecture

Sources: The Composable Architecture (Point-Free)

Scope: parent embeds child

One `Scope` per child domain, then the parent's own `Reduce`.

@Reducer
struct AppFeature {
  @ObservableState
  struct State: Equatable {
    var counter = Counter.State()
    var profile = Profile.State()
  }
  enum Action {
    case counter(Counter.Action)
    case profile(Profile.Action)
  }

  var body: some ReducerOf<Self> {
    Scope(state: \.counter, action: \.counter) { Counter() }
    Scope(state: \.profile, action: \.profile) { Profile() }

    Reduce { state, action in
      switch action {
      case .counter(.incrementTapped):        // parent can observe child actions
        state.profile.score += 1
        return .none
      case .counter, .profile:
        return .none
      }
    }
  }
}

// View side
CounterView(store: store.scope(state: \.counter, action: \.counter))

Optional & collection children

`ifLet` for optional state, `forEach` for identified collections.

@ObservableState
struct State: Equatable {
  var editor: Editor.State?                        // optional child
  var rows: IdentifiedArrayOf<Row.State> = []      // collection of children
}
enum Action {
  case editor(Editor.Action)
  case rows(IdentifiedActionOf<Row>)
}

var body: some ReducerOf<Self> {
  Reduce { ... }
    .ifLet(\.editor, action: \.editor) { Editor() }
    .forEach(\.rows, action: \.rows) { Row() }
}

// View
ForEach(store.scope(state: \.rows, action: \.rows)) { rowStore in
  RowView(store: rowStore)
}

Child → parent communication

Prefer a `delegate` action over the parent snooping on internals.

@Reducer
struct Row {
  enum Action {
    case deleteTapped
    case delegate(Delegate)                  // public API of the child
    enum Delegate: Equatable { case didRequestDelete(Row.State.ID) }
  }

  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .deleteTapped:
        return .send(.delegate(.didRequestDelete(state.id)))
      case .delegate:
        return .none                          // parent handles it
      }
    }
  }
}

// Parent
case let .rows(.element(id: _, action: .delegate(.didRequestDelete(id)))):
  state.rows.remove(id: id)
  return .none

Dependency client

Struct-of-closures, not a protocol. Swappable, `Sendable`, no subclassing.

@DependencyClient
struct NumberFactClient {
  var fetch: @Sendable (Int) async throws -> String
}

extension NumberFactClient: DependencyKey {
  static let liveValue = Self { number in
    let url = URL(string: "http://numbersapi.com/\(number)")!
    let (data, _) = try await URLSession.shared.data(from: url)
    return String(decoding: data, as: UTF8.self)
  }
  static let previewValue = Self { "\($0) is a preview number." }
  static let testValue = Self()               // unimplemented -> test fails if called
}

extension DependencyValues {
  var numberFact: NumberFactClient {
    get { self[NumberFactClient.self] }
    set { self[NumberFactClient.self] = newValue }
  }
}

Built-in dependencies worth knowing

Never reach for the global versions inside a reducer.

  • Date() → use @Dependency(\.date.now)
  • UUID() → use @Dependency(\.uuid) (.incrementing in tests)
  • Task.sleep / Timer → use @Dependency(\.continuousClock) + TestClock
  • Int.random(in:) → use @Dependency(\.withRandomNumberGenerator)
  • @Dependency(\.mainQueue) for scheduler-based code, \.openURL, \.dismiss
  • Override for a subtree: .dependency(\.uuid, .incrementing) on a reducer, or withDependencies { } operation: { }

TestStore — exhaustive testing

Every state mutation and every received action must be asserted.

@Test
func incrementAndFetchFact() async {
  let store = await TestStore(initialState: Counter.State()) {
    Counter()
  } withDependencies: {
    $0.numberFact.fetch = { "\($0) is a good number." }
  }

  await store.send(.incrementTapped) {
    $0.count = 1                        // assert the *diff* only
  }

  await store.send(.factButtonTapped) {
    $0.isLoading = true
  }

  await store.receive(\.factResponse.success) {
    $0.isLoading = false
    $0.fact = "1 is a good number."
  }
}

Testing time-based effects

`TestClock` makes debounce/timers instant and deterministic.

let clock = TestClock()
let store = await TestStore(initialState: Search.State()) {
  Search()
} withDependencies: {
  $0.continuousClock = clock
  $0.apiClient.search = { _ in ["swift"] }
}

await store.send(.queryChanged("sw")) { $0.query = "sw" }
await clock.advance(by: .milliseconds(300))
await store.receive(\.searchResponse) { $0.results = ["swift"] }

// Non-exhaustive style (integration tests):
store.exhaustivity = .off(showSkippedAssertions: true)

Common TCA pitfalls

The things interviewers poke at.

SymptomCauseFix
"Unhandled action received"Effect emitted an action you didn't assertawait store.receive(\.case) or finish/cancel the effect
Test hangsLong-running effect never ends.cancellable(id:) + await store.send(.stop), or store.finish()
State not updating in viewMissing @ObservableState or store not scopedAdd the macro; scope with key paths
Effect keeps old stateCaptured state after mutationCapture explicitly: .run { [x = state.x] send in ... }
Duplicate network callsNo cancellation on re-entrycancelInFlight: true
Store deinit warningsChild store outlived parent stateUse ifLet/forEach scoping, not manual stores

TCA + SwiftUI — Navigation & UI

Tree-based (@Presents) for sheets/alerts, stack-based (StackState) for push navigation.

  • ios
  • ipados
  • macos
  • tvos
  • watchos
  • visionos
  • swift-composable-architecture
  • SwiftUI

Sources: The Composable Architecture (Point-Free) · SwiftUI — Apple Developer Documentation

Pick your navigation style

  • Tree-based — @Presents var destination: Destination.State?. Best for sheets, popovers, alerts, one-off drill-ins.
  • Stack-based — var path = StackState<Path.State>(). Best for arbitrarily deep push navigation.
  • Both make navigation *state-driven*: set state → screen appears. Deep links become State construction.
  • @Presents gives you free dismissal handling: SwiftUI setting the binding to nil sends .dismiss.

Tree-based: sheet / popover / fullScreenCover

One enum reducer for all destinations of a screen.

@Reducer
struct Inventory {
  @Reducer
  enum Destination {                       // @Reducer on an enum = destination reducer
    case addItem(ItemForm)
    case detail(ItemDetail)
  }

  @ObservableState
  struct State: Equatable {
    @Presents var destination: Destination.State?
    var items: IdentifiedArrayOf<Item> = []
  }

  enum Action {
    case addTapped
    case itemTapped(Item.ID)
    case destination(PresentationAction<Destination.Action>)
  }

  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .addTapped:
        state.destination = .addItem(ItemForm.State())
        return .none

      case let .itemTapped(id):
        guard let item = state.items[id: id] else { return .none }
        state.destination = .detail(ItemDetail.State(item: item))
        return .none

      // Listen to a child action across the presentation boundary:
      case let .destination(.presented(.addItem(.delegate(.saved(item))))):
        state.items.append(item)
        state.destination = nil            // programmatic dismiss
        return .none

      case .destination:
        return .none
      }
    }
    .ifLet(\.$destination, action: \.destination)
  }
}

Tree-based: the view side

`$store.scope` turns optional child state into a SwiftUI item binding.

struct InventoryView: View {
  @Bindable var store: StoreOf<Inventory>

  var body: some View {
    List {
      ForEach(store.items) { item in
        Button(item.name) { store.send(.itemTapped(item.id)) }
      }
    }
    .toolbar { Button("Add") { store.send(.addTapped) } }

    .sheet(item: $store.scope(state: \.destination?.addItem,
                              action: \.destination.addItem)) { formStore in
      NavigationStack { ItemFormView(store: formStore) }
    }

    .navigationDestination(item: $store.scope(state: \.destination?.detail,
                                              action: \.destination.detail)) { detailStore in
      ItemDetailView(store: detailStore)
    }
    // also: .popover(item:), .fullScreenCover(item:)
  }
}

Alerts & confirmation dialogs

`AlertState` keeps alerts testable — no view code decides anything.

@ObservableState
struct State: Equatable {
  @Presents var alert: AlertState<Action.Alert>?
}

enum Action {
  case deleteTapped
  case alert(PresentationAction<Alert>)
  @CasePathable enum Alert: Equatable { case confirmDelete }
}

case .deleteTapped:
  state.alert = AlertState {
    TextState("Delete this item?")
  } actions: {
    ButtonState(role: .destructive, action: .confirmDelete) { TextState("Delete") }
    ButtonState(role: .cancel) { TextState("Cancel") }
  } message: {
    TextState("This cannot be undone.")
  }
  return .none

case .alert(.presented(.confirmDelete)):
  state.items.remove(id: state.selectedID)
  return .none

case .alert:
  return .none

// reducer:  .ifLet(\.$alert, action: \.alert)
// view:     .alert($store.scope(state: \.alert, action: \.alert))

Stack-based navigation

`StackState` + `NavigationStack(path:)`. Deep links = appending to the path.

@Reducer
struct AppFeature {
  @Reducer
  enum Path {
    case detail(Detail)
    case settings(Settings)
  }

  @ObservableState
  struct State: Equatable {
    var path = StackState<Path.State>()
    var root = Root.State()
  }

  enum Action {
    case root(Root.Action)
    case path(StackActionOf<Path>)
  }

  var body: some ReducerOf<Self> {
    Scope(state: \.root, action: \.root) { Root() }
    Reduce { state, action in
      switch action {
      // React to an action from *any* Detail screen on the stack:
      case let .path(.element(id: id, action: .detail(.doneTapped))):
        state.path.pop(from: id)
        return .none
      case .root, .path:
        return .none
      }
    }
    .forEach(\.path, action: \.path)
  }
}

Stack-based: the view side

`NavigationLink(state:)` replaces `NavigationLink(destination:)`.

struct AppView: View {
  @Bindable var store: StoreOf<AppFeature>

  var body: some View {
    NavigationStack(path: $store.scope(state: \.path, action: \.path)) {
      RootView(store: store.scope(state: \.root, action: \.root))
    } destination: { store in
      switch store.case {                       // exhaustive over Path
      case let .detail(store):   DetailView(store: store)
      case let .settings(store): SettingsView(store: store)
      }
    }
  }
}

// Inside RootView — push by *state*, not by view:
NavigationLink(state: AppFeature.Path.State.detail(Detail.State(id: item.id))) {
  Text(item.name)
}

// Programmatic push / pop / deep link:
state.path.append(.detail(Detail.State(id: id)))
state.path.removeLast()
state.path = StackState([.settings(Settings.State())])

Dismissing yourself (child-initiated)

`@Dependency(\.dismiss)` — the child doesn't need to know its parent.

@Reducer
struct ItemForm {
  @Dependency(\.dismiss) var dismiss

  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .cancelTapped:
        return .run { _ in await self.dismiss() }   // must be in .run, not sync
      ...
      }
    }
  }
}

Navigation: vanilla SwiftUI vs TCA

Same idea — state drives navigation — but TCA moves the state into the reducer.

Vanilla SwiftUI
struct ListView: View {
  @State private var selected: Item?
  @State private var showingAdd = false

  var body: some View {
    List(items) { item in
      Button(item.name) { selected = item }
    }
    .sheet(item: $selected) { DetailView(item: $0) }
    .sheet(isPresented: $showingAdd) { AddView() }
  }
}
TCA
struct ListView: View {
  @Bindable var store: StoreOf<Feature>

  var body: some View {
    List(store.items) { item in
      Button(item.name) { store.send(.itemTapped(item.id)) }
    }
    .sheet(item: $store.scope(state: \.destination?.detail,
                              action: \.destination.detail),
           content: DetailView.init(store:))
  }
}
// Navigation is now testable + deep-linkable.

Modifier → TCA scope mapping

Cheat table for the live exercise.

SwiftUITCA form
.sheet(isPresented:).sheet(item: $store.scope(state: \.destination?.x, action: \.destination.x))
.sheet(item:)same as above (identity comes from the store)
.alert(isPresented:).alert($store.scope(state: \.alert, action: \.alert))
.confirmationDialog.confirmationDialog($store.scope(state: \.dialog, action: \.dialog))
NavigationStack(path:)NavigationStack(path: $store.scope(state: \.path, action: \.path))
NavigationLink(value:)NavigationLink(state: Path.State.detail(...))
@Environment(\.dismiss)@Dependency(\.dismiss) inside the reducer

Modern Swift Concurrency — Core

async/await, structured tasks, groups, streams, cancellation.

Sources: Concurrency — The Swift Programming Language

async / await basics

`await` = a possible suspension point, not a thread hop guarantee.

func loadUser(id: Int) async throws -> User {
  let (data, response) = try await URLSession.shared.data(from: url(id))
  guard (response as? HTTPURLResponse)?.statusCode == 200 else { throw APIError.badStatus }
  return try JSONDecoder().decode(User.self, from: data)
}

// Sequential: total time = a + b
let user = try await loadUser(id: 1)
let posts = try await loadPosts(for: user)

// Concurrent: total time = max(a, b)
async let user  = loadUser(id: 1)
async let stats = loadStats(id: 1)
let (u, s) = try await (user, stats)      // both awaited here

Structured vs unstructured

Interviewers love this distinction.

  • Structured: async let, TaskGroup. Child tasks cannot outlive the scope; cancellation and errors propagate automatically.
  • Unstructured: Task { } — inherits actor isolation + priority, but you must retain/cancel it yourself.
  • Detached: Task.detached { } — inherits *nothing*. Rarely the right answer; only for truly independent background work.
  • Rule of thumb: use structured concurrency; Task { } only at the boundary between sync (e.g. a SwiftUI button) and async code.

TaskGroup — dynamic fan-out

Use when the number of child tasks is only known at runtime.

func thumbnails(for ids: [String]) async throws -> [String: UIImage] {
  try await withThrowingTaskGroup(of: (String, UIImage).self) { group in
    for id in ids {
      group.addTask { (id, try await fetchThumbnail(id)) }   // runs concurrently
    }
    var result: [String: UIImage] = [:]
    for try await (id, image) in group {                     // results in COMPLETION order
      result[id] = image
    }
    return result
  }
}

// Bounded concurrency (don't launch 10 000 tasks):
try await withThrowingTaskGroup(of: Void.self) { group in
  var it = ids.makeIterator()
  for _ in 0..<4 { if let id = it.next() { group.addTask { try await work(id) } } }
  while try await group.next() != nil {
    if let id = it.next() { group.addTask { try await work(id) } }
  }
}

Cancellation

Cooperative: nothing is killed, you must check.

Task.isCancelled                       // non-throwing check
try Task.checkCancellation()           // throws CancellationError

let task = Task {
  for chunk in chunks {
    try Task.checkCancellation()
    await process(chunk)
  }
}
task.cancel()                          // cancels task AND all its children
await task.value                       // await result (rethrows)

// Clean up when cancelled mid-flight:
await withTaskCancellationHandler {
  try await longRunningWork()
} onCancel: {
  connection.close()                   // runs immediately, sync, on any thread
}

// try await Task.sleep(...) throws CancellationError immediately on cancel.

AsyncSequence & AsyncStream

Bridge callbacks/delegates into async, and iterate over time.

// Consume
for try await line in url.lines { print(line) }
for await note in NotificationCenter.default.notifications(named: .didLogin) { ... }

// Produce — bridging a callback API
func locations() -> AsyncStream<CLLocation> {
  AsyncStream { continuation in
    let manager = LocationManager()
    manager.onUpdate = { continuation.yield($0) }
    continuation.onTermination = { _ in manager.stop() }
    manager.start()
  }
}

// Throwing variant + explicit finish
let stream = AsyncThrowingStream<Int, Error> { c in
  c.yield(1); c.yield(2); c.finish()
}

// Operators exist too:
for await x in stream.map { $0 * 2 }.filter({ $0 > 2 }).prefix(10) { ... }

Bridging legacy callbacks

Continuations: resume exactly once, or you crash / leak.

func load() async throws -> Data {
  try await withCheckedThrowingContinuation { continuation in
    legacyLoad { data, error in
      if let error { continuation.resume(throwing: error) }
      else { continuation.resume(returning: data!) }
    }
  }
}

// Cancellable version
func load() async throws -> Data {
  try await withTaskCancellationHandler {
    try await withCheckedThrowingContinuation { c in
      task = legacyLoad { ... }
    }
  } onCancel: { task?.cancel() }
}

// withUnsafeContinuation = same, no runtime checks. Use Checked while developing.

Task API quick reference

APIInherits isolation?Inherits priority?Use for
async letyesyesfixed number of parallel calls
withTaskGroupyesyesdynamic fan-out / bounded parallelism
Task { }yesyessync → async boundary (button taps, onAppear)
Task.detached { }nonobackground work unrelated to the caller
Task.yield()——give other tasks a turn in a long CPU loop
Task.sleep(for:)——delays (cancellable, never blocks a thread)

MainActor hop

Only touch UI on the main actor; the compiler enforces it in Swift 6.

@MainActor
final class ProfileViewModel: ObservableObject {
  @Published private(set) var user: User?

  func load() async {                     // inherits @MainActor
    let fetched = try? await api.user()   // api call suspends off the main actor
    self.user = fetched                   // back on main automatically
  }
}

// Explicit hop from a nonisolated context:
await MainActor.run { label.text = "done" }

// Already on main and need it synchronously (e.g. in a delegate callback):
MainActor.assumeIsolated { label.text = "done" }   // traps if not actually on main

Actors, Sendable & Isolation

The Swift 6 data-race safety model, and how to talk about it.

  • swift >=6.0

Sources: Concurrency — The Swift Programming Language · Migrating to Swift 6 — swift.org

actor = reference type with a serial executor

Mutable state protected by the compiler.

actor ImageCache {
  private var cache: [URL: UIImage] = [:]        // isolated: only reachable via await

  func image(for url: URL) -> UIImage? { cache[url] }     // implicitly async from outside

  func insert(_ image: UIImage, for url: URL) { cache[url] = image }

  nonisolated let name: String = "images"        // immutable -> no isolation needed
  nonisolated func describe() -> String { "Cache(\(name))" }
}

let cache = ImageCache()
await cache.insert(image, for: url)              // cross-actor call = await
let img = await cache.image(for: url)

Actor reentrancy — the classic trap

Every `await` inside an actor is a chance for other calls to interleave.

actor Downloader {
  private var cache: [URL: Data] = [:]
  private var inFlight: [URL: Task<Data, Error>] = [:]

  // BUG: two callers can both see an empty cache and both download.
  func badFetch(_ url: URL) async throws -> Data {
    if let hit = cache[url] { return hit }
    let data = try await URLSession.shared.data(from: url).0   // <-- suspension!
    cache[url] = data
    return data
  }

  // FIX: store the *task*, not just the result, before suspending.
  func fetch(_ 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 URLSession.shared.data(from: url).0 }
    inFlight[url] = task
    defer { inFlight[url] = nil }
    let data = try await task.value
    cache[url] = data
    return data
  }
}

Sendable in one screen

What can safely cross an isolation boundary.

  • struct/enum of Sendable members → implicit Sendable (if not public; public needs explicit conformance).
  • final class with only let Sendable properties → can be Sendable.
  • actor types are always Sendable.
  • Non-final classes and classes with var state are not Sendable.
  • @unchecked Sendable = "I promise I lock correctly." Only with a real mutex/queue inside.
  • @Sendable on closures: no capture of non-Sendable state, no mutable captures.
  • sending parameters (SE-0430) transfer ownership across isolation without needing Sendable.

Sendable conformance

Do
struct User: Sendable {
  let id: UUID
  let name: String
}

final class Counter: @unchecked Sendable {
  private let lock = NSLock()
  private var _value = 0
  var value: Int { lock.withLock { _value } }
  func increment() { lock.withLock { _value += 1 } }
}

@MainActor final class ViewModel { var items: [User] = [] }
// MainActor isolation makes it safe to share.
Don't
final class Counter: @unchecked Sendable {
  var value = 0                 // no lock -> a lie to the compiler
}

class Model: Sendable { }       // non-final: subclass can add mutable state

// Capturing mutable local state in a @Sendable closure
var total = 0
Task { total += 1 }             // error / race

Global actors & custom isolation

`@MainActor` is just the built-in global actor.

@globalActor
actor DatabaseActor {
  static let shared = DatabaseActor()
}

@DatabaseActor
final class Store {
  private var rows: [Row] = []          // all members isolated to DatabaseActor
  func insert(_ r: Row) { rows.append(r) }
}

@DatabaseActor func migrate() async { ... }

// Opting a member out
@MainActor
final class VM {
  nonisolated let id = UUID()
  nonisolated func pureHelper(_ x: Int) -> Int { x * 2 }
}

Isolated & sending parameters

Advanced but very quotable in an interview.

// Run code *in* someone else's isolation domain
func mutate(_ cache: isolated ImageCache, url: URL) {
  cache.evict(url)                        // no await needed: we're isolated to it
}

// Transfer a non-Sendable value across a boundary (compiler proves no other refs)
func handOff(_ model: sending NonSendableModel) async {
  await actor.consume(model)
}

// #isolation gives the caller's isolation to a generic API
func run(isolation: isolated (any Actor)? = #isolation, _ body: () async -> Void) async {
  await body()
}

Isolation quick decisions

SituationAnswer
UI / view model state@MainActor on the type
Shared mutable cache or connection poolactor
Pure value passed between tasksstruct + Sendable
Legacy class you can't changewrap in an actor, or @unchecked Sendable + lock
One-off background computationTask.detached or await a nonisolated func
Callback API from a C/ObjC libAsyncStream or continuation, marked @Sendable

Swift 6 Concurrency — Do's & Don'ts

Migration traps, and how modern code meets legacy GCD/Combine code.

  • swift >=6.0

Sources: Migrating to Swift 6 — swift.org · Concurrency — The Swift Programming Language

Never block a thread waiting on async

The cooperative thread pool has ~one thread per core. Blocking one can deadlock the app.

Don't
func value() -> Int {
  let sem = DispatchSemaphore(value: 0)
  var result = 0
  Task {
    result = await compute()
    sem.signal()
  }
  sem.wait()          // deadlock risk, priority inversion
  return result
}

DispatchQueue.main.sync { ... }   // from async code: hangs
Do
func value() async -> Int {
  await compute()
}

// At a sync boundary you cannot change, hop instead of block:
Task { @MainActor in
  let v = await compute()
  self.label.text = "\(v)"
}

Don't use queues for mutual exclusion in new code

Legacy
final class Store {
  private let q = DispatchQueue(label: "store")
  private var items: [Item] = []

  func add(_ i: Item) { q.async { self.items.append(i) } }
  func all(_ done: @escaping ([Item]) -> Void) {
    q.async { done(self.items) }
  }
}
Modern
actor Store {
  private var items: [Item] = []
  func add(_ i: Item) { items.append(i) }
  func all() -> [Item] { items }
}

let store = Store()
await store.add(item)
let items = await store.all()

@MainActor over DispatchQueue.main

Legacy
URLSession.shared.dataTask(with: url) { data, _, error in
  DispatchQueue.main.async {
    self.items = decode(data)
    self.tableView.reloadData()
  }
}.resume()
Modern
@MainActor
func reload() async throws {
  let (data, _) = try await URLSession.shared.data(from: url)
  items = try decode(data)      // already on main; compiler-checked
}

Do

  • Mark model types Sendable and keep them value types.
  • Put @MainActor on view models / anything touching UIKit or SwiftUI state.
  • Use nonisolated for pure helpers so callers don't need await.
  • Prefer async let / TaskGroup over manually spawned Tasks.
  • Store Task handles you need to cancel (and cancel in deinit / onDisappear).
  • Use Task.sleep, ContinuousClock, or an injected clock instead of Thread.sleep/Timer.
  • Turn on SWIFT_STRICT_CONCURRENCY = complete per-module and migrate incrementally.
  • Use @preconcurrency import to silence a legacy module while you migrate.

Don't

  • Don't use DispatchSemaphore/DispatchGroup.wait() to make async look sync.
  • Don't sprinkle Task { } inside loops — that's unstructured and unbounded.
  • Don't use Task.detached just to "get off the main thread"; a nonisolated async func already does.
  • Don't add @unchecked Sendable to silence errors without a real lock.
  • Don't assume state is unchanged across an await inside an actor (reentrancy).
  • Don't capture self strongly in a long-lived Task on a view controller.
  • Don't resume a continuation twice (crash) or zero times (leak + hang).
  • Don't rely on Thread.current — a task can resume on any thread.

Making a legacy delegate Swift 6 safe

Common migration exercise.

// The delegate protocol is not isolated; the callback arrives on an unknown thread.
final class Scanner: NSObject, ScannerDelegate {
  private let continuation: AsyncStream<Code>.Continuation
  let codes: AsyncStream<Code>

  override init() {
    var c: AsyncStream<Code>.Continuation!
    codes = AsyncStream { c = $0 }
    continuation = c
    super.init()
  }

  nonisolated func scanner(_ s: RawScanner, didFind code: Code) {
    continuation.yield(code)          // Continuation is Sendable -> safe from any thread
  }
}

// Consumer stays on the main actor:
@MainActor
func observe(_ scanner: Scanner) async {
  for await code in scanner.codes { self.lastCode = code }
}

Common Swift 6 errors → fix

Compiler saysMeaningFix
Capture of non-Sendable type in @Sendable closureYou're sharing a class across domainsmake it a struct, an actor, or pass sending
Main actor-isolated property can not be referenced from a nonisolated contextSync access to UI stateawait MainActor.run { } or make the caller @MainActor
Sending value risks causing data racesValue could still be referenced by the sendermake it Sendable, or don't touch it after sending
Static property is not concurrency-safeMutable globallet + Sendable, @MainActor static var, or nonisolated(unsafe) as a stopgap
Call to actor-isolated method in a synchronous nonisolated contextMissing awaitmake the caller async, or wrap in Task { }
Non-sendable type exiting main actor-isolated contextReturning a class from @MainActor codereturn a Sendable snapshot instead

Multithreading — GCD vs Combine vs async/await

Same 12 problems, three eras of solution. Read across the row.

  • ios
  • ipados
  • macos
  • tvos
  • watchos
  • visionos
  • Dispatch
  • Combine

Sources: Dispatch — Apple Developer Documentation · Combine — Apple Developer Documentation · Concurrency — The Swift Programming Language

Run work off the main thread, then update UI

GCD (legacy)
DispatchQueue.global(qos: .userInitiated).async {
  let result = expensive()
  DispatchQueue.main.async {
    self.label.text = result
  }
}
Combine
Just(input)
  .subscribe(on: DispatchQueue.global())
  .map(expensive)
  .receive(on: DispatchQueue.main)
  .sink { self.label.text = $0 }
  .store(in: &cancellables)
async/await
Task { @MainActor in
  let result = await Task.detached(priority: .userInitiated) {
    expensive()
  }.value
  label.text = result
}

Wait for N parallel jobs

DispatchGroup
let group = DispatchGroup()
var images: [UIImage] = []
let lock = NSLock()

for url in urls {
  group.enter()
  download(url) { image in
    lock.withLock { images.append(image) }
    group.leave()
  }
}
group.notify(queue: .main) { render(images) }
Combine
Publishers.MergeMany(urls.map(downloadPublisher))
  .collect()
  .receive(on: DispatchQueue.main)
  .sink { render($0) }
  .store(in: &cancellables)

// or Publishers.Zip / .zip for pairwise
TaskGroup
let images = try await withThrowingTaskGroup(of: UIImage.self) { group in
  for url in urls { group.addTask { try await download(url) } }
  var out: [UIImage] = []
  for try await img in group { out.append(img) }
  return out
}
render(images)          // ordering is completion order

Serialize access to shared mutable state

Serial queue / lock
let queue = DispatchQueue(label: "counter")
var count = 0
queue.sync { count += 1 }

// or a barrier on a concurrent queue (readers-writer)
let rw = DispatchQueue(label: "rw", attributes: .concurrent)
rw.async(flags: .barrier) { count += 1 }
rw.sync { _ = count }
Combine
// Combine has no mutual exclusion primitive.
// You still need a lock/queue, or confine all
// mutation to one scheduler:
subject
  .receive(on: serialQueue)
  .sink { count += $0 }
  .store(in: &cancellables)
Actor
actor Counter {
  private var value = 0
  func increment() { value += 1 }
  func current() -> Int { value }
}
await counter.increment()

Cancel in-flight work

GCD
let item = DispatchWorkItem { heavyWork() }
DispatchQueue.global().async(execute: item)
item.cancel()               // only prevents *start*, can't interrupt

URLSessionTask.cancel()     // for networking
Combine
let c = publisher.sink { ... }
c.cancel()                  // cancels the whole chain

// automatic when the AnyCancellable deallocates
cancellables.removeAll()
Structured
let task = Task { try await work() }
task.cancel()               // propagates to children

// cooperative check inside:
try Task.checkCancellation()
if Task.isCancelled { return }

Delay / debounce / throttle

GCD
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { ... }

// manual debounce
var work: DispatchWorkItem?
func search(_ q: String) {
  work?.cancel()
  let w = DispatchWorkItem { performSearch(q) }
  work = w
  DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: w)
}
Combine
$query
  .debounce(for: .milliseconds(300), scheduler: RunLoop.main)
  .removeDuplicates()
  .sink { performSearch($0) }
  .store(in: &cancellables)

$scroll.throttle(for: .seconds(1),
                 scheduler: RunLoop.main,
                 latest: true)
async/await
var searchTask: Task<Void, Never>?

func search(_ q: String) {
  searchTask?.cancel()
  searchTask = Task {
    try? await Task.sleep(for: .milliseconds(300))
    guard !Task.isCancelled else { return }
    await performSearch(q)
  }
}

Retry with backoff

GCD
func attempt(_ n: Int) {
  request { result in
    switch result {
    case .success(let v): done(v)
    case .failure where n > 0:
      DispatchQueue.global().asyncAfter(
        deadline: .now() + pow(2, Double(3 - n))) {
        attempt(n - 1)
      }
    case .failure(let e): fail(e)
    }
  }
}
Combine
requestPublisher
  .retry(3)                       // immediate retries
  .catch { _ in Just(.cached) }
  .eraseToAnyPublisher()

// backoff needs delay + retry composition:
.catch { _ in
  Just(()).delay(for: .seconds(2), scheduler: q)
    .flatMap { requestPublisher }
}
async/await
func withRetry<T>(_ max: Int = 3,
                  _ op: () async throws -> T) async throws -> T {
  for attempt in 0..<max {
    do { return try await op() }
    catch {
      if attempt == max - 1 { throw error }
      try await Task.sleep(for: .seconds(pow(2, Double(attempt))))
    }
  }
  fatalError("unreachable")
}

Concept translation table

NeedGCDCombineConcurrency
Background executionDispatchQueue.global().async.subscribe(on:)await a nonisolated func / Task.detached
Main-thread deliveryDispatchQueue.main.async.receive(on: DispatchQueue.main)@MainActor
Fan-out + joinDispatchGroupMergeMany + collectTaskGroup / async let
Mutual exclusionserial queue, NSLock, barrier— (use a lock)actor
CancellationDispatchWorkItem.cancel()AnyCancellable.cancel()Task.cancel() (cooperative)
TimerDispatchSourceTimerTimer.publishAsyncTimerSequence / clock.timer
Stream of valuescallbacksPublisherAsyncSequence
PriorityQoS on the queuescheduler choiceTaskPriority (inherited)
Deadlock riskhigh (sync on same queue)lowlow (never block)

Talking points

Short answers that show depth.

  • GCD threads *block*; Swift concurrency tasks *suspend* and free the thread. That's why blocking a cooperative thread is so bad.
  • await does not guarantee a thread change; it guarantees the correct isolation domain.
  • Thread explosion: 1000 DispatchQueue.global().async calls can spawn ~64+ threads. TaskGroup is bounded by the core count.
  • Priority inversion: GCD needs manual QoS care; Swift concurrency escalates priority automatically for actor/task dependencies.
  • Combine is still the right answer for UIKit-era reactive pipelines and for @Published; new code usually prefers AsyncSequence + Observation.

Combine — Core

Publisher -> Operator -> Subscriber. Output + Failure are part of the type.

  • ios
  • ipados
  • macos
  • tvos
  • watchos
  • visionos
  • Combine

Sources: Combine — Apple Developer Documentation

Mental model

  • Publisher<Output, Failure> emits 0…n values then either .finished or .failure — never both, never after.
  • Subscriber requests demand (backpressure). sink/assign request .unlimited.
  • A pipeline is inert until subscribed, and dies when its AnyCancellable deallocates.
  • Failure == Never means the compiler knows it can't fail — required by assign(to:on:).
  • .eraseToAnyPublisher() hides the huge generic type at API boundaries.

Creating publishers

Just(42)                                   // one value, then finish. Failure == Never
Empty<Int, Never>()                        // finish immediately
Fail<Int, MyError>(error: .boom)           // fail immediately
[1, 2, 3].publisher                        // Sequence
Future<Int, Error> { promise in            // single async result, EAGER
  api.load { promise(.success($0)) }
}
Deferred { Future { ... } }                // make Future lazy (one call per subscriber)

URLSession.shared.dataTaskPublisher(for: url)
Timer.publish(every: 1, on: .main, in: .common).autoconnect()
NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)
$searchText                                // @Published projected value

Subjects — imperative bridge into Combine

let events = PassthroughSubject<Event, Never>()      // no memory of last value
let state  = CurrentValueSubject<State, Never>(.idle) // replays current value on subscribe

events.send(.tapped)
state.send(.loading)
state.value = .loaded          // CurrentValueSubject only
events.send(completion: .finished)

// @Published is a CurrentValueSubject in disguise
final class VM: ObservableObject {
  @Published var query = ""
  @Published private(set) var results: [Item] = []
}

Operators you actually need

CategoryOperators
Transformmap tryMap compactMap flatMap scan reduce replaceNil setFailureType
Filterfilter removeDuplicates first(where:) dropFirst prefix ignoreOutput
CombinecombineLatest zip merge switchToLatest Publishers.MergeMany
Timingdebounce throttle delay timeout measureInterval
Errorscatch tryCatch retry replaceError mapError assertNoFailure
Schedulingreceive(on:) subscribe(on:)
Sharingshare() multicast makeConnectable()
Side effectshandleEvents(receiveOutput:receiveCompletion:) (debugging), print()
Sinkssink assign(to:on:) assign(to: &$published)

flatMap vs switchToLatest vs combineLatest vs zip

The most-asked Combine question.

flatMap / switchToLatest
// flatMap: keeps ALL inner publishers alive
$query.flatMap { api.search($0) }        // stale results can arrive late

// switchToLatest: cancels the previous inner publisher
$query
  .map { api.search($0) }
  .switchToLatest()                      // correct for search-as-you-type

// flatMap(maxPublishers: .max(1)) = serial
combineLatest / zip / merge
// combineLatest: emits on ANY input change, using latest of each
Publishers.CombineLatest($email, $password)
  .map { !$0.isEmpty && $1.count >= 8 }
  .assign(to: &$isValid)

// zip: pairs by index, waits for both. 1+1 -> 1 emission
Publishers.Zip(uploadA, uploadB)

// merge: interleaves, same Output type
Publishers.Merge($fromCache, $fromNetwork)

Networking pipeline

The canonical whiteboard answer.

func search(_ query: String) -> AnyPublisher<[Repo], APIError> {
  var components = URLComponents(string: "https://api.github.com/search/repositories")!
  components.queryItems = [.init(name: "q", value: query)]

  return URLSession.shared.dataTaskPublisher(for: components.url!)
    .tryMap { data, response in
      guard let http = response as? HTTPURLResponse, 200..<300 ~= http.statusCode else {
        throw APIError.badStatus
      }
      return data
    }
    .decode(type: SearchResponse.self, decoder: JSONDecoder())
    .map(\.items)
    .mapError { $0 as? APIError ?? .underlying($0) }
    .receive(on: DispatchQueue.main)
    .eraseToAnyPublisher()
}

// Wiring it up
$query
  .debounce(for: .milliseconds(300), scheduler: RunLoop.main)
  .removeDuplicates()
  .filter { $0.count > 2 }
  .map { [search] q in search(q).catch { _ in Just([]) } }
  .switchToLatest()
  .assign(to: &$repos)

Subscribing & lifetime

private var cancellables = Set<AnyCancellable>()

publisher
  .sink(
    receiveCompletion: { completion in
      if case let .failure(error) = completion { self.show(error) }
    },
    receiveValue: { value in self.items = value }
  )
  .store(in: &cancellables)

// Failure == Never shortcuts
publisher.sink { self.items = $0 }.store(in: &cancellables)
publisher.assign(to: \.text, on: label).store(in: &cancellables)
publisher.assign(to: &$items)              // @Published target, no cancellable needed

// assign(to:on:) retains `on` -> use [weak self] in sink instead of assign(to:on: self)

Combine ↔ async/await bridging

You will be asked how to migrate.

Publisher → async
for await value in publisher.values { ... }

// single value
let user = try await publisher
  .first()
  .values
  .first(where: { _ in true })

// or the common helper:
extension Publisher {
  var firstValue: Output {
    get async throws {
      for try await v in values { return v }
      throw CancellationError()
    }
  }
}
async → Publisher
func publisher<T>(_ op: @escaping () async throws -> T)
  -> AnyPublisher<T, Error> {
  Deferred {
    Future { promise in
      Task {
        do { promise(.success(try await op())) }
        catch { promise(.failure(error)) }
      }
    }
  }
  .eraseToAnyPublisher()
}

Combine gotchas

  • Future is eager — it runs at creation, not at subscription. Wrap in Deferred.
  • Forgetting .store(in:) → pipeline cancelled immediately, nothing happens.
  • assign(to:\.x, on: self) creates a retain cycle. Use sink { [weak self] }.
  • subscribe(on:) affects the *subscription*, receive(on:) affects delivery downstream of it.
  • An error terminates the stream forever. Use catch/replaceError inside a flatMap to keep the outer stream alive.
  • Combine has no built-in backpressure control in sink — it requests unlimited demand.

Timers & Async Patterns — Combine vs async/await

Every periodic / concurrent case side by side. Grab and adapt.

  • ios
  • ipados
  • macos
  • tvos
  • watchos
  • visionos
  • Combine

Sources: Combine — Apple Developer Documentation · Concurrency — The Swift Programming Language

Repeating timer

Combine
Timer.publish(every: 1, on: .main, in: .common)
  .autoconnect()                       // ConnectablePublisher!
  .scan(0) { n, _ in n + 1 }
  .sink { self.seconds = $0 }
  .store(in: &cancellables)

// stop:
cancellables.removeAll()
async/await
timerTask = Task { @MainActor in
  var n = 0
  while !Task.isCancelled {
    try? await Task.sleep(for: .seconds(1))
    n += 1
    self.seconds = n
  }
}
// stop:
timerTask?.cancel()

// swift-async-algorithms:
for await _ in AsyncTimerSequence(interval: .seconds(1),
                                  clock: .continuous) { ... }

One-shot delay

Combine
Just(())
  .delay(for: .seconds(2), scheduler: RunLoop.main)
  .sink { self.showToast = false }
  .store(in: &cancellables)
async/await
Task {
  try await Task.sleep(for: .seconds(2))
  showToast = false
}
// cancellable, never blocks a thread

Countdown / polling until a condition

Combine
Timer.publish(every: 1, on: .main, in: .common)
  .autoconnect()
  .scan(10) { n, _ in n - 1 }
  .prefix(while: { $0 >= 0 })          // completes at 0
  .sink(receiveCompletion: { _ in self.finish() },
        receiveValue: { self.remaining = $0 })
  .store(in: &cancellables)
async/await
func countdown(from n: Int) async {
  for i in stride(from: n, through: 0, by: -1) {
    remaining = i
    guard i > 0 else { break }
    try? await Task.sleep(for: .seconds(1))
  }
  finish()
}

// Polling with backoff
while !Task.isCancelled {
  if try await job.isDone() { break }
  try await Task.sleep(for: .seconds(2))
}

Timeout on an operation

Combine
request
  .timeout(.seconds(5), scheduler: DispatchQueue.main,
           customError: { APIError.timedOut })
  .sink(...)
  .store(in: &cancellables)
async/await
func withTimeout<T: Sendable>(
  _ seconds: Double,
  _ op: @escaping @Sendable () async throws -> T
) async throws -> T {
  try await withThrowingTaskGroup(of: T.self) { group in
    group.addTask { try await op() }
    group.addTask {
      try await Task.sleep(for: .seconds(seconds))
      throw TimeoutError()
    }
    let first = try await group.next()!
    group.cancelAll()                    // cancel the loser
    return first
  }
}

Debounced search-as-you-type

Classic live-coding exercise.

Combine
final class SearchVM: ObservableObject {
  @Published var query = ""
  @Published private(set) var results: [Item] = []
  private var bag = Set<AnyCancellable>()

  init(api: API) {
    $query
      .debounce(for: .milliseconds(300), scheduler: RunLoop.main)
      .removeDuplicates()
      .map { q in
        api.search(q).replaceError(with: [])
      }
      .switchToLatest()
      .receive(on: RunLoop.main)
      .assign(to: &$results)
  }
}
async/await (Observation)
@MainActor @Observable
final class SearchVM {
  var query = "" { didSet { schedule() } }
  private(set) var results: [Item] = []
  private var task: Task<Void, Never>?

  private func schedule() {
    task?.cancel()
    task = Task {
      try? await Task.sleep(for: .milliseconds(300))
      guard !Task.isCancelled else { return }
      results = (try? await api.search(query)) ?? []
    }
  }
}

// SwiftUI-native alternative:
.task(id: query) {
  try? await Task.sleep(for: .milliseconds(300))
  results = await api.search(query)
}

Merge two sources of truth (cache + network)

Combine
Publishers.Merge(
  cache.publisher.setFailureType(to: Error.self),
  network.publisher
)
.receive(on: DispatchQueue.main)
.sink(...)
.store(in: &cancellables)
async/await
// AsyncStream that yields cache first, then network
AsyncThrowingStream { continuation in
  Task {
    if let cached = await cache.load() { continuation.yield(cached) }
    do {
      continuation.yield(try await network.load())
      continuation.finish()
    } catch { continuation.finish(throwing: error) }
  }
}

Serial vs parallel execution of N jobs

Combine
// Parallel
Publishers.MergeMany(jobs).collect()

// Serial (one at a time)
jobs.publisher
  .flatMap(maxPublishers: .max(1)) { $0 }
  .collect()
async/await
// Parallel
await withTaskGroup(of: Result.self) { g in
  for j in jobs { g.addTask { await j() } }
  return await g.reduce(into: []) { $0.append($1) }
}

// Serial
var out: [Result] = []
for j in jobs { out.append(await j()) }

Observing UIKit/system events

Both worlds, same event.

// Combine
NotificationCenter.default
  .publisher(for: UIResponder.keyboardWillShowNotification)
  .compactMap { $0.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect }
  .map(\.height)
  .assign(to: &$keyboardHeight)

// async/await
for await note in NotificationCenter.default.notifications(
      named: UIResponder.keyboardWillShowNotification) {
  guard let frame = note.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect
  else { continue }
  keyboardHeight = frame.height
}

Which do I reach for?

  • async/await — request/response, sequential logic, anything new, anything you must unit test simply.
  • Combine — @Published glue in UIKit/pre-Observation SwiftUI, complex multi-source operator chains, debounce/throttle for free.
  • GCD — only when interoperating with old code, or for DispatchSource-level primitives.
  • You can mix: expose AsyncStream from a Combine pipeline via .values, or wrap an async call in Deferred { Future { } }.

SwiftUI + Combine — Wiring Them Together

Where Combine plugs into a SwiftUI view, and what replaced it in the Observation era.

  • ios
  • ipados
  • macos
  • tvos
  • watchos
  • visionos
  • SwiftUI
  • Combine

Sources: SwiftUI — Apple Developer Documentation · Combine — Apple Developer Documentation · Observation — Apple Developer Documentation

The connection points

There are only a handful. Know all of them.

  • ObservableObject + @Published — the object *is* a publisher; SwiftUI subscribes to objectWillChange.
  • .onReceive(publisher) { value in } — subscribe to any publisher for the view's lifetime.
  • assign(to: &$published) — pipe a publisher straight into a @Published property (no cancellable needed).
  • objectWillChange.send() — manual notification when you don't use @Published.
  • .task { for await x in publisher.values { } } — bridge Combine into structured concurrency.
  • With @Observable (iOS 17+) there is no objectWillChange and no @Published; Combine becomes an *input* only.

The classic ViewModel

Combine-powered `ObservableObject`, driving a SwiftUI view.

@MainActor
final class SearchViewModel: ObservableObject {
  @Published var query = ""                       // input from the view
  @Published private(set) var results: [Repo] = []
  @Published private(set) var isLoading = false
  @Published private(set) var errorMessage: String?

  private var cancellables = Set<AnyCancellable>()

  init(api: API = .live) {
    $query
      .debounce(for: .milliseconds(300), scheduler: RunLoop.main)
      .removeDuplicates()
      .filter { $0.count >= 2 }
      .handleEvents(receiveOutput: { [weak self] _ in self?.isLoading = true })
      .map { q in
        api.search(q)
          .catch { _ in Just<[Repo]>([]) }        // keep the OUTER stream alive
      }
      .switchToLatest()                            // cancels the previous request
      .receive(on: RunLoop.main)
      .handleEvents(receiveOutput: { [weak self] _ in self?.isLoading = false })
      .assign(to: &$results)                       // no cancellable to store
  }
}

struct SearchView: View {
  @StateObject private var vm = SearchViewModel()

  var body: some View {
    List(vm.results) { RepoRow($0) }
      .searchable(text: $vm.query)                 // two-way binding into @Published
      .overlay { if vm.isLoading { ProgressView() } }
  }
}

Derived / validated state

`CombineLatest` is the idiomatic form-validation answer.

final class SignUpViewModel: ObservableObject {
  @Published var email = ""
  @Published var password = ""
  @Published var confirmation = ""
  @Published private(set) var isValid = false
  @Published private(set) var passwordHint: String?

  private var bag = Set<AnyCancellable>()

  init() {
    let validEmail = $email.map { $0.contains("@") && $0.count > 5 }
    let validPassword = Publishers.CombineLatest($password, $confirmation)
      .map { pwd, confirm in pwd.count >= 8 && pwd == confirm }

    Publishers.CombineLatest(validEmail, validPassword)
      .map { $0 && $1 }
      .removeDuplicates()
      .assign(to: &$isValid)

    $password
      .map { $0.count >= 8 ? nil : "At least 8 characters" }
      .assign(to: &$passwordHint)
  }
}

// View
Button("Create account") { vm.submit() }
  .disabled(!vm.isValid)

onReceive vs a ViewModel pipeline

Use `onReceive` for system publishers; use the VM for business logic.

onReceive — view-local
struct ClockView: View {
  @State private var now = Date()
  private let timer = Timer
    .publish(every: 1, on: .main, in: .common)
    .autoconnect()

  var body: some View {
    Text(now, format: .dateTime.hour().minute().second())
      .onReceive(timer) { now = $0 }
  }
}

// Also great for system notifications:
.onReceive(NotificationCenter.default.publisher(
    for: UIApplication.willEnterForegroundNotification)) { _ in
  Task { await vm.refresh() }
}
Don't put logic here
struct FeedView: View {
  @State private var items: [Item] = []

  var body: some View {
    List(items) { ... }
      .onReceive(api.feed()
        .retry(3)
        .replaceError(with: [])
        .receive(on: RunLoop.main)) { items = $0 }
  }
}
// The publisher is REBUILT on every body evaluation ->
// resubscribes, refires, and is impossible to unit test.

Manual objectWillChange

When your state isn't in `@Published` properties.

final class Document: ObservableObject {
  private(set) var text: String = "" {
    willSet { objectWillChange.send() }    // must fire BEFORE the change
  }

  func append(_ s: String) { text += s }
}

// Republishing a child object's changes to the parent
final class Parent: ObservableObject {
  @Published var child = Child()
  private var bag = Set<AnyCancellable>()

  init() {
    child.objectWillChange
      .sink { [weak self] in self?.objectWillChange.send() }
      .store(in: &bag)                     // nested ObservableObjects are NOT automatic
  }
}

Combine VM vs @Observable VM

Same screen, before and after iOS 17.

Combine / ObservableObject
@MainActor
final class CartVM: ObservableObject {
  @Published private(set) var items: [Item] = []
  @Published private(set) var total: Decimal = 0
  private var bag = Set<AnyCancellable>()

  init() {
    $items
      .map { $0.reduce(0) { $0 + $1.price } }
      .assign(to: &$total)
  }
}

struct CartView: View {
  @StateObject private var vm = CartVM()
  var body: some View { Text("\(vm.total)") }
}
// Whole view re-renders when ANY @Published changes.
Observation (iOS 17+)
@MainActor @Observable
final class CartVM {
  private(set) var items: [Item] = []
  var total: Decimal {                       // just a computed property
    items.reduce(0) { $0 + $1.price }
  }
  func load() async { items = await api.cart() }
}

struct CartView: View {
  @State private var vm = CartVM()
  var body: some View {
    Text("\(vm.total)").task { await vm.load() }
  }
}
// Re-renders only the views that actually READ `total`.

Bridging both ways inside a view

Combine in, async out — and vice versa.

// Combine publisher -> async iteration, scoped to the view's lifetime
.task {
  for await location in locationManager.publisher.values {
    region = MKCoordinateRegion(center: location.coordinate, span: span)
  }
}

// @Observable model that still consumes a legacy Combine API
@Observable
final class LocationModel {
  private(set) var last: CLLocation?
  @ObservationIgnored private var bag = Set<AnyCancellable>()

  init(service: LegacyLocationService) {
    service.publisher
      .receive(on: RunLoop.main)
      .sink { [weak self] in self?.last = $0 }   // plain assignment triggers Observation
      .store(in: &bag)
  }
}

SwiftUI + Combine pitfalls

  • Building a publisher inside body / onReceive(...) — it is recreated every render. Build it in init or store it in a let.
  • @ObservedObject var vm = VM() — recreated on each render. Use @StateObject (or @State with @Observable).
  • Forgetting .receive(on: RunLoop.main) before touching UI state (or mark the VM @MainActor).
  • An error reaching the outer publisher kills the stream permanently — catch inside the flatMap/map+switchToLatest.
  • assign(to: \.x, on: self) retains self. Prefer assign(to: &$published) or sink { [weak self] }.
  • Nested ObservableObjects don't propagate; forward objectWillChange manually or flatten the model.
  • DispatchQueue.main vs RunLoop.main: RunLoop.main pauses during scroll tracking (often what you want for search); DispatchQueue.main always delivers.
  • New code: prefer @Observable + async/await; keep Combine for debounce/throttle/multi-source merges and legacy APIs.

SwiftUI ← publisher connection cheat table

GoalCode
React to a publisher in a view.onReceive(pub) { value in ... }
Drive a @Published from a publisherpub.assign(to: &$property)
Drive any property, manual lifetimepub.sink { [weak self] ... }.store(in: &bag)
Two-way binding to a VM property$vm.query (needs @Published var, non-private setter)
Timer in a viewTimer.publish(...).autoconnect() + .onReceive
System notificationNotificationCenter.default.publisher(for:) + .onReceive
Scene phase / lifecycle@Environment(\.scenePhase) + .onChange(of:)
Publisher → asyncfor await v in pub.values inside .task { }
Force a refresh without @PublishedobjectWillChange.send()

Timing Operators — Combine vs Swift Concurrency

debounce, throttle, delay, collect, timeout: what each one drops, and how to rebuild it safely with tasks and clocks.

  • ios
  • ipados
  • macos
  • tvos
  • watchos
  • visionos
  • Combine

Sources: Combine — Apple Developer Documentation · Concurrency — The Swift Programming Language

Six operators, one input

Read down the column: at 200 ms every operator has made a different decision.

SAME INPUT, SIX OPERATORS - WINDOW = 200 ms200400600800input8 values, two burstsABCDEFGHdebounce(200ms)waits for 200 ms of SILENCE, keeps the LAST of a burstCHthrottle(latest: false)at most one per window - keeps the FIRSTADFthrottle(latest: true)at most one per window - keeps the LASTCEHdelay(150ms)shifts EVERYTHING, drops nothingABCDEFGHcollect(byTime: 200ms)buffers each window into an array[A,B,C][D,E][F,G,H]timeout(300ms)no value for 300 ms -> the stream FAILS and is overABCfailure at 480 ms - the 340 ms gap blew the budgetdebounce = "tell me when they stop typing". throttle = "at most N per second". delay = "same thing, later". collect = "batch it". timeout = "give up".Combine delivers the leading value of a throttle immediately and then one per window; empty collect windows can still emit []. Verify against your Combine version.

The conceptual split

This is the part people get wrong — the operators are the easy bit.

  • Push vs pull. A Combine publisher *pushes* whenever it likes; an AsyncSequence is *pulled* by its consumer. So Combine needs buffer/debounce to protect a slow consumer, while for await naturally applies backpressure — the producer waits for you.
  • Who owns the timer. In Combine the operator owns it, invisibly, and is torn down with the AnyCancellable. In async code *you* own a Task — if you don't store and cancel it, it leaks or fires after the screen is gone.
  • Scheduler vs clock. Combine takes a Scheduler (where *and* when work runs). Concurrency splits those: the clock decides *when* (ContinuousClock, SuspendingClock) and the actor decides *where*.
  • Cancellation. AnyCancellable.cancel() tears the chain down synchronously. Task.cancel() is *cooperative* — a sleep throws, but your own loop must check Task.isCancelled.
  • Failure ends everything. A Combine error terminates the stream permanently; there is no "resume". A thrown error ends one for try await loop — you can just start another.
  • Hot vs cold. Timer.publish is a ConnectablePublisher (nothing until .autoconnect() or .connect()). An AsyncStream starts producing when its closure runs, whether or not anyone iterates — so bound its buffer.
  • Practical rule: use Combine's operators when you're already in a Combine pipeline. In new code, model time with an injected Clock and own your Tasks.

Operator → meaning → async equivalent

CombineSemanticsDrops values?Concurrency equivalent
debounce(for:scheduler:)emit only after N of silence; keeps the last of a burstyes, most of themAsyncSequence.debounce(for:)* or a cancel-and-restart Task
throttle(for:scheduler:latest:)at most one per window; latest picks first vs lastyes.throttle(for:latest:)* or a last-emit timestamp on an actor
delay(for:tolerance:scheduler:)shift every value later in timenotry await Task.sleep(for:) before yielding
timeout(_:scheduler:customError:)fail if no value arrives within NterminatesTaskGroup race: work vs Task.sleep, then cancelAll()
collect(.byTime(s, n))buffer a window into an arrayno (batches).chunks(ofCount:or:)* or a manual buffer + deadline
collect(.byTimeOrCount(s, n, c))flush on time or on sizenosame, with both conditions
measureInterval(using:)emit the gap between upstream valuesnosubtract two clock.now instants
Timer.publish(every:on:in:)repeating ticks on a RunLoopn/aAsyncTimerSequence* or a clock.sleep(until:) loop
Publishers.Debounce + removeDuplicatesthe search-as-you-type pairyes.debounce().removeDuplicates()*
.receive(on:)deliver downstream on that schedulerno@MainActor / await on an actor
.subscribe(on:)start the subscription therenoTask.detached or a nonisolated func

debounce — "tell me when they stop typing"

Cancel-and-restart. The last value of a burst wins.

Combine
$query
  .debounce(for: .milliseconds(300),
            scheduler: RunLoop.main)
  .removeDuplicates()
  .sink { [weak self] text in
    self?.search(text)
  }
  .store(in: &cancellables)

// The operator owns the timer. Cancelling the
// AnyCancellable cancels the pending emission.
Swift Concurrency
@MainActor @Observable
final class SearchModel {
  var query = "" { didSet { schedule() } }
  private(set) var results: [Item] = []
  private var pending: Task<Void, Never>?

  private func schedule() {
    pending?.cancel()                  // restart the clock
    pending = Task { [query] in
      try? await Task.sleep(for: .milliseconds(300))
      guard !Task.isCancelled else { return }
      results = await search(query)
    }
  }

  func stop() { pending?.cancel() }    // call from onDisappear
}

// Safe because: one owner, one stored handle, and
// the whole thing is @MainActor — no shared mutable
// state crossing isolation domains.

// With swift-async-algorithms, no bookkeeping at all:
for await text in queries.debounce(for: .milliseconds(300)) {
  results = await search(text)
}

throttle — "at most N per second"

Rate limiting. Note the two flavours.

Combine
// latest: false -> the FIRST value of each window
scrollOffset
  .throttle(for: .milliseconds(200),
            scheduler: DispatchQueue.main,
            latest: false)
  .sink { recordScroll($0) }
  .store(in: &cancellables)

// latest: true -> the LAST value of each window
// (what you want for "show me the current value")
sensorReadings
  .throttle(for: .seconds(1),
            scheduler: DispatchQueue.main,
            latest: true)
  .assign(to: &$displayValue)
Swift Concurrency
/// Leading-edge throttle. The actor makes the
/// timestamp check atomic - two callers can never
/// both pass the gate.
actor Throttle {
  private var last: ContinuousClock.Instant?
  private let interval: Duration

  init(interval: Duration) { self.interval = interval }

  func allow(now: ContinuousClock.Instant = .now) -> Bool {
    if let last, now - last < interval { return false }
    last = now
    return true
  }
}

let gate = Throttle(interval: .milliseconds(200))
if await gate.allow() { recordScroll(offset) }

// Trailing edge ("latest") = a debounce with a
// maximum wait, or simply:
for await value in readings.throttle(for: .seconds(1),
                                     latest: true) { ... }

delay & timeout

One shifts, the other gives up.

Combine
// delay: everything, later. Nothing is dropped.
Just(())
  .delay(for: .seconds(2), scheduler: RunLoop.main)
  .sink { self.showToast = false }
  .store(in: &cancellables)

// timeout: no value within N -> the stream FAILS
request
  .timeout(.seconds(5),
           scheduler: DispatchQueue.global(),
           customError: { APIError.timedOut })
  .sink(receiveCompletion: ..., receiveValue: ...)
  .store(in: &cancellables)

// tolerance lets the system coalesce timers:
.delay(for: .seconds(60),
       tolerance: .seconds(5),
       scheduler: RunLoop.main)
Swift Concurrency
// delay
try await Task.sleep(for: .seconds(2))
showToast = false

// tolerance saves battery on long sleeps
try await Task.sleep(for: .seconds(60),
                     tolerance: .seconds(5))

// timeout: race the work against a sleep, then
// cancel the loser. Structured, so nothing leaks.
func withTimeout<T: Sendable>(
  _ limit: Duration,
  _ 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: limit)
      throw TimeoutError()
    }
    let winner = try await group.next()!
    group.cancelAll()            // <- do not forget this
    return winner
  }
}

let user = try await withTimeout(.seconds(5)) {
  try await api.user()
}

collect(byTime:) — batching

Combine
// Flush every 2 seconds
events
  .collect(.byTime(DispatchQueue.global(), .seconds(2)))
  .sink { batch in upload(batch) }
  .store(in: &cancellables)

// Flush every 2 seconds OR at 50 items,
// whichever comes first
events
  .collect(.byTimeOrCount(DispatchQueue.global(),
                          .seconds(2), 50))
  .sink { batch in upload(batch) }
  .store(in: &cancellables)

// collect() with no strategy waits for COMPLETION -
// on an endless publisher it buffers for ever.
Swift Concurrency
// swift-async-algorithms
for await batch in events.chunks(
  ofCount: 50,
  or: .repeating(every: .seconds(2))
) {
  await upload(Array(batch))
}

// By hand, deadline-based so a slow trickle still
// flushes on time:
var buffer: [Event] = []
var deadline = ContinuousClock.now
  .advanced(by: .seconds(2))

for await event in events {
  buffer.append(event)
  if buffer.count >= 50 || .now >= deadline {
    await upload(buffer)
    buffer.removeAll(keepingCapacity: true)
    deadline = .now.advanced(by: .seconds(2))
  }
}
if !buffer.isEmpty { await upload(buffer) }

Repeating timers, without drift

Sleeping for the interval accumulates every millisecond of overhead.

// WRONG: each iteration is interval + however long tick() took.
// After an hour you've drifted seconds.
while !Task.isCancelled {
  try await Task.sleep(for: .seconds(1))
  await tick()
}

// RIGHT: advance an absolute deadline, so the work's cost is absorbed.
func run() async throws {
  let clock = ContinuousClock()
  var next = clock.now
  while !Task.isCancelled {
    next = next.advanced(by: .seconds(1))
    try await clock.sleep(until: next, tolerance: .milliseconds(50))
    await tick()                       // late ticks don't shift the schedule
  }
}

// swift-async-algorithms does it for you
for await instant in AsyncTimerSequence(interval: .seconds(1), clock: .continuous) {
  await tick()
}

// Combine equivalent - the operator handles the schedule, you handle the lifetime
Timer.publish(every: 1, on: .main, in: .common)
  .autoconnect()                       // ConnectablePublisher: nothing happens without this
  .sink { _ in tick() }
  .store(in: &cancellables)

Clocks and schedulers: pick deliberately

The wrong one is a bug that only shows up in the field.

Combine schedulers
DispatchQueue.main       // always delivers; use for UI
RunLoop.main             // PAUSES during scroll tracking
                         // and in modal run-loop modes
DispatchQueue.global(qos: .utility)
OperationQueue()         // has maxConcurrentOperationCount
ImmediateScheduler.shared // synchronous - great in tests

// Classic bug: RunLoop.main from a background thread.
// There is no run loop running there, so nothing fires.
somePublisher
  .subscribe(on: DispatchQueue.global())
  .debounce(for: .seconds(1), scheduler: RunLoop.main) // ok
  .receive(on: DispatchQueue.main)

// debounce on RunLoop.main will not fire while the
// user is dragging a scroll view. For search that's
// often desirable; for a progress readout it isn't.
Concurrency clocks
ContinuousClock()   // monotonic, KEEPS COUNTING while
                    // the device is asleep. The default
                    // for Task.sleep(for:).
SuspendingClock()   // pauses while the machine sleeps -
                    // use for "5 s of CPU-time work"
Date / Calendar     // wall clock: can jump backwards
                    // (NTP, user edits). Never for durations.

// Deadlines are values you can pass around
let deadline = ContinuousClock.now.advanced(by: .seconds(30))
try await clock.sleep(until: deadline)

// Measure
let elapsed = try await ContinuousClock().measure {
  try await work()
}

// Where the work runs is a separate decision:
await MainActor.run { label.text = "done" }

Make time injectable — and your tests instant

// Depend on the `Clock` protocol, never on Task.sleep directly.
struct Poller<C: Clock> {
  let clock: C
  let interval: C.Duration

  func run(_ body: @Sendable () async -> Void) async throws {
    var next = clock.now
    while !Task.isCancelled {
      next = next.advanced(by: interval)
      try await clock.sleep(until: next, tolerance: nil)
      await body()
    }
  }
}

// Production
let poller = Poller(clock: ContinuousClock(), interval: .seconds(30))

// Tests: pointfreeco/swift-clocks — time only moves when you say so
@Test func pollsEverySecond() async throws {
  let clock = TestClock()
  var ticks = 0
  let task = Task { try await Poller(clock: clock, interval: .seconds(1))
                        .run { ticks += 1 } }
  await clock.advance(by: .seconds(3))
  #expect(ticks == 3)
  task.cancel()
}

// TCA gives you this as a dependency out of the box:
@Dependency(\.continuousClock) var clock

// Combine equivalent: inject the scheduler type
final class Model<S: Scheduler> {
  init(scheduler: S) { ... }        // ImmediateScheduler or a TestScheduler in tests
}

Owning the Task safely

Most async-timing bugs are lifetime bugs, not timing bugs.

// The state that a timer touches must live in ONE isolation domain.
@MainActor
final class TickerViewModel {
  private(set) var seconds = 0
  private var task: Task<Void, Never>?      // only ever touched on the main actor

  func start() {
    task?.cancel()                          // idempotent: no double timers
    task = Task { [weak self] in            // weak: don't keep the screen alive
      let clock = ContinuousClock()
      var next = clock.now
      while !Task.isCancelled {
        next = next.advanced(by: .seconds(1))
        try? await clock.sleep(until: next, tolerance: .milliseconds(50))
        guard let self, !Task.isCancelled else { return }
        self.seconds += 1                   // already on the main actor
      }
    }
  }

  func stop() { task?.cancel(); task = nil }
  deinit { task?.cancel() }
}

// In SwiftUI, prefer the framework's ownership - it cancels on disappear for you:
.task { await model.runTicker() }           // tied to the view's lifetime
.task(id: query) { await debouncedSearch() } // restarts when `query` changes

// Never Task.detached for a timer: it inherits no isolation and no cancellation,
// so it outlives the screen and races whatever it touches.

Safety checklist for async timing

  • Store every long-lived Task and cancel it in stop() / onDisappear / deinit.
  • Cancel before restarting (task?.cancel() then reassign) — otherwise you accumulate timers.
  • Re-check Task.isCancelled after every await; a sleep that throws is not the same as a loop that stops.
  • Keep the mutated state and the Task handle in the same isolation domain (@MainActor or one actor).
  • Use absolute deadlines (clock.sleep(until:)) for anything periodic, so the work's cost doesn't accumulate as drift.
  • Pass a tolerance on long sleeps so the OS can coalesce wakeups — it's real battery.
  • Inject the Clock. A test that sleeps for real is a test that will flake in CI.
  • Don't use Date() differences to measure elapsed time — the wall clock can move backwards.
  • Don't Task.detached a timer, and don't spawn one per row/cell/view update.
  • Don't hold a strong self in a repeating task — the screen can never deallocate.
  • Don't Thread.sleep or busy-wait in async code; it blocks a cooperative thread that other tasks need.
  • In Combine, the mirror-image rules are: always .store(in:), always .autoconnect() a Timer, and know whether your scheduler is RunLoop.main (pauses) or DispatchQueue.main (doesn't).

Clean Code in Swift

Naming, functions, comments, errors, tests — the reviewable habits, with Swift-flavoured examples.

The short version

If you can only remember five things.

  • Names reveal intent. A good name removes the need for a comment.
  • Functions do one thing, at one level of abstraction, and are small.
  • Fewer arguments is better. 0 > 1 > 2 > 3. Flags are a smell.
  • No surprises. A function's name should predict its side effects — ideally there are none.
  • The boy scout rule. Leave the code cleaner than you found it; refactor in small, safe steps.
  • Clean code is optimised for *reading*. Code is read far more often than it is written.

Naming

Intention-revealing, pronounceable, searchable, no encodings.

Don't
var d: Int                     // elapsed time in days
var list1: [User]
var flag = false
let strName: String            // Hungarian notation
func proc(_ a: [User]) -> [User]

func getUserData() -> Data     // "data"? "info"? "manager"?
class UserInfoDataManagerHelper { }

if user.t == 2 { ... }         // magic number
Do
var elapsedDays: Int
var activeUsers: [User]
var isEligibleForRefund = false
let customerName: String
func removingBannedUsers(from users: [User]) -> [User]

func fetchUserProfile() async throws -> UserProfile
struct UserProfileRepository { }

enum AccountTier: Int { case free, plus, pro }
if user.tier == .pro { ... }

// Swift API Design Guidelines:
//   mutating -> imperative verb:  array.sort()
//   nonmutating -> -ed/-ing noun: array.sorted()

Functions: small, one level of abstraction

Extract until you cannot extract anymore.

Before
func checkout(_ cart: Cart) throws -> Receipt {
  var total: Decimal = 0
  for item in cart.items {
    guard item.stock > 0 else { throw CheckoutError.outOfStock(item.id) }
    var price = item.price
    if item.isOnSale { price *= 0.8 }
    if cart.user.tier == .pro { price *= 0.9 }
    total += price * Decimal(item.quantity)
  }
  if total > 100 { total -= 5 }
  let tax = total * 0.21
  let charge = total + tax
  let token = try payments.charge(charge)
  analytics.log("checkout", ["total": charge])
  return Receipt(total: charge, tax: tax, token: token)
}
After
func checkout(_ cart: Cart) throws -> Receipt {
  try validateStock(cart)
  let subtotal = subtotal(for: cart)
  let discounted = applyOrderDiscounts(to: subtotal)
  let tax = taxAmount(on: discounted)
  let token = try payments.charge(discounted + tax)
  analytics.log(.checkout(total: discounted + tax))
  return Receipt(total: discounted + tax, tax: tax, token: token)
}

private func price(of item: CartItem, for tier: AccountTier) -> Decimal {
  item.price * item.saleMultiplier * tier.discountMultiplier
}
// Each helper sits at ONE level of abstraction, and each is unit-testable.

Arguments & flag parameters

A boolean argument means the function does two things.

Don't
func render(_ user: User, _ compact: Bool, _ animated: Bool) { ... }
render(user, true, false)          // unreadable at the call site

func save(_ item: Item, validate: Bool = true) {
  if validate { ... }
  store.write(item)
}
Do
enum RowStyle { case compact, expanded }
func render(_ user: User, style: RowStyle, animation: Animation?) { ... }
render(user, style: .compact, animation: nil)

func save(_ item: Item) throws { try validate(item); store.write(item) }
func saveWithoutValidation(_ item: Item) { store.write(item) }

// >3 arguments? Introduce a parameter object.
struct SearchQuery { let text: String; let page: Int; let filters: [Filter] }
func search(_ query: SearchQuery) async throws -> [Result]

Comments

Comments explain WHY. Code explains WHAT.

Noise
// increment the counter by one
counter += 1

// MARK: - Properties
var name: String        // the name

/*
func oldImplementation() {   // dead code, delete it
   ...
}
*/

// TODO: fix this (2019)
Useful
// The server returns 200 with an empty body when the token has
// expired (see BUG-4821); treat it as unauthenticated.
guard !data.isEmpty else { throw APIError.unauthenticated }

/// Returns the users who can be refunded.
/// - Note: Only orders under 30 days old are eligible.
func refundableUsers() -> [User]

// Prefer making it self-documenting instead of commenting:
let isRefundEligible = order.age < .days(30) && order.state == .delivered

Error handling over sentinel values

Don't
func loadUser(id: String) -> User? {
  guard let data = try? Data(contentsOf: url) else { return nil }
  return try? JSONDecoder().decode(User.self, from: data)
}
// Caller has no idea WHY it failed.

func parse(_ s: String) -> Int { Int(s) ?? -1 }   // magic sentinel

if result == nil { /* network? decoding? not found? */ }
Do
enum UserError: Error {
  case notFound(id: String)
  case decoding(underlying: Error)
}

func loadUser(id: String) async throws -> User {
  let data = try await api.data(for: id)
  do { return try JSONDecoder().decode(User.self, from: data) }
  catch { throw UserError.decoding(underlying: error) }
}

// Return empty collections, not nil.
func tags() -> [Tag] { stored ?? [] }

Code smells checklist

Say these names when reviewing code out loud.

  • Long function / god object — many reasons to change, hard to name.
  • Deep nesting — invert conditions and use guard for early exit.
  • Duplicated logic — the third copy is when you extract.
  • Primitive obsession — String for an id/email; use a typed wrapper.
  • Feature envy — a method that mostly touches another type's data; move it.
  • Shotgun surgery — one change forces edits in ten files; the abstraction is wrong.
  • Temporal coupling — configure() must be called before start(); enforce it in the initialiser.
  • Boolean blindness — several Bools that encode one state; use an enum.

Guard clauses & the arrow anti-pattern

Nested
func handle(_ response: Response?) {
  if let response {
    if response.statusCode == 200 {
      if let body = response.body {
        if !body.isEmpty {
          process(body)
        }
      }
    }
  }
}
Flat
func handle(_ response: Response?) {
  guard let response else { return }
  guard response.statusCode == 200 else { return log(.badStatus) }
  guard let body = response.body, !body.isEmpty else { return }
  process(body)
}
// Happy path stays at the left margin.

Typed wrappers beat primitives

Cheap in Swift, and the compiler catches whole bug classes.

struct UserID: Hashable, Codable, RawRepresentable { let rawValue: String }
struct Email: Hashable {
  let value: String
  init?(_ raw: String) {
    guard raw.contains("@") else { return nil }   // parse, don't validate later
    value = raw
  }
}

func message(from: UserID, to: UserID) { }
message(from: sender, to: recipient)     // can't accidentally swap with an order id

// Units
struct Meters: Numeric { ... }
let distance: Meters = 400

Clean tests (F.I.R.S.T.)

  • Fast — milliseconds. No network, no sleeps: inject a clock.
  • Independent — order must not matter; no shared mutable state between tests.
  • Repeatable — inject Date, UUID, randomness. No flakiness.
  • Self-validating — a pass/fail assertion, never manual inspection of a log.
  • Timely — written with (or before) the code.
  • One logical assertion per test; name it test_whenX_thenY. Arrange / Act / Assert structure.

Arrange–Act–Assert

@Test
func refund_isRejected_whenOrderIsOlderThan30Days() throws {
  // Arrange
  let order = Order.stub(placedAt: .distantPast, state: .delivered)
  let sut = RefundService(now: { .fixed })      // injected clock -> repeatable

  // Act
  let result = sut.eligibility(for: order)

  // Assert
  #expect(result == .rejected(reason: .expired))
}

// "sut" = system under test. Keep stubs in factory helpers so the
// test body shows only what is RELEVANT to this case.

Uncle Bob rule → Swift idiom

Clean Code ruleSwift way to satisfy it
Avoid nullOptional + guard let; return empty collections
Don't return error codesthrows + typed Error enums, or Result
Prefer polymorphism to switchprotocols, or an enum when the set is closed
Encapsulate conditionalscomputed properties: var isEligible: Bool
Avoid output argumentsreturn values; inout only for local, obvious mutation
Keep classes small (SRP)struct + protocol composition; extensions per concern
Dependency injectioninitialiser injection, closures, @Dependency in TCA
Separate construction from usefactories, Builder, DI container / composition root

SOLID in Swift

Five principles, one concrete Swift example each. Say the name, then the smell it prevents.

The one-line versions

  • Single Responsibility — a type has one reason to change.
  • Open/Closed — open for extension, closed for modification.
  • Liskov Substitution — a subtype must be usable anywhere its supertype is, without surprises.
  • Interface Segregation — many small protocols beat one fat protocol.
  • Dependency Inversion — depend on abstractions, and let the caller supply them.
  • In Swift, protocols + value types + closures do most of the work; you rarely need class hierarchies.

S — Single Responsibility

Smell: a class that talks to the network, parses, caches and formats.

Violates
final class UserManager {
  func fetchUser(id: String) async throws -> User {
    let (data, _) = try await URLSession.shared.data(from: url(id))
    let user = try JSONDecoder().decode(User.self, from: data)
    UserDefaults.standard.set(data, forKey: id)     // persistence
    Analytics.log("user_fetched")                   // analytics
    return user
  }
  func displayName(_ u: User) -> String { ... }     // formatting
}
Respects
struct UserAPI {           // transport only
  func user(id: String) async throws -> User
}
struct UserCache {         // persistence only
  func save(_ user: User); func load(id: String) -> User?
}
struct UserFormatter {     // presentation only
  func displayName(_ u: User) -> String
}

// A thin coordinator composes them:
struct UserRepository {
  let api: UserAPI, cache: UserCache
  func user(id: String) async throws -> User {
    if let hit = cache.load(id: id) { return hit }
    let u = try await api.user(id: id); cache.save(u); return u
  }
}

O — Open/Closed

Smell: adding a case means editing an existing switch in a shipped type.

Violates
struct DiscountCalculator {
  func discount(for kind: String, price: Decimal) -> Decimal {
    switch kind {
    case "student": return price * 0.1
    case "senior":  return price * 0.15
    // every new rule edits THIS function
    default: return 0
    }
  }
}
Respects
protocol DiscountRule {
  func discount(on price: Decimal) -> Decimal
}
struct StudentDiscount: DiscountRule {
  func discount(on p: Decimal) -> Decimal { p * 0.1 }
}
struct SeniorDiscount: DiscountRule {
  func discount(on p: Decimal) -> Decimal { p * 0.15 }
}

struct DiscountCalculator {
  let rules: [DiscountRule]                 // extend by ADDING a type
  func total(on p: Decimal) -> Decimal {
    rules.reduce(0) { $0 + $1.discount(on: p) }
  }
}
// Note: a closed enum + exhaustive switch is also fine when the set
// of cases is genuinely fixed - that's the trade-off to mention.

L — Liskov Substitution

Smell: a subclass that throws, no-ops, or tightens preconditions.

Violates
class Rectangle {
  var width: Double = 0
  var height: Double = 0
  var area: Double { width * height }
}

class Square: Rectangle {
  override var width: Double {
    didSet { super.height = width }         // surprise side effect
  }
}

func resize(_ r: Rectangle) {
  r.width = 4; r.height = 5
  assert(r.area == 20)                      // fails for Square
}
Respects
protocol Shape { var area: Double { get } }

struct Rectangle: Shape {
  let width, height: Double
  var area: Double { width * height }
}
struct Square: Shape {
  let side: Double
  var area: Double { side * side }
}

func totalArea(_ shapes: [any Shape]) -> Double {
  shapes.reduce(0) { $0 + $1.area }         // every Shape behaves
}

I — Interface Segregation

Smell: conformances full of `fatalError()` or empty methods.

Violates
protocol Worker {
  func fetch() async throws -> Data
  func upload(_ d: Data) async throws
  func deleteAll() async throws
  func exportCSV() -> URL
}

struct ReadOnlyClient: Worker {
  func fetch() async throws -> Data { ... }
  func upload(_ d: Data) async throws { fatalError("unsupported") }
  func deleteAll() async throws { }          // silently wrong
  func exportCSV() -> URL { fatalError() }
}
Respects
protocol Fetching  { func fetch() async throws -> Data }
protocol Uploading { func upload(_ d: Data) async throws }
protocol Purging   { func deleteAll() async throws }

struct ReadOnlyClient: Fetching {
  func fetch() async throws -> Data { ... }
}
struct FullClient: Fetching, Uploading, Purging { ... }

func sync(_ c: some Fetching & Uploading) async throws { ... }

D — Dependency Inversion

Smell: `URLSession.shared` / `Date()` / singletons hard-coded inside logic.

Violates
final class CheckoutService {
  func pay(_ order: Order) async throws {
    let token = try await StripeSDK.shared.charge(order.total)
    Database.shared.save(token)
    Analytics.shared.log("paid")
  }
}
// Untestable: every test hits the real world.
Respects
struct CheckoutService {
  var charge: (Decimal) async throws -> Token   // closures ARE protocols
  var save: (Token) throws -> Void
  var log: (String) -> Void

  func pay(_ order: Order) async throws {
    let token = try await charge(order.total)
    try save(token)
    log("paid")
  }
}

// Production
CheckoutService(charge: StripeSDK.shared.charge,
                save: Database.shared.save,
                log: Analytics.shared.log)
// Test
CheckoutService(charge: { _ in .stub }, save: { _ in }, log: { _ in })

Functional Programming in Swift

Values, purity, and functions as data — with the standard-library tools that express them.

Sources: Closures — The Swift Programming Language

Core ideas

  • Pure function — same input → same output, no observable side effects. Trivially testable and parallelisable.
  • Immutability — let by default; model changes as new values, not mutations.
  • Value semantics — struct/enum copy on assignment, so no shared-state races.
  • First-class functions — pass, return and store functions like any value.
  • Declarative — describe *what*, not the loop mechanics.
  • Algebraic data types — struct = AND (product), enum = OR (sum). Make illegal states unrepresentable.
  • Swift is multi-paradigm: use FP where it clarifies (transforms, pipelines), not dogmatically.

Imperative → declarative

Imperative
var names: [String] = []
for user in users {
  if user.isActive && user.age >= 18 {
    names.append(user.name.uppercased())
  }
}
names.sort()

var total = 0
for o in orders { total += o.amount }
Functional
let names = users
  .filter { $0.isActive && $0.age >= 18 }
  .map { $0.name.uppercased() }
  .sorted()

let total = orders.reduce(0) { $0 + $1.amount }
// or: orders.map(\.amount).reduce(0, +)

Higher-order functions you must know cold

FunctionSignature ideaExample
map[A] -> (A -> B) -> [B][1,2,3].map { $0 * 2 } → [2,4,6]
compactMapdrops nils["1","x"].compactMap(Int.init) → [1]
flatMapflattens one level[[1,2],[3]].flatMap { $0 } → [1,2,3]
filterkeeps matchesnums.filter { $0.isMultiple(of: 2) }
reducefold to one valuenums.reduce(0, +)
reduce(into:)fold without copyingwords.reduce(into: [:]) { $0[$1, default: 0] += 1 }
zippairwisezip(names, scores).map { "\($0): \($1)" }
sorted(by:)custom orderitems.sorted { $0.date > $1.date }
allSatisfy / containspredicatesnums.allSatisfy { $0 > 0 }
prefix/drop(while:)lazy slicingnums.prefix(while: { $0 < 10 })
chunked / splitpartitiontext.split(separator: " ")

KeyPaths as functions

Concise, and a favourite trick in interviews.

let names = users.map(\.name)                       // KeyPath as (User) -> String
let sorted = users.sorted(using: KeyPathComparator(\.createdAt))
let grouped = Dictionary(grouping: users, by: \.country)
let ids = Set(users.map(\.id))

// Generic helper built on a key path
func by<T, V: Comparable>(_ kp: KeyPath<T, V>) -> (T, T) -> Bool {
  { $0[keyPath: kp] < $1[keyPath: kp] }
}
users.sorted(by: by(\.age))

Function composition & currying

infix operator >>>: AdditionPrecedence
func >>> <A, B, C>(f: @escaping (A) -> B, g: @escaping (B) -> C) -> (A) -> C {
  { a in g(f(a)) }
}

let trim: (String) -> String = { $0.trimmingCharacters(in: .whitespaces) }
let lower: (String) -> String = { $0.lowercased() }
let normalize = trim >>> lower

// Currying: turn a 2-arg function into a chain of 1-arg functions
func curry<A, B, C>(_ f: @escaping (A, B) -> C) -> (A) -> (B) -> C {
  { a in { b in f(a, b) } }
}
let add = curry(+)
let inc = add(1)                       // (Int) -> Int
[1, 2, 3].map(inc)                     // [2, 3, 4]

Optional & Result as containers

`map`/`flatMap` are not just for arrays.

// Optional chaining is flatMap
let length: Int? = name.map(\.count)                 // Optional<Int>
let user: User? = id.flatMap(repository.user(id:))   // avoids Optional<Optional<User>>

// Coalescing + guard for early exit
guard let email = form.email, email.contains("@") else { throw ValidationError.email }

// Result
let parsed = Result { try JSONDecoder().decode(User.self, from: data) }
  .map(\.name)
  .mapError(APIError.decoding)

switch parsed {
case .success(let name): print(name)
case .failure(let error): report(error)
}

try parsed.get()                         // back to throwing world

Make illegal states unrepresentable

The highest-value FP idea in day-to-day Swift.

// Bad: 4 booleans = 16 states, 12 of which are nonsense
struct ViewState {
  var isLoading = false
  var items: [Item] = []
  var error: Error?
}

// Good: exactly 4 possible states
enum ViewState {
  case idle
  case loading
  case loaded([Item])
  case failed(Error)
}

// Enums with associated values + exhaustive switch = compiler-checked logic
switch state {
case .idle:            EmptyView()
case .loading:         ProgressView()
case .loaded(let xs):  List(xs) { ItemRow($0) }
case .failed(let e):   ErrorView(error: e)
}

Laziness, memoization & pure transforms

// Lazy: no intermediate arrays, work happens on demand
let firstBig = numbers.lazy
  .map(expensiveTransform)
  .first { $0 > 1_000 }

// Infinite sequence
let evens = sequence(first: 0) { $0 + 2 }.prefix(5)   // 0,2,4,6,8

// Memoize a pure function
func memoize<A: Hashable, B>(_ f: @escaping (A) -> B) -> (A) -> B {
  var cache: [A: B] = [:]
  return { a in
    if let hit = cache[a] { return hit }
    let value = f(a); cache[a] = value; return value
  }
}

// Pure update on a value type
func updating<T>(_ value: T, _ mutate: (inout T) -> Void) -> T {
  var copy = value; mutate(&copy); return copy
}
let renamed = updating(user) { $0.name = "Ada" }

Trade-offs to mention out loud

  • Pure functions are trivially unit-testable and thread-safe — a natural fit for Swift 6 Sendable.
  • Value semantics remove whole classes of aliasing bugs; SwiftUI and TCA are built on it.
  • Copying large structs can cost; Swift's CoW helps, but measure (isKnownUniquelyReferenced for custom CoW).
  • Deep operator chains can hurt readability and compile times — name intermediate steps.
  • inout and mutating are the pragmatic escape hatch: local mutation, value semantics preserved.

Swift Language Essentials

Generics, protocols, memory and error handling — the fill-in-the-blank parts of a live exercise.

Sources: The Swift Programming Language · Generics — The Swift Programming Language

Generics & constraints

func first<T: Equatable>(_ value: T, in items: [T]) -> Int? {
  items.firstIndex(of: value)
}

struct Stack<Element> {
  private var storage: [Element] = []
  mutating func push(_ e: Element) { storage.append(e) }
  mutating func pop() -> Element? { storage.popLast() }
  var isEmpty: Bool { storage.isEmpty }
}

extension Stack where Element: Comparable {       // conditional extension
  var max: Element? { storage.max() }
}
extension Stack: Equatable where Element: Equatable { }

// where clauses on associated types
func sum<S: Sequence>(_ s: S) -> Int where S.Element == Int {
  s.reduce(0, +)
}

some vs any

Opaque type vs existential — a very common question.

`some` — opaque, static
func makeShape() -> some Shape { Circle() }
// - ONE concrete type, known to the compiler
// - no boxing, full specialisation, fast
// - caller can't see which type it is

func area(of s: some Shape) -> Double { s.area }
// sugar for: func area<S: Shape>(of s: S) -> Double
`any` — existential, dynamic
let shapes: [any Shape] = [Circle(), Square()]
// - heterogeneous storage
// - boxed, dynamic dispatch (slower)
// - needed for heterogeneous collections

func area(of s: any Shape) -> Double { s.area }
// Swift 5.7+ can open existentials automatically

Protocols with associated types & primary associated types

protocol Repository {
  associatedtype Item: Identifiable
  func all() async throws -> [Item]
  func save(_ item: Item) async throws
}

// Swift 5.7 primary associated types make PATs usable as `some`/`any`
protocol Storage<Value> {
  associatedtype Value
  func load() -> Value?
}
func use(_ s: some Storage<User>) { ... }
let boxed: any Storage<User> = KeychainStorage()

// Protocol with default implementation
extension Repository {
  func first() async throws -> Item? { try await all().first }
}

Error handling

enum APIError: Error, Equatable {
  case badStatus(Int)
  case decoding(String)
}
extension APIError: LocalizedError {
  var errorDescription: String? {
    switch self {
    case .badStatus(let c): "Server returned \(c)"
    case .decoding(let m):  "Could not read the response: \(m)"
    }
  }
}

do { let u = try await api.user() }
catch let e as APIError { show(e) }
catch { report(error) }

try? await api.user()          // Optional result
try! JSONDecoder().decode(...) // crash on failure - tests/known-good only

// Typed throws (Swift 6)
func load() throws(APIError) -> User { ... }

defer { file.close() }         // runs on every exit path (LIFO)

Memory & ARC

class Node {
  var next: Node?
  weak var parent: Node?          // weak: optional, becomes nil
  unowned let owner: Owner        // unowned: non-optional, crashes if accessed after dealloc
}

// Closure capture lists
button.onTap = { [weak self] in
  guard let self else { return }
  self.reload()
}
Task { [weak self] in await self?.load() }

// Escaping vs non-escaping
func run(_ body: () -> Void) { body() }                 // non-escaping (default)
func store(_ body: @escaping () -> Void) { self.h = body }

// Retain-cycle checklist: delegate (weak), closures capturing self,
// Combine .assign(to:on: self), timers with strong targets, NotificationCenter observers.

Value vs reference semantics

struct Point { var x = 0 }          // value: copy on assign
final class Box { var x = 0 }       // reference: shared

var a = Point(); var b = a; b.x = 1   // a.x == 0
let p = Box(); let q = p; q.x = 1     // p.x == 1

// Copy-on-write for a custom buffer
struct Buffer {
  private var storage = Storage()
  mutating func mutate() {
    if !isKnownUniquelyReferenced(&storage) { storage = storage.copy() }
    storage.value += 1
  }
}

Pattern matching & control flow

The syntax you forget under pressure.

switch value {
case let .success(user) where user.isAdmin: grantAdmin(user)
case .success(let user):                    grant(user)
case .failure(APIError.badStatus(401)):     signOut()
case .failure:                              showRetry()
}

if case let .loaded(items) = state, !items.isEmpty { render(items) }
guard case .loading = state else { return }

for case let .some(x) in optionals { ... }
for (i, item) in items.enumerated() { ... }

let range = 1...10          // closed
0..<items.count             // half-open
if (0...100).contains(score) { ... }

// Result builders (how SwiftUI's DSL works)
@resultBuilder
enum StringBuilder {
  static func buildBlock(_ parts: String...) -> String { parts.joined(separator: " ") }
}

Testing quick reference

Swift Testing (Xcode 16+) alongside XCTest.

NeedSwift TestingXCTest
Test case@Test func x() { }func testX() { }
Assertion#expect(a == b)XCTAssertEqual(a, b)
Required assertiontry #require(optional)XCTUnwrap(optional)
Async@Test func x() async throwsfunc testX() async throws
Expected failure#expect(throws: APIError.self) { }XCTAssertThrowsError
Parametrised@Test(arguments: [1,2,3])loop by hand
Setupinit() / deinit on the suitesetUp() / tearDown()
Waitingjust awaitXCTestExpectation + wait(for:)

Collections — Types & Protocols

Which container, which protocol rung, and what every operation costs.

Sources: Collection — Swift Standard Library · Swift Collections (apple/swift-collections)

The protocol ladder

Generic code should demand the weakest rung that still works.

THE PROTOCOL LADDER - EACH RUNG ADDS A GUARANTEESequenceONE pass, may be infinitemakeIterator() / next()map filter reduce first(where:)no count, no indices, no reuserefinesCollectionmulti-pass, finite, indexedstartIndex endIndex subscriptcount isEmpty indices firstindex math may be O(n)refinesBidirectionalCollectioncan walk backwardsindex(before:) last reversed()String Set Dictionarysuffix / dropLast are cheaprefinesRandomAccessCollectionO(1) index offset AND countdistance(from:to:) is O(1)Array ArraySlice Rangemakes sort + binary search fastORTHOGONAL CAPABILITIES - MIX THEM INMutableCollectionsubscript has a SETTERlength cannot changeswapAt sort() reverse()shuffle() partition(by:)RangeReplaceableCollectionthe length CAN changeappend insert remove +=replaceSubrange(_:with:)removeAll(where:)SetAlgebraunion intersection subtractingsymmetricDifferenceisSubset(of:) isDisjoint(with:)Set OptionSet IndexSetArray = RandomAccess + Mutable + RangeReplaceable String = Bidirectional + RangeReplaceable (NOT RandomAccess) Set / Dictionary = Bidirectional, not orderedtarget the WEAKEST rung that compilesSequence for infinite or streaming dataa Slice reuses the parent's indices

Pick the container

The need on the left decides the type; the cost on the right decides whether you got it right.

PICK THE CONTAINERArrayordered, indexed, duplicates okindex O(1) · append O(1)* · contains O(n)Setmembership + dedupe, unorderedinsert / contains O(1) avgDictionarykey to value, unorderedsubscript O(1) avgContiguousArrayArray without ObjC bridgingsame, slightly faster for classesArraySlicea window onto an Array, no copyshares storage · indices are the PARENT'sRange / ClosedRange0..<n, a..​.b — a lazy collectionO(1) count · contains O(1)String / Substringgrapheme clusters, not bytescount O(n) · no Int subscriptKeyValuePairsordered literal pairs, duplicates oklookup O(n) — for small fixed configIndexSeta set of integers, run-length packedgreat for row/section index mathDequequeue: push/pop at BOTH endsO(1) both ends (Array.removeFirst is O(n))OrderedSetSet that keeps insertion orderO(1) lookup + a random-access viewOrderedDictionaryDictionary with a stable orderO(1) lookup, ordered iterationHeapmin/max priority queueinsert / popMin O(log n) · min O(1)TreeDictionary / TreeSetpersistent, cheap copies + diffingO(log n), structural sharingBitSetdense set of small integers1 bit per element, fast set algebraIdentifiedArrayordered AND keyed by Identifiable idTCA — O(1) by id, O(1) by index.lazy viewchain map/filter with no intermediateswork happens on demand, per elementAnyCollection / AnySequenceerase the concrete type at an API edgeadds a box + dynamic dispatchDeque · OrderedSet · OrderedDictionary · Heap · BitSet · TreeDictionary all come from apple/swift-collections — one SPM dependency, no bridging cost.

Costs, per type

The table interviewers and profilers both care about.

TypeLookupInsertRemoveOrdered?Notes
ArrayO(1) by index, O(n) by valueO(1)* append, O(n) insertO(1)* last, O(n) otheryes*amortised; contiguous storage
ContiguousArraysamesamesameyesskips ObjC bridging — for class elements
ArraySliceO(1)——yesshares the parent's buffer and keeps it alive
SetO(1) avgO(1) avgO(1) avgnoorder varies between launches
DictionaryO(1) avgO(1) avgO(1) avgnosame; Key: Hashable
StringO(n) — no Int indexO(1) appendO(n)yesgrapheme clusters; count is O(n)
DequeO(1) by indexO(1) both endsO(1) both endsyesswift-collections
OrderedSetO(1) by element, O(1) by indexO(1) appendO(n) middleyesSet semantics + a RandomAccess view
OrderedDictionaryO(1)O(1) appendO(n) middleyesstable iteration order
HeapO(1) min/max onlyO(log n)O(log n)partialpriority queue; no arbitrary lookup
BitSetO(1)O(1)O(1)yes (numeric)1 bit per element; fast set algebra
TreeDictionary / TreeSetO(log n)O(log n)O(log n)nopersistent: copies share structure

Array essentials

var xs = [1, 2, 3]
var zeros = Array(repeating: 0, count: 100)
var fromRange = Array(1...5)                  // [1,2,3,4,5]
var empty = [Int]()

xs.reserveCapacity(10_000)                    // one allocation instead of ~14 regrows
xs.append(4);  xs += [5, 6];  xs.append(contentsOf: 7...9)
xs.insert(0, at: 0)                           // O(n): shifts everything
xs.insert(contentsOf: [-2, -1], at: 0)

xs.removeLast()                               // O(1)
xs.popLast()                                  // O(1), returns Optional — never traps
xs.removeFirst()                              // O(n)!  use a Deque if you do this in a loop
xs.removeAll { $0 < 0 }                       // one pass, in place
xs.removeSubrange(0..<2)
xs.replaceSubrange(0..<2, with: [10, 20, 30]) // lengths need not match

xs.first;  xs.last                            // Optional — safe
xs[0]                                         // traps when empty
xs.indices                                    // Range<Int> for Array
xs.isEmpty                                    // ALWAYS this, never count == 0

Slices: the trap and the fix

A slice is a *view*, not a copy.

What surprises people
let xs = [10, 20, 30, 40, 50]
let tail = xs[2...]              // ArraySlice<Int>

tail.count       // 3
tail[0]          // 💥 traps — indices are the PARENT's
tail.startIndex  // 2
tail.first       // 30  ✓ safe

// A slice keeps the WHOLE parent buffer alive:
func firstLine(of huge: [UInt8]) -> ArraySlice<UInt8> {
  huge.prefix(80)                // holds all 500 MB alive
}
What to do
// Index relative to the slice, or offset from its start
tail[tail.startIndex]                       // 30
tail[tail.index(tail.startIndex, offsetBy: 1)]  // 40

// enumerated() gives OFFSETS, which is usually what you meant
for (offset, value) in tail.enumerated() { }

// zip(indices, self) when you need real indices
for (index, value) in zip(tail.indices, tail) { }

// Copy out to break the storage link
func firstLine(of huge: [UInt8]) -> [UInt8] {
  Array(huge.prefix(80))                     // now 80 bytes
}

Set & SetAlgebra

var tags: Set<String> = ["swift", "ios"]

tags.insert("ios")                    // (inserted: false, memberAfterInsert: "ios")
if tags.insert("macos").inserted { }  // the "was it new?" idiom
tags.remove("ios")                    // returns the removed element, or nil
tags.contains("swift")                // O(1)

let a: Set = [1, 2, 3], b: Set = [3, 4]
a.union(b)                 // [1,2,3,4]
a.intersection(b)          // [3]
a.subtracting(b)           // [1,2]
a.symmetricDifference(b)   // [1,2,4]
a.isSubset(of: b);  a.isSuperset(of: b);  a.isDisjoint(with: b)
// Mutating twins: formUnion, formIntersection, subtract, formSymmetricDifference

// Cheapest dedupe there is — but it loses order
let unique = Array(Set(items))

// Set order is NOT stable across launches (hash seeding). Sort before display:
let display = tags.sorted()

// OptionSet is SetAlgebra over a bitfield:
struct Permissions: OptionSet {
  let rawValue: Int
  static let read = Permissions(rawValue: 1 << 0)
  static let write = Permissions(rawValue: 1 << 1)
  static let all: Permissions = [.read, .write]
}
if perms.contains(.write) { }

Dictionary essentials

The `default:` subscript is the single most useful line here.

var counts: [String: Int] = [:]

counts["a", default: 0] += 1          // read-modify-write, no optional dance
counts["a"] = nil                     // removes the key
counts.updateValue(5, forKey: "b")    // returns the OLD value
counts.removeValue(forKey: "b")

// Build from pairs
Dictionary(uniqueKeysWithValues: zip(ids, names))          // traps on a duplicate key
Dictionary(pairs, uniquingKeysWith: { first, _ in first }) // pick a winner instead
Dictionary(grouping: users, by: \.country)                 // [String: [User]]

// Transform
counts.mapValues { $0 * 2 }                   // [String: Int]
counts.compactMapValues { $0 > 1 ? $0 : nil } // drops entries
counts.filter { $0.value > 1 }                // returns a Dictionary, not an array

// Merge
var settings = defaults
settings.merge(overrides) { _, new in new }   // last one wins
let merged = defaults.merging(overrides) { _, new in new }

// Iteration order is unspecified. Sort explicitly when it matters:
for (key, value) in counts.sorted(by: { $0.key < $1.key }) { }

counts.keys;  counts.values                   // lazy views, not arrays
Array(counts.keys)                            // materialise only if you must

String is a collection — of graphemes

let s = "Cafe\u{301} 👍🏽"          // "Café 👍🏽"
s.count                              // 6 — user-perceived characters, O(n)
s.utf8.count                         // 13 — bytes
s.unicodeScalars.count               // 8

// No integer subscript. Indices are opaque and tied to THIS string.
let i = s.index(s.startIndex, offsetBy: 4)
s[i]                                 // " "
s[s.startIndex..<i]                  // "Café"  (a Substring — shares storage)
String(s.prefix(4))                  // copy out when you store it

s.hasPrefix("Ca");  s.hasSuffix("🏽")
s.split(separator: " ")              // [Substring]
s.components(separatedBy: ", ")      // [String] — Foundation
s.replacingOccurrences(of: "é", with: "e")
s.trimmingCharacters(in: .whitespacesAndNewlines)
s.lowercased();  s.uppercased()

if let range = s.range(of: "fé") { s[range] }         // Range<String.Index>
s.firstIndex(of: "é")

// Building strings in a loop: reserve, and append — never  result = result + x
var out = ""
out.reserveCapacity(1024)
for line in lines { out += line; out += "\n" }

swift-collections: the four you'll actually use

`.package(url: "https://github.com/apple/swift-collections", from: "1.1.0")`

import Collections

// Deque — a queue that isn't O(n) at the front
var queue: Deque<Task> = []
queue.append(task)          // back
queue.prepend(task)         // front
queue.popFirst()            // O(1)  <- the reason it exists
queue.popLast()             // O(1)

// OrderedSet — Set lookup, Array order
var recents: OrderedSet<String> = []
recents.append("swift")     // (inserted: Bool, index: Int)
recents.contains("swift")   // O(1)
recents[0]                  // O(1) — it has a RandomAccess view
recents.remove("swift")

// OrderedDictionary — stable iteration, still O(1)
var config: OrderedDictionary<String, String> = [:]
config["theme"] = "dark"
for (key, value) in config { }        // insertion order, every time

// Heap — the priority queue Swift's stdlib is missing
var heap = Heap<Int>()
heap.insert(5); heap.insert(1)
heap.min                    // O(1) peek
heap.popMin()               // O(log n)
heap.popMax()               // O(log n) — it's a min-MAX heap

// Also: BitSet, BitArray, TreeSet, TreeDictionary (persistent, cheap copies)
// And apple/swift-algorithms for chunks, windows, uniqued, product, combinations.

Generic algorithms: constrain to the right rung

// Works on anything you can walk once — including infinite sequences
func firstTwo<S: Sequence>(of s: S) -> [S.Element] { Array(s.prefix(2)) }

// Needs count / indices / multiple passes
func middle<C: Collection>(of c: C) -> C.Element? {
  guard !c.isEmpty else { return nil }
  return c[c.index(c.startIndex, offsetBy: c.count / 2)]
}

// Needs O(1) index math — binary search would be O(n log n) without this
func binarySearch<C: RandomAccessCollection>(_ c: C, _ target: C.Element) -> C.Index?
  where C.Element: Comparable {
  var lo = c.startIndex, hi = c.endIndex
  while lo < hi {
    let mid = c.index(lo, offsetBy: c.distance(from: lo, to: hi) / 2)
    if c[mid] == target { return mid }
    if c[mid] < target { lo = c.index(after: mid) } else { hi = mid }
  }
  return nil
}

// Needs in-place element assignment (length fixed)
func swapEnds<C: MutableCollection>(_ c: inout C) where C: BidirectionalCollection {
  guard !c.isEmpty else { return }
  c.swapAt(c.startIndex, c.index(before: c.endIndex))
}

// Needs to grow/shrink
func padded<C: RangeReplaceableCollection>(_ c: C, to n: Int, with e: C.Element) -> C {
  var copy = c
  while copy.count < n { copy.append(e) }
  return copy
}

// Return `some Collection` / `[Element]`, not AnyCollection, unless you truly
// need type erasure at a module boundary.

Rolling your own

Conform to the smallest protocol that gives callers what they need.

// A Sequence: one pass, possibly infinite
struct Fibonacci: Sequence, IteratorProtocol {
  private var (a, b) = (0, 1)
  mutating func next() -> Int? {
    defer { (a, b) = (b, a + b) }
    return a
  }
  func makeIterator() -> Fibonacci { self }
}
Array(Fibonacci().prefix(8))          // [0,1,1,2,3,5,8,13]

// A Collection: implement 4 members and you inherit ~100 methods
struct Matrix<Element>: RandomAccessCollection {
  private var storage: [Element]
  let columns: Int

  var startIndex: Int { 0 }
  var endIndex: Int { storage.count }
  subscript(position: Int) -> Element { storage[position] }
  // index(after:) / index(before:) are free for Int indices

  subscript(row: Int, column: Int) -> Element { storage[row * columns + column] }
}
// Now you get: map, filter, first, count, sorted, reversed, max, indices...

// An AsyncSequence: the streaming equivalent
struct Ticks: AsyncSequence {
  typealias Element = Date
  func makeAsyncIterator() -> AsyncIterator { AsyncIterator() }
  struct AsyncIterator: AsyncIteratorProtocol {
    mutating func next() async throws -> Date? {
      try await Task.sleep(for: .seconds(1))
      return .now
    }
  }
}

Performance levers

// 1. Reserve when you know the size — the single biggest easy win
var out: [Result] = []
out.reserveCapacity(inputs.count)

// 2. reduce(into:) instead of reduce — no copy per element
let counts = words.reduce(into: [String: Int]()) { $0[$1, default: 0] += 1 }

// 3. keepingCapacity when you reuse a buffer in a loop
buffer.removeAll(keepingCapacity: true)

// 4. lazy to fuse a chain and skip intermediates
let firstBig = numbers.lazy.map(expensive).first { $0 > 1_000 }

// 5. Copy-on-write is per-buffer. This copies once, not n times:
var copy = original
copy.append(x)                  // the copy happens HERE, on first mutation

// 6. Custom CoW type
struct Buffer {
  private var storage: Storage
  mutating func mutate() {
    if !isKnownUniquelyReferenced(&storage) { storage = storage.copy() }
    storage.value += 1
  }
}

// 7. Drop to a raw buffer for numeric hot loops
let total = values.withUnsafeBufferPointer { buffer -> Double in
  var sum = 0.0
  for v in buffer { sum += v }
  return sum
}

// 8. Set/Dictionary beat Array.contains the moment you ask more than once
let banned = Set(bannedList)            // build once
items.filter { !banned.contains($0.id) }  // O(n), not O(n·m)

Collection pitfalls

  • Array.removeFirst() / insert(at: 0) are O(n). In a loop that's O(n²) — use a Deque or a head index.
  • slice[0] traps: a slice inherits the parent's indices. Use slice.first or slice.startIndex.
  • A slice keeps the parent's whole buffer alive. Array(slice) when you store it.
  • enumerated() yields offsets, not indices. On a slice or a String they're different things.
  • Set and Dictionary iteration order changes between launches. Never persist or diff it — sorted() first.
  • hashValue is not stable across launches or platforms. Never store it; use a real hash (SHA-256) if you need persistence.
  • array.count == 0 instead of isEmpty — for String and lazy sequences count is O(n).
  • array.contains inside a loop over another array is the classic accidental O(n²).
  • Mutating a value-type collection inside its own for loop iterates the *original copy* — no crash, but usually not what you meant. Collect the changes, then apply.
  • first(where:) short-circuits; filter { }.first does not.
  • sort() is not stable. Sort by a tuple including the original offset when ties must keep their order.

Collections — Operation Cookbook

"I want to ___" mapped to the exact call. Chain these instead of writing loops.

Sources: Collection — Swift Standard Library

I want to…

The lookup table. Package ops are marked.

GoalCall
transform every elementmap { } · mapValues { } for dictionaries
transform and drop nilscompactMap { }
flatten one levelflatMap { } · joined() · joined(separator:)
fold to a single valuereduce(0, +) · reduce(into: [:]) { } (no copies)
keep matching elementsfilter { }
find the first matchfirst(where:) · firstIndex(where:) (short-circuits)
find the last matchlast(where:) · lastIndex(where:)
does any / do all matchcontains(where:) · allSatisfy { }
count matchescount(where:) (Swift 6) · filter { }.count
biggest / smallest by fieldmax(by:) · min(by:) · max { $0.x < $1.x }
sortsorted() · sorted(by:) · sorted(using: KeyPathComparator(\.date))
reverse / shuffle / rotatereversed() · shuffled() · rotate(toStartAt:)*
group by a keyDictionary(grouping:by:)
take / drop from the endsprefix(n) suffix(n) dropFirst(n) dropLast(n) prefix(while:)
split on a separatorsplit(separator:) · split(whereSeparator:)
fixed-size chunkschunks(ofCount: 3)* · stride + Array(slice)
sliding windowswindows(ofCount: 3)*
consecutive pairsadjacentPairs()* · zip(xs, xs.dropFirst())
remove duplicatesSet(xs) (unordered) · uniqued()* (keeps order)
walk two collections togetherzip(a, b) · Publishers.Zip for streams
cartesian product / combinationsproduct(a, b)* · combinations(ofCount:)* · permutations()*
index and element togetherenumerated() (offsets) · zip(xs.indices, xs) (indices)
mutate in placeremoveAll(where:) · swapAt · replaceSubrange · loop over indices
diff two collectionsnew.difference(from: old) → CollectionDifference
defer the work.lazy.map { }.filter { }.first { }

Transform & fold

let names = users.map(\.name)                       // key path as a function
let ages = users.map { $0.age }
let ids = rows.compactMap { Int($0.id) }            // drops the unparseable
let allTags = posts.flatMap(\.tags)                 // [[Tag]] -> [Tag]
let sentence = words.joined(separator: " ")

let total = orders.reduce(0) { $0 + $1.amount }
let sum = orders.map(\.amount).reduce(0, +)         // clearer, one extra array

// reduce(into:) — mutates an inout accumulator, so no copy per element
let byCountry = users.reduce(into: [String: [User]]()) { acc, user in
  acc[user.country, default: []].append(user)
}
let counts = text.reduce(into: [Character: Int]()) { $0[$1, default: 0] += 1 }

// Running totals
let runningTotal = amounts.reduce(into: [Decimal]()) { $0.append(($0.last ?? 0) + $1) }

// Dictionaries
prices.mapValues { $0 * 1.21 }
prices.compactMapValues { $0 > 0 ? $0 : nil }

Search & test

users.first { $0.isAdmin }                  // Optional<User>, stops at the first hit
users.firstIndex { $0.id == target }        // Optional<Index>
users.last { $0.isActive }
users.contains { $0.age < 18 }              // any
users.allSatisfy { $0.email.contains("@") } // all
users.count { $0.isActive }                 // Swift 6; else filter { }.count

users.max { $0.score < $1.score }           // by a field
users.min(by: { $0.date < $1.date })
amounts.max();  amounts.min()               // Comparable elements

// Binary search on sorted data: lower bound via partitioningIndex (swift-algorithms)
let insertAt = sorted.partitioningIndex { $0 >= newValue }

// Short-circuit matters: this stops early…
if users.contains(where: \.isAdmin) { }
// …this walks everything and allocates
if !users.filter(\.isAdmin).isEmpty { }

Sort & order

items.sorted()                                      // Comparable
items.sorted(by: >)                                 // descending
items.sorted { $0.date > $1.date }                  // by a field

// Multi-key sort with a tuple
items.sorted { ($0.priority, $0.title) < ($1.priority, $1.title) }

// Foundation comparators — composable and reversible
items.sorted(using: KeyPathComparator(\.date, order: .reverse))
items.sorted(using: [KeyPathComparator(\.group),
                     KeyPathComparator(\.name)])

// sort() is NOT stable. Make it stable by including the original offset:
let stable = items.enumerated()
  .sorted { ($0.element.priority, $0.offset) < ($1.element.priority, $1.offset) }
  .map(\.element)

// In place (MutableCollection)
items.sort { $0.name < $1.name }
items.reverse()
items.shuffle()
let pivot = items.partition { $0.isDone }   // false's first, returns the boundary index

// Sorted top-K without sorting everything: O(n log k)
let top5 = scores.max(count: 5)             // swift-algorithms

Group, aggregate, index

// Group
let byCountry = Dictionary(grouping: users, by: \.country)     // [String: [User]]
let byInitial = Dictionary(grouping: names) { $0.first! }

// Index by a unique key (build a lookup table once)
let byID = Dictionary(uniqueKeysWithValues: users.map { ($0.id, $0) })
let safeByID = Dictionary(users.map { ($0.id, $0) }) { first, _ in first }
let keyed = users.keyed(by: \.id)                              // swift-algorithms

// Aggregate per group
let totalPerCountry = orders.reduce(into: [String: Decimal]()) {
  $0[$1.country, default: 0] += $1.amount
}

// Count occurrences, then rank
let ranked = Dictionary(grouping: events, by: \.type)
  .mapValues(\.count)
  .sorted { $0.value > $1.value }

// Sectioned list for SwiftUI
struct Section: Identifiable { let id: String; let items: [User] }
let sections = Dictionary(grouping: users, by: \.country)
  .map { Section(id: $0.key, items: $0.value.sorted { $0.name < $1.name }) }
  .sorted { $0.id < $1.id }

Slice, chunk, window

xs.prefix(3);  xs.suffix(3)
xs.dropFirst();  xs.dropLast(2)
xs.prefix(while: { $0 < 10 });  xs.drop(while: { $0 < 10 })
xs[2...];  xs[..<5];  xs[1..<4]                 // all ArraySlice — no copy

"a,b,,c".split(separator: ",")                                  // 3 — empties dropped
"a,b,,c".split(separator: ",", omittingEmptySubsequences: false) // 4

// Fixed chunks — swift-algorithms
for page in items.chunks(ofCount: 50) { try await upload(Array(page)) }

// Without the package
let chunks = stride(from: 0, to: items.count, by: 50).map {
  Array(items[$0 ..< min($0 + 50, items.count)])
}

// Sliding windows and pairs
for window in temperatures.windows(ofCount: 3) {                // [a,b,c], [b,c,d]…
  moving.append(window.reduce(0, +) / 3)
}
for (previous, current) in readings.adjacentPairs() {           // deltas
  deltas.append(current - previous)
}
// No package:  zip(readings, readings.dropFirst())

// Split on a changing key
let runs = items.chunked { $0.category == $1.category }         // swift-algorithms

Combine & dedupe

// Zip stops at the shorter one
for (name, score) in zip(names, scores) { }
let pairs = Array(zip(ids, names))

// Concatenate
let all = a + b
let flat = [[1, 2], [3]].joined()               // lazy FlattenSequence
Array([[1, 2], [3]].joined())                   // [1,2,3]

// Dedupe, order lost
let unique = Array(Set(items))

// Dedupe, order kept — no package
var seen = Set<Item.ID>()
let ordered = items.filter { seen.insert($0.id).inserted }

// Dedupe, order kept — swift-algorithms
let ordered2 = items.uniqued(on: \.id)

// Set algebra over model objects
let added = Set(new.map(\.id)).subtracting(Set(old.map(\.id)))

// Interleave / intersperse
Array([1, 2, 3].interspersed(with: 0))          // [1,0,2,0,3]

Diffing for UI updates

Let the stdlib compute the minimal change set.

// CollectionDifference — the basis of animated table/collection updates
let diff = newItems.difference(from: oldItems)     // Element: Equatable

for change in diff {
  switch change {
  case let .insert(offset, element, _): rows.insert(element, at: offset)
  case let .remove(offset, _, _):       rows.remove(at: offset)
  }
}

// Or apply it wholesale
if let updated = oldItems.applying(diff) { rows = updated }

// Detect moves instead of remove+insert (nicer animations)
let moves = newItems.difference(from: oldItems).inferringMoves()

// UIKit
dataSource.apply(snapshot, animatingDifferences: true)

// SwiftUI does this for you — your job is just STABLE IDS
ForEach(items) { item in Row(item) }     // items: Identifiable

Mutate in place

// Filter in place, one pass, no new array
items.removeAll { $0.isExpired }

// Mutate every element — you need indices, not the elements
for index in items.indices { items[index].isRead = true }

// …or map to new values
items = items.map { var copy = $0; copy.isRead = true; return copy }

// …or a mutating helper on the element
extension MutableCollection {
  mutating func mutateEach(_ body: (inout Element) -> Void) {
    for index in indices { body(&self[index]) }
  }
}
items.mutateEach { $0.isRead = true }

// inout parameters keep value semantics while allowing local mutation
func normalize(_ values: inout [Double]) {
  guard let maximum = values.max(), maximum > 0 else { return }
  for i in values.indices { values[i] /= maximum }
}

// Dictionaries: modify through the subscript, don't read-modify-write
counts["key", default: 0] += 1
users[id]?.name = "Ada"                    // no-op if the key is missing

Lazy pipelines

Same syntax, no intermediate arrays.

// Eager: builds TWO full arrays, runs expensive() on every element
let first = items.map(expensive).filter(\.isValid).first

// Lazy: runs expensive() until the first valid element, allocates nothing
let first = items.lazy.map(expensive).first(where: \.isValid)

// Infinite sequences are only possible lazily
let powers = sequence(first: 1) { $0 * 2 }        // 1,2,4,8…
Array(powers.prefix(10))

let evens = (1...).lazy.filter { $0.isMultiple(of: 2) }
Array(evens.prefix(5))                            // [2,4,6,8,10]

// Careful: a lazy chain is re-evaluated on every traversal
let lazyView = items.lazy.map(expensive)
_ = lazyView.count       // runs expensive() n times
_ = Array(lazyView)      // runs it n times AGAIN
// Materialise once if you'll use it twice.

Small extensions worth having

extension Collection {
  /// Non-trapping index access.
  subscript(safe index: Index) -> Element? {
    indices.contains(index) ? self[index] : nil
  }
  var nonEmpty: Self? { isEmpty ? nil : self }
}

extension Sequence {
  func sorted<V: Comparable>(by keyPath: KeyPath<Element, V>,
                             _ order: (V, V) -> Bool = (<)) -> [Element] {
    sorted { order($0[keyPath: keyPath], $1[keyPath: keyPath]) }
  }
  func grouped<K: Hashable>(by keyPath: KeyPath<Element, K>) -> [K: [Element]] {
    Dictionary(grouping: self) { $0[keyPath: keyPath] }
  }
  func sum<V: AdditiveArithmetic>(of keyPath: KeyPath<Element, V>) -> V {
    reduce(.zero) { $0 + $1[keyPath: keyPath] }
  }
}

extension Array {
  func chunked(into size: Int) -> [[Element]] {
    stride(from: 0, to: count, by: size).map {
      Array(self[$0 ..< Swift.min($0 + size, count)])
    }
  }
}

users[safe: 5]                       // nil instead of a crash
orders.sum(of: \.amount)
users.grouped(by: \.country)
users.sorted(by: \.name)

Choosing the operation

  • Reach for first(where:), contains(where:), allSatisfy — they short-circuit. filter { }.first doesn't.
  • Use reduce(into:) whenever the accumulator is a collection; plain reduce copies it every step.
  • Build a Set or a Dictionary once outside the loop instead of calling contains inside it.
  • Key paths (map(\.name), grouped(by: \.id)) read better than closures and can't capture the wrong thing.
  • lazy pays off when the chain is long, the work per element is expensive, or you only need the first few results.
  • Prefer one clear chain over a clever one. If you need a comment to explain the chain, write the loop.
  • swift-algorithms and swift-collections are first-party, dependency-free and small — adding them is not a design decision you'll regret.
  • Don't chain more than about three operators without naming an intermediate value; compile times and readability both suffer.

Swift Engineering Best Practices

The habits that make Apple-platform code survive a second year and a second engineer.

The ten that matter most

If you say nothing else in a review, say these.

  • Make illegal states unrepresentable. An enum with associated values beats four Bools.
  • Value types by default. Reach for a class only when you need identity or shared mutable state.
  • Inject your dependencies. Anything reading the clock, the network, the disk or a singleton is a constructor parameter.
  • Keep the public surface small. internal is the default for a reason; public is a promise.
  • Errors are values. Typed error enums, no try!, no silent try? that swallows the cause.
  • One source of truth per piece of state, owned by exactly one type.
  • @MainActor on anything touching UI, actor for shared mutable state, Sendable on anything crossing a boundary.
  • Tests are part of the change, not a follow-up ticket. If it's hard to test, the design is wrong.
  • Delete code. Dead code, feature flags past their expiry, and "we might need it" abstractions all cost real money.
  • Measure before optimising, with Instruments, on the oldest device you support.

Follow the Swift API Design Guidelines

Clarity at the point of use beats brevity at the point of definition.

Do
// Mutating verb / non-mutating -ed, -ing
array.sort();  let sorted = array.sorted()
list.reverse(); let reversed = list.reversed()

// Read like an English phrase at the call site
employees.remove(at: index)
view.insertSubview(child, at: 0)
scanner.scan(services: [heartRate])

// Omit needless words; the type is already in the signature
func add(_ observer: Observer)          // not addObserver(observer:)
extension Collection {
  func removingBoundaries() -> [Element]
}

// Booleans read as assertions
if line.isEmpty, path.isAbsolute { }

// Protocols: -able / -ible for capability, nouns for what it IS
protocol Cancellable { }
protocol Collection { }
Don't
func sortArray(theArray: inout [Int])   // stutters, unclear
employees.remove(index)                 // remove the value, or at that index?
func doWork(_ a: Bool, _ b: Bool)       // unreadable at the call site
var flag = false                        // flag for what?
class MyDataManagerHelperUtil { }       // four nouns, zero meaning

// Abbreviations the reader has to decode
func calcTtlAmt(_ i: [Itm]) -> Dec

// Type name repeated in every member
struct User {
  var userName: String                  // user.userName
  var userAge: Int
}

Errors: model them, don't stringly-type them

enum SyncError: Error, Equatable {
  case offline
  case unauthorized
  case server(status: Int)
  case decoding(field: String)
}

extension SyncError: LocalizedError {
  // What the USER sees - never the raw error.
  var errorDescription: String? {
    switch self {
    case .offline:      String(localized: "You appear to be offline.")
    case .unauthorized: String(localized: "Please sign in again.")
    case .server, .decoding: String(localized: "Something went wrong. Try again.")
    }
  }
}

// Typed throws (Swift 6) documents the failure mode in the signature.
func sync() async throws(SyncError) -> Snapshot { ... }

// At the boundary, translate - never leak a transport error into the domain.
do { return try await api.fetch() }
catch let error as URLError where error.code == .notConnectedToInternet {
  throw SyncError.offline
}

// Rules of thumb
// try!   -> only when failure is a programmer error (a bundled resource)
// try?   -> only when you genuinely do not care WHY it failed
// fatalError -> only for unreachable states, with a message that names the invariant

Optionals

An Optional is a design decision, not a nuisance.

Do
guard let user = session.user else { return .signedOut }

// Give the caller a real default
let name = user.nickname ?? user.legalName

// Optional chaining over pyramids
let city = user.address?.city?.capitalized

// Empty collections instead of optional collections
var tags: [Tag] = []          // not [Tag]?

// Non-optional after init: use a factory or a failable init
init?(rawValue: String) { ... }
Don't
let user = session.user!               // crash waiting to happen
var name: String!                      // implicitly unwrapped: no safety at all

if user != nil {
  print(user!.name)                    // the compiler already offered you if-let
}

var items: [Item]?                     // now every caller handles nil AND empty

// try? that hides the reason
let data = try? Data(contentsOf: url)  // was it missing? unreadable? corrupt?

Logging & diagnostics

`OSLog` is structured, fast, privacy-aware and visible in Instruments.

import OSLog

extension Logger {
  private static let subsystem = Bundle.main.bundleIdentifier!
  static let sync = Logger(subsystem: subsystem, category: "sync")
  static let ui   = Logger(subsystem: subsystem, category: "ui")
}

Logger.sync.debug("Starting sync for \(userID, privacy: .private)")
Logger.sync.error("Sync failed: \(error.localizedDescription, privacy: .public)")
Logger.sync.notice("Uploaded \(count) records in \(duration, format: .fixed(precision: 2))s")

// Dynamic data is redacted by default in release builds - mark .public deliberately.

// Performance regions that show up in Instruments' timeline
let signposter = OSSignposter(logger: Logger.sync)
let state = signposter.beginInterval("decode", id: signposter.makeSignpostID())
defer { signposter.endInterval("decode", state) }

// print() is invisible in production, unstructured, and slows down release builds.

Configuration, secrets & feature flags

// 1. Build settings live in .xcconfig, not in the project file (mergeable, reviewable).
//    API_BASE_URL = https:/$()/api.staging.example.com
//    Then surface it through Info.plist and read it once:
enum AppConfig {
  static let apiBaseURL: URL = {
    guard let raw = Bundle.main.object(forInfoDictionaryKey: "API_BASE_URL") as? String,
          let url = URL(string: raw) else { preconditionFailure("API_BASE_URL missing") }
    return url
  }()
}

// 2. Secrets never live in the repo or in the binary. Tokens go to the Keychain.
func store(token: String, account: String) throws {
  let query: [String: Any] = [
    kSecClass as String: kSecClassGenericPassword,
    kSecAttrAccount as String: account,
    kSecValueData as String: Data(token.utf8),
    kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
  ]
  SecItemDelete(query as CFDictionary)
  guard SecItemAdd(query as CFDictionary, nil) == errSecSuccess else { throw KeychainError.write }
}

// 3. Feature flags are typed, defaulted, and have an owner + an expiry date.
struct FeatureFlags: Sendable {
  var newCheckout = false        // owner: payments, remove after 2026-11
  var useLocalCache = true
}

Performance checklist

In rough order of how often each one is the actual culprit.

SymptomLook atFix
Janky scrollingTime Profiler, main threadmove work off @MainActor; precompute in the model
Slow listview body countsmaller View structs, stable IDs, Lazy stacks, avoid AnyView
Memory spikes on imagesAllocationsdownsample with ImageIO before display; don't decode full-res
Slow launchApp Launch templatedefer non-critical work off didFinishLaunching; fewer dynamic frameworks
Growing memoryLeaks + Memory Graphretain cycles in closures/delegates; unbounded caches
Battery / thermalEnergy Log, Points of Interestcoalesce network calls, back off timers and BLE scans
Slow build-Xfrontend -warn-long-function-bodies=100annotate types on big expressions; split modules
Hitches after a changeSignposts + XCTMetricadd a performance test so it can't regress silently

Concurrency practices

The Swift 6 era, in review-comment form.

  • @MainActor the view model, not individual methods — one boundary is easier to reason about.
  • Keep actor methods short and side-effect free around await; state can change under you (reentrancy).
  • Store Task handles you must cancel, and cancel them in onDisappear / deinit.
  • Prefer async let and TaskGroup to loose Task { } — structured work cannot leak.
  • Turn on strict concurrency per module and migrate leaf modules first.
  • Never block: no DispatchSemaphore, no .wait(), no Thread.sleep in async code.
  • @unchecked Sendable without a real lock is a lie the compiler will believe and the runtime won't.
  • Don't reach for Task.detached to "get off the main thread" — a nonisolated func already is.

Accessibility, localization & privacy

Cheap if done as you go; expensive as a retrofit.

  • Every interactive control needs a label: .accessibilityLabel, .accessibilityValue, .accessibilityHint.
  • Support Dynamic Type — use text styles (.body, .headline), never fixed point sizes; test at AX5.
  • Contrast, hit targets ≥ 44×44 pt, and .accessibilityElement(children: .combine) for compound rows.
  • Use String(localized:) / String Catalogs, never string concatenation for sentences; format numbers and dates with FormatStyle.
  • Test right-to-left with the RTL pseudolanguage scheme option, and pseudo-locale for truncation.
  • Declare data use in the privacy manifest (PrivacyInfo.xcprivacy) and justify required-reason APIs.
  • Ask for permissions *in context*, with a purpose string that says what the user gets — not at launch.

Documentation that pays for itself

DocC comments show up in Quick Help and in the generated docs.

/// Cancels an order and refunds the customer.
///
/// The order must still be in the ``OrderState/placed`` state; a delivered
/// order has to go through returns instead.
///
/// - Parameters:
///   - id: The identifier of the order to cancel.
///   - reason: Free text shown to the support team. Not user visible.
/// - Returns: The updated order, with `state == .cancelled`.
/// - Throws: ``OrderError/notCancellable`` if the order already shipped.
/// - Note: Idempotent — cancelling twice returns the same result.
public func cancel(orderID id: Order.ID, reason: String) async throws -> Order

// Comment WHY, never WHAT:
// The API returns 200 with an empty body when the token expired (BUG-4821),
// so an empty payload has to be treated as unauthenticated.
guard !data.isEmpty else { throw SyncError.unauthorized }

// MARK: - marks sections;  // TODO: needs an owner and a ticket.

Project hygiene

What a healthy Swift repo has.

AreaPractice
Formattingswift-format or SwiftFormat in a pre-commit hook — never argue about style in review
LintingSwiftLint with the ruleset committed; warnings fail CI, not the reviewer's patience
Build settings.xcconfig files per configuration; keep the .pbxproj diff small
WarningsSWIFT_TREAT_WARNINGS_AS_ERRORS on CI; a warning nobody fixes is a warning everybody ignores
DependenciesSPM, pinned with Package.resolved committed; audit before adding — every dependency is a liability
TestsXcode test plans; unit tests on every PR, UI tests nightly
CIbuild + test + lint on PR; archive on merge to main; keep it under 10 minutes
Releasessemantic versioning, an automated changelog, and a tagged commit per release
Crash reportingsymbolicated crashes with breadcrumbs; triage weekly, not at release time
Code ownersCODEOWNERS per module so reviews route themselves

A review checklist you can run in five minutes

  • Correctness — what happens on empty, nil, error, cancellation, and a slow network?
  • Concurrency — who owns this state? Is the isolation stated or assumed?
  • Naming — could I guess what this does from the call site alone?
  • Surface — does this need to be public? Does the new API force future callers into a corner?
  • Tests — is there one test that fails if the core behaviour regresses?
  • Size — is this diff reviewable? Under ~400 lines gets real review; over that gets a rubber stamp.
  • Deletion — did this change leave anything behind that should have gone?
  • Review the design before the syntax. Comment on the shape first; formatting is the linter's job.

Architectural Approaches

Draw the data flow, name the trade-off, and justify the choice for the team's size.

Four data flows

If you can draw these on a whiteboard you can answer almost any architecture question.

FOUR DATA FLOWS YOU MUST BE ABLE TO DRAWMVC (Apple flavour)ViewUIViewControllerUIViewControllerModeldata + ruleseventsreadsreload / KVOMassive View Controller: view + navigation + networking all end up here.MVVMViewSwiftUI / UIKitViewModel@Published stateModelrepos + entitiesintentcallsbindingTestable presentation logic. No rule about navigation -> pair with a Coordinator.VIPER / Clean-ishViewpassivePresenterformatsInteractoruse casesEntitymodeloutput protocolRouter owns navigation. Very testable, very boilerplate-heavy.Unidirectional (TCA / Redux)Viewrenders StateActionwhat happenedReducerpure mutationStateone sourcere-renderView sends an Action, the Reducer mutates State and returns Effects.

Clean Architecture layers

The only rule that matters: dependencies point inward. Everything else is naming.

CLEAN ARCHITECTURE - THE DEPENDENCY RULEFrameworks & DriversInterface AdaptersUse CasesEntitiessource-code dependencies point INWARD onlyinner layers know nothing about outer onesFrameworks & DriversSwiftUI / UIKit views, URLSession,CoreData, CoreBluetooth, Keychain-> disposable detail; swap freelyInterface AdaptersViewModels / Presenters, Repositoryimplementations, DTO <-> Entity mappers-> where protocols get satisfiedUse Cases (Interactors)PlaceOrder, SyncDevices, Refundorchestrate entities; no UIKit import-> depends on protocols it declaresEntitiesstruct Order, Money, DeviceIDpure Swift, no frameworks-> the rules that outlive the app

Comparison

PatternStrengthWeaknessTestabilityBest for
MVC (Apple)trivial to start, zero ceremonyMassive View Controllerlowprototypes, tiny screens
MVPpassive view, explicit contractprotocol boilerplatehighUIKit teams migrating off MVC
MVVMbindings, works natively with SwiftUIsays nothing about navigation or side effectshighmost SwiftUI apps
VIPERstrict boundaries, router for navigation5 files per screenvery highlarge UIKit codebases, many teams
Clean / Oniondomain independent of frameworksmapping layers everywherevery highlong-lived, multi-platform domains
Redux / TCAone state, replayable, exhaustive testslearning curve, ceremony for small screensvery highcomplex state, deep navigation
Coordinatorremoves navigation from view controllersan extra object graph to ownhighany UIKit app, pairs with MVVM

MVVM done properly

The view is dumb, the view model is pure logic, the model is injected.

@MainActor @Observable
final class ProfileViewModel {
  enum State: Equatable { case idle, loading, loaded(User), failed(String) }

  private(set) var state: State = .idle
  private let repository: UserRepositing        // a protocol -> injectable
  private let analytics: (String) -> Void

  init(repository: UserRepositing, analytics: @escaping (String) -> Void = { _ in }) {
    self.repository = repository
    self.analytics = analytics
  }

  func onAppear() async {
    state = .loading
    do {
      state = .loaded(try await repository.currentUser())
      analytics("profile_loaded")
    } catch {
      state = .failed(error.localizedDescription)
    }
  }
}

struct ProfileView: View {
  @State private var vm: ProfileViewModel
  var body: some View {
    Group {
      switch vm.state {
      case .idle, .loading: ProgressView()
      case .loaded(let u):  ProfileDetail(user: u)
      case .failed(let m):  ErrorView(message: m) { Task { await vm.onAppear() } }
      }
    }
    .task { await vm.onAppear() }
  }
}

Repository + Use Case (the Clean core)

Protocols live in the domain; implementations live outside.

// ---- Domain (no UIKit, no URLSession, no CoreData) ----
struct Order: Equatable, Sendable { let id: UUID; let total: Decimal; var state: State }

protocol OrderRepositing: Sendable {                 // declared BY the domain
  func order(id: UUID) async throws -> Order
  func save(_ order: Order) async throws
}

struct CancelOrder: Sendable {                       // a use case = one verb
  let repository: OrderRepositing
  let now: @Sendable () -> Date

  func callAsFunction(id: UUID) async throws -> Order {
    var order = try await repository.order(id: id)
    guard order.state == .placed else { throw OrderError.notCancellable }
    order.state = .cancelled(at: now())
    try await repository.save(order)
    return order
  }
}

// ---- Data layer (outer ring) satisfies the protocol ----
struct RemoteOrderRepository: OrderRepositing {
  let api: APIClient
  func order(id: UUID) async throws -> Order { try await api.get("/orders/\(id)").toDomain() }
  func save(_ order: Order) async throws { try await api.put("/orders", order.toDTO()) }
}

// ---- Test ----
let sut = CancelOrder(repository: InMemoryOrderRepository(.placed), now: { .fixed })
#expect(try await sut(id: id).state == .cancelled(at: .fixed))

Coordinator pattern (UIKit navigation)

Takes navigation out of the view controller.

protocol Coordinator: AnyObject {
  var children: [Coordinator] { get set }
  func start()
}

final class CheckoutCoordinator: Coordinator {
  var children: [Coordinator] = []
  private let navigation: UINavigationController
  private let onFinish: () -> Void

  init(navigation: UINavigationController, onFinish: @escaping () -> Void) {
    self.navigation = navigation
    self.onFinish = onFinish
  }

  func start() {
    let vm = CartViewModel(onCheckout: { [weak self] in self?.showPayment() })
    navigation.pushViewController(CartViewController(viewModel: vm), animated: true)
  }

  private func showPayment() {
    let vc = PaymentViewController(onSuccess: { [weak self] in
      self?.navigation.popToRootViewController(animated: true)
      self?.onFinish()
    })
    navigation.pushViewController(vc, animated: true)
  }
}
// SwiftUI equivalent: a Router @Observable object holding `var path: [Route]`.

Unidirectional flow without a library

Shows you understand *why* TCA looks the way it does.

@MainActor @Observable
final class Store<State, Action> {
  private(set) var state: State
  private let reduce: (inout State, Action) -> Effect<Action>

  init(initial: State, reduce: @escaping (inout State, Action) -> Effect<Action>) {
    self.state = initial
    self.reduce = reduce
  }

  func send(_ action: Action) {
    let effect = reduce(&state, action)          // pure, synchronous
    Task { for await next in effect.actions { send(next) } }   // side effects re-enter
  }
}

enum CounterAction { case increment, factResponse(String) }

let reducer: (inout CounterState, CounterAction) -> Effect<CounterAction> = { state, action in
  switch action {
  case .increment:
    state.count += 1
    return .run { [n = state.count] in .factResponse(await api.fact(n)) }
  case .factResponse(let fact):
    state.fact = fact
    return .none
  }
}

How to answer "which architecture would you use?"

  • Start with the constraints: team size, app lifetime, testing bar, SwiftUI vs UIKit, how much shared state there is.
  • Name the invariants you want: one source of truth, no logic in views, navigation as data, framework-free domain.
  • Then pick the lightest pattern that gives them — usually MVVM + repositories for SwiftUI, TCA when state is genuinely complex.
  • Say what you'd not do: no architecture astronautics on a 5-screen app; no VIPER for a prototype.
  • Mention the seams that matter more than the acronym: DI, a testable domain, and navigation modelled as state.
  • Avoid answering with a single acronym and no trade-off — that is the actual failure mode.

Modularization Approaches

Split by feature, invert with interfaces, keep the graph acyclic — and watch build times fall.

Sources: Swift Package Manager — swift.org

A module graph that scales

Four layers, arrows only ever point down. This is the picture to draw.

A MODULE GRAPH THAT SCALES - ARROWS ONLY EVER POINT DOWNApp (thin shell)composition root: builds the graph,deep links, app lifecycleFeatureOnboardingscreens + logicno feature imports anotherFeatureCatalogscreens + logicno feature imports anotherFeatureCheckoutscreens + logicno feature imports anotherFeatureSettingsscreens + logicno feature imports anotherDomainModelsOrder, Money, UserUseCasesPlaceOrder, SyncRepositoryInterfacesprotocols onlyfeatures depend on the domain, never the reverseCoreNetworkingURLSession clientCorePersistenceCoreData / GRDBDesignSystemtokens + componentsCoreTestingfixtures + doublesappfeaturesdomaincoreno cycles - SPM refuses to build thema 'Common' module is an anti-patternone test target per modulea preview app per feature

Interface modules + strategy comparison

How to break a dependency between two features without a shared 'Common' blob.

INTERFACE MODULES - HOW TO BREAK A DEPENDENCY (AND A BUILD BOTTLENECK)FeatureCheckoutimports CatalogInterfacecompiles WITHOUT CatalogImplCatalogInterfaceprotocol CatalogService { }struct Product { }tiny + rarely changesCatalogImplfinal class LiveCatalogthe heavy moduleCatalogMockstruct StubCatalogused by tests + previewsAppthe only module that seesBOTH sides: it wires themimportsconforms toinjects the live implementation at startupResult: touching CatalogImpl no longer recompiles FeatureCheckout.Tests and SwiftUI previews link the mock instead - fast and hermetic.Single targetfastest to startslowest to build at scaleno boundaries enforcedLayer-basedUI / Domain / Dataclear rulesevery feature still touches all 3Feature-basedone module per featureteams work in parallelwatch for shared-code sprawlFeature + interfacesimpl hidden behind protocolsbest build times + testabilitymost setup costPICKING A STRATEGY

Why modularize at all

Have concrete reasons ready; "cleanliness" is not one.

  • Build times — only the changed module and its dependents recompile.
  • Enforced boundaries — internal is invisible across modules, so architecture becomes compiler-checked.
  • Parallel teams — clear ownership, fewer merge conflicts in one giant project file.
  • Faster feedback — run one module's tests, or launch a tiny preview app for one feature.
  • Reuse — share a design system or networking layer with an extension, watch app, or second product.
  • Cost: more setup, dependency-injection plumbing, resource/bundle handling, and versioning discipline.

Package.swift for a feature module

One package can hold many targets — start there before many packages.

// swift-tools-version: 6.0
import PackageDescription

let package = Package(
  name: "Modules",
  platforms: [.iOS(.v17)],
  products: [
    .library(name: "FeatureCheckout", targets: ["FeatureCheckout"]),
    .library(name: "CatalogInterface", targets: ["CatalogInterface"]),
  ],
  dependencies: [
    .package(url: "https://github.com/pointfreeco/swift-composable-architecture", from: "1.15.0")
  ],
  targets: [
    .target(name: "CatalogInterface"),                       // protocols + models only

    .target(name: "FeatureCheckout",
            dependencies: [
              "CatalogInterface", "DomainKit", "DesignSystem",
              .product(name: "ComposableArchitecture", package: "swift-composable-architecture")
            ],
            resources: [.process("Resources")],
            swiftSettings: [.swiftLanguageMode(.v6)]),

    .target(name: "CatalogLive", dependencies: ["CatalogInterface", "CoreNetworking"]),

    .testTarget(name: "FeatureCheckoutTests",
                dependencies: ["FeatureCheckout", "CatalogMock", "CoreTesting"]),
  ]
)

Interface module + composition root

Dependency inversion at module scale.

// ---------- CatalogInterface (tiny, stable, no heavy imports) ----------
public protocol CatalogService: Sendable {
  func products(matching query: String) async throws -> [Product]
}
public struct Product: Identifiable, Sendable, Equatable { public let id: UUID; public let name: String }

// ---------- FeatureCheckout: imports the INTERFACE only ----------
import CatalogInterface

public struct CheckoutView: View {
  private let catalog: any CatalogService          // never knows the concrete type
  public init(catalog: any CatalogService) { self.catalog = catalog }
}

// ---------- CatalogLive: the heavy implementation ----------
import CatalogInterface
public struct LiveCatalog: CatalogService { ... }

// ---------- App: the ONLY place that sees both ----------
import FeatureCheckout
import CatalogLive

@main struct MyApp: App {
  var body: some Scene {
    WindowGroup { CheckoutView(catalog: LiveCatalog(client: .live)) }
  }
}
// Tests & previews inject CatalogMock instead - no rebuild of CatalogLive.

Access control is the enforcement mechanism

public struct PaymentSheet: View {        // visible to other modules
  public init(...) { }                    // public types need an explicit public init!
  internal let engine: Engine             // module-only (the default)
  private var attempts = 0                // file/type-only
  fileprivate func log() { }
}

open class Themable { }                   // subclassable outside the module
package func sharedHelper() { }           // Swift 5.9: visible to the same package only

@_exported import CatalogInterface        // re-export so consumers get it for free
                                          // (convenient, but leaks your dependency graph)

// Rule of thumb: default to internal, promote to public deliberately.
// The public surface IS the module's contract - review it like an API.

Choosing a strategy

StrategyLooks likeUse when
Single app targeteverything in the appsolo dev, prototype, < ~20 screens
Layer-basedUI, Domain, Datasmall team wanting rules without much plumbing
Feature-basedFeatureCart, FeatureSearchmultiple squads; the common default
Feature + interface modulesXInterface / XLive / XMockbig codebase where build time and previews matter
Local SPM packagesone Package.swift, many targetsstart here — cheapest, fully Xcode-native
Separate repos + versionsone package per repogenuine reuse across products; adds release overhead
Tuist / XcodeGengenerated projectsmany modules and you're tired of .xcodeproj conflicts

Pitfalls

  • A Common/Utils/Shared module everything depends on — it becomes the new monolith and rebuilds constantly.
  • Cyclic dependencies — SPM refuses them; the fix is an interface module or moving the shared type down a layer.
  • Over-modularization — 80 micro-modules cost more in linking and DI wiring than they save.
  • Feature A importing Feature B. Features should be siblings that only meet in the app or via an interface module.
  • Forgetting Bundle.module for resources, or localizing strings in the wrong bundle.
  • Mixing dynamic and static linking carelessly — too many dynamic frameworks slows app launch.
  • Give every module a test target and, ideally, a runnable preview/demo app.
  • Enforce the graph in CI (a dependency-lint step) — documentation alone never holds.

Testing Approaches on iOS

What to test, at which layer, with which tool — and how to keep it fast.

Sources: Swift Testing — Apple Developer Documentation · XCTest — Apple Developer Documentation

The pyramid, iOS edition

Ratios are a heuristic, not a law — but be ready to defend the shape.

THE TEST PYRAMID, iOS EDITIONUI / End-to-end5 - 10 %Integration20 - 30 %Unit60 - 70 %slower - flakier - costlierUI / End-to-endXCUITest, XCUIApplicationminutes, flaky, needs a devicelaunch args to stub the backendassert: 3-5 critical journeys onlyIntegrationXCTest + real collaboratorsURLProtocol / in-memory storeseconds; no live networkassert: modules wired correctlyUnitSwift Testing / XCTestpure functions, reducers, mappersmilliseconds, deterministicassert: logic + edge casesRule: if a bug can be caught one layer down, write it one layer down.

Test doubles

Say the right name and show the Swift version: a struct of closures does all five.

TEST DOUBLES - KNOW THE FIVE NAMESno behaviour -> real behaviourDummynever actually used;just fills a slotlet noop: () -> Void = { }Stubreturns canned answersno assertions on it{ _ in .success(.mock) }Spya stub that RECORDSwhat it was askedvar saved: [Item] = []Mockstub + verifies theexpected interaction#expect(spy.calls == 1)Fakea real, lightweightimplementationInMemoryRepository()In Swift you rarely need a mocking framework: a struct of closures IS a stub, a spy and a mock.Injectable seamstruct API { var user: (Int) async throws -> User }let sut = Service(api: .init(user: { _ in .mock }))Verify without a frameworkfinal class Spy { private(set) var calls = 0 }#expect(spy.calls == 1)

Tooling matrix

ToolLayerUse it for
Swift Testing (@Test, #expect)unitdefault for new code: parallel, parameterised, clear failures
XCTestunit / integrationexisting suites, performance tests, XCTestExpectation
XCUITestUI / E2Eblack-box journeys; app.launchArguments to inject a stub environment
URLProtocol subclassintegrationdeterministic networking without a server
swift-snapshot-testingUIpixel/text snapshots of views, layouts, and even JSON
ViewInspectorUIunit-testing SwiftUI view trees without rendering
TCA TestStoreunitexhaustive state + effect assertions
XCTMetric / measure { }performanceguard against regressions in hot paths
addTeardownBlock + weakunitmemory-leak assertions
Xcode test plansallper-configuration runs, sanitizers, code coverage, retry-on-failure

Design for testability: inject the seam

The single most valuable answer to "how would you test this?"

// 1. Wrap the untestable thing in a value you can substitute.
struct WeatherClient: Sendable {
  var current: @Sendable (String) async throws -> Weather
}

extension WeatherClient {
  static let live = Self { city in
    let (data, _) = try await URLSession.shared.data(from: .weather(city))
    return try JSONDecoder().decode(Weather.self, from: data)
  }
  static func stub(_ weather: Weather) -> Self { Self { _ in weather } }
  static let failing = Self { _ in throw URLError(.notConnectedToInternet) }
}

// 2. The system under test only knows the seam.
@MainActor @Observable
final class WeatherViewModel {
  private let client: WeatherClient
  private(set) var state: ViewState = .idle
  init(client: WeatherClient = .live) { self.client = client }

  func load(_ city: String) async {
    state = .loading
    do    { state = .loaded(try await client.current(city)) }
    catch { state = .failed(error) }
  }
}

// 3. Tests are now trivial, fast and deterministic.
@Test func showsFailure_whenTheNetworkIsDown() async {
  let sut = WeatherViewModel(client: .failing)
  await sut.load("Bogotá")
  #expect(sut.state.isFailed)
}

Swift Testing vs XCTest

Know both; new projects default to Swift Testing.

Swift Testing (Xcode 16+)
import Testing

@Suite("Cart")
struct CartTests {
  let sut = Cart()                       // fresh per test, runs in parallel

  @Test("adds an item")
  func add() {
    sut.add(.mock)
    #expect(sut.items.count == 1)
  }

  @Test(arguments: [0, -1, -99])
  func rejectsNonPositive(_ qty: Int) {
    #expect(throws: CartError.self) { try sut.add(.mock, qty: qty) }
  }

  @Test func unwraps() throws {
    let item = try #require(sut.items.first)
    #expect(item.price > 0)
  }

  @Test(.disabled("flaky on CI"), .tags(.network))
  func slow() async { }
}
XCTest
import XCTest

final class CartTests: XCTestCase {
  var sut: Cart!
  override func setUp() { super.setUp(); sut = Cart() }
  override func tearDown() { sut = nil; super.tearDown() }

  func test_add_appendsItem() {
    sut.add(.mock)
    XCTAssertEqual(sut.items.count, 1)
  }

  func test_throwsOnZeroQuantity() {
    XCTAssertThrowsError(try sut.add(.mock, qty: 0))
  }

  func test_asyncLoad() async throws {
    let item = try XCTUnwrap(sut.items.first)
    XCTAssertGreaterThan(item.price, 0)
  }
}

Deterministic networking with URLProtocol

No server, no waiting, no flakes.

final class StubURLProtocol: URLProtocol {
  nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))?

  override class func canInit(with request: URLRequest) -> Bool { true }
  override class func canonicalRequest(for r: URLRequest) -> URLRequest { r }

  override func startLoading() {
    guard let handler = Self.handler else { return }
    do {
      let (response, data) = try handler(request)
      client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
      client?.urlProtocol(self, didLoad: data)
      client?.urlProtocolDidFinishLoading(self)
    } catch {
      client?.urlProtocol(self, didFailWithError: error)
    }
  }
  override func stopLoading() { }
}

// In the test
let config = URLSessionConfiguration.ephemeral
config.protocolClasses = [StubURLProtocol.self]
let session = URLSession(configuration: config)

StubURLProtocol.handler = { request in
  #expect(request.url?.path == "/v1/weather")
  return (HTTPURLResponse(url: request.url!, statusCode: 200,
                          httpVersion: nil, headerFields: nil)!,
          Data(#"{"temp": 21}"#.utf8))
}

Async, UI and leak tests

// Async without expectations
@Test func loadsFeed() async throws {
  let items = try await sut.loadFeed()
  #expect(items.count == 3)
}

// XCTest expectation (callback APIs, notifications)
func test_notifiesOnSave() {
  let exp = expectation(description: "saved")
  sut.onSave = { exp.fulfill() }
  sut.save()
  wait(for: [exp], timeout: 1.0)
}

// UI test with an injected stub backend
func test_checkoutJourney() {
  let app = XCUIApplication()
  app.launchArguments = ["-UITest", "-StubScenario", "happyPath"]
  app.launch()
  app.buttons["addToCart"].tap()
  app.buttons["checkout"].tap()
  XCTAssertTrue(app.staticTexts["Order confirmed"].waitForExistence(timeout: 5))
}

// Memory-leak assertion, reusable in every test
func assertNoLeak(_ instance: AnyObject, file: StaticString = #filePath, line: UInt = #line) {
  addTeardownBlock { [weak instance] in
    XCTAssertNil(instance, "Potential retain cycle", file: file, line: line)
  }
}

Principles worth stating out loud

  • Test behaviour through the public API, not private implementation — otherwise every refactor breaks the suite.
  • Inject Date, UUID, randomness, the clock and the network. Anything else is a flake waiting to happen.
  • One reason to fail per test; the name should read as a specification: test_whenOffline_showsCachedResults.
  • Prefer fakes over mocks: an in-memory repository survives refactors, a strict mock does not.
  • Snapshot tests are great for design systems, painful for whole screens — scope them tightly.
  • Sleeping in tests (Thread.sleep, wait(for:timeout:5)) — use a TestClock or an injected scheduler.
  • Shared singletons/UserDefaults.standard between tests — parallel execution will find you.
  • Chasing 100% coverage; coverage is a smoke detector, not a goal.
  • TDD in an interview: state the cycle (red → green → refactor) and *actually* write the failing test first if asked.

AI Workflows for Swift (Cursor + Claude)

Ground it, plan it, let it run the build, review every diff. Configuration that makes that reliable.

The loop, and where context belongs

Most bad AI output is a grounding failure, not a model failure.

THE LOOP THAT ACTUALLY SHIPS SWIFT CODE1. Groundpoint at the real filespaste the real error@file, not "my app"2. Planplan mode / ask for a planapprove or correct itbefore any edit3. Implementsmall, reviewable diffsone concern per turntests first when you can4. Verifyxcodebuild test via MCPagent reads the failureloop until green5. Reviewyou read every diffcheck API + concurrencythen commitfailing test / wrong approach -> back to grounding, not to naggingWHERE EACH KIND OF CONTEXT BELONGSAlways-on rulesCLAUDE.md / AGENTS.md.cursor/rules/*.mdckeep it SHORTReusable know-howClaude Code skills(SKILL.md + scripts)loaded on demandIsolated worksubagents / parallel agentsown context windowsearch, review, migrateHard guaranteeshooks + permissionsformat on write,tests before commitExternal systemsMCP serversXcodeBuildMCP, Xcode MCP,Figma, Sentry, docsIf it must be enforced -> a hook. If it is knowledge -> a skill. If it is a delegation boundary -> a subagent. If it is always true -> one short line in CLAUDE.md.

Why Swift is harder for models than TypeScript

Know the failure modes and you can pre-empt every one.

  • Stale APIs — training data is full of ObservableObject, @StateObject and DispatchQueue.main.async where you now want @Observable, @State and @MainActor.
  • Invented modifiers and initialisers that never existed, especially in SwiftUI and Charts.
  • Concurrency annotations by vibes — @unchecked Sendable or @MainActor sprinkled until it compiles.
  • Package versions and product names guessed in Package.swift.
  • It can't see the compiler. Without a build tool it is writing Swift blind — and Swift's type inference means a small mistake produces a nonsense error a thousand lines away.
  • All five are fixed the same way: pin the target versions in your rules file, and give the agent a way to actually build and test.

CLAUDE.md / AGENTS.md for a Swift project

Short, factual, and about *this* repo. Long files get skimmed.

# Project

iOS 17+ SwiftUI app, Swift 6 language mode, strict concurrency = complete.
Package manager: SPM only. Modules live in `Modules/`, app shell in `App/`.

## Build & test (always use these, never guess)
- Build:  `xcodebuild -scheme App -destination 'platform=iOS Simulator,name=iPhone 16' build`
- Test:   `xcodebuild test -scheme App -testPlan Unit -destination '...'`
- Lint:   `swiftlint --strict` and `swift-format lint -r Sources`

## House rules
- SwiftUI only. Do not add UIKit unless a `Representable` is unavoidable.
- Observation (`@Observable`), NOT `ObservableObject`/`@Published`.
- Concurrency: `@MainActor` on view models, `actor` for shared state.
  Never `DispatchQueue`, never `DispatchSemaphore`.
- Tests: Swift Testing (`@Test`, `#expect`). Do not add XCTest to new targets.
- Dependencies are injected through initialisers. No singletons in domain code.
- Never edit `*.pbxproj` by hand; add files to the SPM target instead.

## Conventions
- One `View` per file; previews at the bottom with `#Preview`.
- Errors: typed enums per module conforming to `LocalizedError`.

## Do not
- Do not create README/summary files unless asked.
- Do not bump package versions as a side effect of another change.

Cursor project rules — .cursor/rules/*.mdc

The single `.cursorrules` file is deprecated; Agent mode reads `.mdc` and `AGENTS.md`.

# .cursor/rules/swift-concurrency.mdc
---
description: Swift 6 concurrency conventions for this app
globs: ["**/*.swift"]
alwaysApply: false        # attach when a matching file is in context
---

- The language mode is Swift 6, strict concurrency complete.
- View models are `@MainActor @Observable final class`.
- Shared mutable state is an `actor`. Never a serial DispatchQueue.
- Anything crossing an isolation boundary must be `Sendable`; if you are
  tempted to write `@unchecked Sendable`, stop and make it a value type.
- Bridge legacy callbacks with `AsyncStream` or a checked continuation
  that is resumed exactly once.

# .cursor/rules/testing.mdc
---
description: How to write tests here
globs: ["**/Tests/**/*.swift"]
alwaysApply: false
---
Use Swift Testing. Inject the clock, never sleep. One behaviour per test,
named test_whenCondition_thenOutcome.

# Rule activation modes:
#   alwaysApply: true      always in context (keep these tiny)
#   globs: [...]           auto-attached when a matching file is touched
#   description only       agent-requested: the model pulls it in when relevant

Which surface owns which practice

The 2026 decision table — pick one home per rule.

If it is…Put it inBecause
always true about the repoCLAUDE.md / AGENTS.md (short)loaded every turn — length costs you context
true for some files only.cursor/rules/*.mdc with globsattached automatically when relevant
reusable know-how or a procedurea Claude Code skill (SKILL.md + scripts)routed in on demand by its description
a one-tap workflow you invokea slash commandexplicit, repeatable, parameterised
a delegation boundarya subagentown context window; parallel search / review / migration
non-negotiablea hook or a permission ruleruns deterministically, not "if the model remembers"
access to an external systeman MCP serverreal tools instead of guessed output
a big refactor across the treea subagent per module, or a git worktreekeeps contexts and diffs separate

Give the agent a compiler: Xcode over MCP

The single highest-leverage change for Swift work.

// Claude Code: add the build server once, then it can build, test and read failures.
//   claude mcp add xcodebuild -- npx -y xcodebuildmcp@latest
// or, in .mcp.json committed to the repo so the whole team gets it:
{
  "mcpServers": {
    "xcodebuild": { "command": "npx", "args": ["-y", "xcodebuildmcp@latest"] }
  }
}

// XcodeBuildMCP (maintained by Sentry since Feb 2026) exposes ~80 tools:
//   discover schemes, build, run tests, boot simulators, install & launch,
//   capture logs, drive the UI, and attach LLDB.
// Xcode 26.3 also ships its OWN MCP server: project structure, diagnostics,
//   SwiftUI preview screenshots and documentation search - any MCP client
//   (Claude Code, Cursor, Codex) can connect to it.

// The prompt that this unlocks:
//   "Run the Unit test plan. Fix whatever fails. Re-run until green,
//    then show me the diff."
// Now the agent has a ground-truth oracle instead of your patience.

Prompting: grounded vs vague

Same request, wildly different output quality.

Do
"@CartViewModel.swift @CartView.swift

The 'Apply coupon' button does nothing when the cart
is empty. Expected: it stays disabled and shows the
hint text.

Constraints: Swift 6, @Observable, no new dependencies.
Write the failing test in CartViewModelTests.swift
first, show me it fails, then fix it.
Keep the diff under 40 lines."

--- and for a bug with a build error ---
"Here is the exact compiler output:
<paste the full error, including the note: lines>
Explain the cause in one sentence before changing
anything."
Don't
"the coupon thing is broken, fix it"

"make my app faster"

"refactor this to be more SOLID"

"add tests"

// Why these fail:
// - no file, no repro, no expected behaviour
// - no constraint on scope, so you get a 900-line diff
//   touching files you never asked about
// - no oracle: nothing tells the model whether it
//   succeeded, so it declares victory and stops

Prompt patterns that work on Swift codebases

  • Failing test first. "Write a test that reproduces this, run it, show me red, then fix it." The test is the spec *and* the oracle.
  • Ask for the plan before the diff. Plan mode, or literally "don't write code yet — list the files you'd change and why."
  • Bound the blast radius. "Only touch these two files." "Keep it under 50 lines." "No new dependencies."
  • Paste real output — the compiler error, the test failure, the crash log — not your paraphrase of it.
  • Ask for two options on design questions, with the trade-off named. You pick; it implements.
  • Make it prove things. "Cite the file and line where that's defined" kills a whole class of confident invention.
  • Pin the era: "iOS 17+, Swift 6, Observation, Swift Testing." Otherwise you get 2021 SwiftUI.
  • Restart the context when a thread goes sideways. Re-grounding beats arguing with a confused transcript.

Hooks: turn conventions into guarantees

A rule the model might forget becomes a rule the tool enforces.

// .claude/settings.json - run the formatter after every Swift edit,
// and block a commit whose tests don't pass.
{
  "hooks": {
    "PostToolUse": [{
      "matcher": "Edit|Write",
      "hooks": [{
        "type": "command",
        "command": "if [[ \"$CLAUDE_FILE_PATHS\" == *.swift ]]; then swift-format -i $CLAUDE_FILE_PATHS; fi"
      }]
    }],
    "PreToolUse": [{
      "matcher": "Bash(git commit:*)",
      "hooks": [{ "type": "command", "command": "swiftlint --strict --quiet" }]
    }]
  }
}

// Good hook candidates in a Swift repo:
//   format on write, lint before commit, regenerate the Xcode project
//   after Package.swift changes, block edits to Generated/ and *.pbxproj.

A migration workflow that scales

The pattern for "change 200 files consistently".

// 1. Do ONE by hand, or supervise the agent doing one. That file becomes the spec.
// 2. Capture the rule where the tool will see it:
//      .cursor/rules/observation-migration.mdc, or a Claude Code skill
//      with the before/after example inline.
// 3. Verify the loop closes on a single module:
//      "Migrate Modules/Profile from ObservableObject to @Observable.
//       Run the Profile test plan. Show me the diff and the test output."
// 4. Then fan out - one subagent (or one git worktree) per module,
//    so contexts and diffs never mix:
//      git worktree add ../app-profile   feature/observation-profile
//      git worktree add ../app-checkout  feature/observation-checkout
// 5. Review each diff separately. Small PRs, one module each.

// The rule of thumb: never ask for a change you could not review.
// If the diff is too big to read, the task was too big to delegate.

Failure modes and the antidote

FailureWhat you seeAntidote
Hallucinated API.onChange(of:perform:) on iOS 17, invented modifiersMCP build tool; "cite where this is declared"
Era driftObservableObject, DispatchQueue.main.asyncpin iOS/Swift versions in CLAUDE.md and rules
Concurrency cargo cult@unchecked Sendable added until it buildsrule: "explain the isolation in one sentence first"
Scope creepa 900-line diff you didn't ask for"only these files", "under N lines", plan mode first
Confident wrongness"Fixed!" with no build runmake green tests the definition of done
Context rotit forgets a decision from ten turns agorestart, re-ground, or promote the decision into the rules file
Package inventiona version or product name that doesn't existhave it read Package.resolved before editing Package.swift
Silent test weakeningassertions deleted to make a suite passreview test diffs like production code

Where AI earns its keep on Apple platforms

  • Boilerplate with a known shape — Codable models from a JSON sample, Representable wrappers, TCA reducer scaffolding, Package.swift targets.
  • Tests for existing code — especially edge cases you'd skip by hand. You review the assertions.
  • Mechanical migrations — ObservableObject → @Observable, XCTest → Swift Testing, completion handlers → async.
  • Reading unfamiliar code — "explain how navigation flows through this module, cite files."
  • Build and test triage — hand it the failure, let it bisect with real runs.
  • Accessibility and localization passes — labels, Dynamic Type, string extraction. Verify with the actual audit tool.
  • Novel concurrency design — it will produce something that compiles and races.
  • Security-sensitive code — Keychain, crypto, auth flows. Review line by line or write it yourself.
  • Performance work without measurement — it optimises what looks slow, not what is slow.
  • Architecture decisions — it has no stake in the next two years of the codebase. You do.

Habits of people who get good output

  • Keep CLAUDE.md / AGENTS.md under a page. Prune it when a rule stops being violated.
  • Commit your rules, .mcp.json and skills — the team's tooling should be reproducible, like any dependency.
  • One task per session. New task, new context.
  • Read every diff before staging. git add -p is a perfectly good AI review tool.
  • Let it run the tests. An agent that can't verify itself is a very fast intern with no feedback.
  • When it's wrong twice in the same way, that's a missing rule — write the rule instead of repeating yourself.
  • You own the code. "The model wrote it" has never once been an accepted answer in a post-mortem.

Algorithmic Patterns & Complexity

Eighteen patterns, the trigger phrase that reveals each one, and Swift you can type from memory.

Pattern picker

Match the sentence in the problem statement to the row, then write the template.

PATTERN PICKER - READ THE TRIGGER, REACH FOR THE PATTERNTwo pointerssorted array, pair/triplet, in-placeO(n) / O(1)Sliding windowcontiguous subarray/substring, 'longest'O(n) / O(k)Binary searchsorted, or 'minimise the maximum'O(log n)Hash map countingfrequency, anagram, 'seen before'O(n) / O(n)Prefix sumrange sums, 'subarray equals k'O(n) / O(n)Sort then sweepintervals, overlaps, schedulingO(n log n)Monotonic stack'next greater', spans, histogramsO(n) / O(n)Heap / top-K'k largest', streaming median, merge kO(n log k)Fast & slow pointerslinked list cycle, middle, palindromeO(n) / O(1)BFSshortest path on unweighted graph, levelsO(V+E)DFS / backtrackingall combinations, permutations, pathsO(2^n) / O(n!)Topological sortdependencies, 'can you finish', orderingO(V+E)Union-Findconnected components, cycle in undirected~O(1) amortisedDynamic programming'ways to', 'min cost', overlapping subproblemsO(n*m)Greedylocal choice provably optimal, intervalsO(n log n)Trieprefix search, autocomplete, word dictionaryO(len)Bit manipulationsubsets, 'appears once', flagsO(n) / O(1)Cyclic sortvalues are 1...n, 'find the missing'O(n) / O(1)Say the pattern name out loud before you write code - it tells the interviewer you recognised the shape of the problem.

Complexity, and what it costs

Know where the cliff is: n log n is fine at a million, n² is not.

HOW FAST IT BLOWS UPinput size nworkO(1)O(log n)O(n)O(n log n)O(n^2)O(2^n)AT n = 1,000,000O(1)constantdictionary lookup, array index1 stepO(log n)logarithmicbinary search, balanced tree~20 stepsO(n)linearone pass, map / filter / reduce1 millionO(n log n)linearithmicsort(), heap build + drain~20 millionO(n^2)quadraticnested loop, naive dedupe10^12 - too slowO(2^n), O(n!)exponentialall subsets, all permutationsnot happeningSpace matters too: recursion depth is O(n) stack. An interviewer asking "can you do better?" usually means drop one factor of n with a hash map, a sort, or two pointers.

Swift standard library complexity

The numbers interviewers expect you to know.

OperationCostNote
array[i]O(1)random access, contiguous storage
array.appendO(1) amortisedO(n) on the occasional grow — reserveCapacity(n) to avoid
array.insert(at:) / remove(at:)O(n)shifts every later element
array.containsO(n)use a Set if you ask more than once
dict[key], set.insertO(1) averageO(n) worst case; needs a good Hashable
sort() / sorted()O(n log n)introsort; not stable (use enumerated() as a tiebreak)
min() / max() / reduceO(n)single pass
string.countO(n)grapheme clusters — cache it in a loop
string[i]—no integer subscript; Index walking is O(n)
Array(set) / Set(array)O(n)the cheapest dedupe, loses order
Deque push/pop both endsO(1)swift-collections; Array.removeFirst() is O(n)
Heap.insert / popMinO(log n)swift-collections; Swift has no built-in heap

Two pointers

Sorted input, pairs, or in-place compaction. O(n) time, O(1) space.

// Pair summing to a target in a SORTED array
func twoSum(_ a: [Int], _ target: Int) -> (Int, Int)? {
  var lo = 0, hi = a.count - 1
  while lo < hi {
    let sum = a[lo] + a[hi]
    if sum == target { return (lo, hi) }
    sum < target ? (lo += 1) : (hi -= 1)
  }
  return nil
}

// In-place removal, the "slow writer / fast reader" variant
func removeDuplicates(_ a: inout [Int]) -> Int {
  guard !a.isEmpty else { return 0 }
  var write = 1
  for read in 1..<a.count where a[read] != a[write - 1] {
    a[write] = a[read]
    write += 1
  }
  return write
}

// Three-sum = sort, then fix one index and two-pointer the rest -> O(n^2)

Sliding window

Contiguous subarray/substring. Grow right, shrink left. O(n).

// Fixed size: max sum of any k consecutive elements
func maxSum(_ a: [Int], k: Int) -> Int {
  var window = a.prefix(k).reduce(0, +)
  var best = window
  for i in k..<a.count {
    window += a[i] - a[i - k]      // slide, don't recompute
    best = max(best, window)
  }
  return best
}

// Variable size: longest substring with no repeated character
func longestUnique(_ s: String) -> Int {
  var lastSeen: [Character: Int] = [:]
  var start = 0, best = 0
  for (i, ch) in Array(s).enumerated() {
    if let seen = lastSeen[ch], seen >= start { start = seen + 1 }
    lastSeen[ch] = i
    best = max(best, i - start + 1)
  }
  return best
}

Hash map: counting, grouping, memory of the past

The default first idea. O(n) time, O(n) space.

// Two-sum, unsorted: remember what you've seen
func twoSum(_ a: [Int], _ target: Int) -> (Int, Int)? {
  var seen: [Int: Int] = [:]                       // value -> index
  for (i, v) in a.enumerated() {
    if let j = seen[target - v] { return (j, i) }
    seen[v] = i
  }
  return nil
}

// Frequency map - reduce(into:) avoids copying the dictionary each step
let counts = text.reduce(into: [Character: Int]()) { $0[$1, default: 0] += 1 }

// Group anagrams: build a canonical key
let groups = Dictionary(grouping: words) { String($0.sorted()) }

// First non-repeating character
let first = text.first { counts[$0] == 1 }

Prefix sums & difference arrays

Range queries in O(1) after O(n) prep.

// prefix[i] = sum of the first i elements
let prefix = nums.reduce(into: [0]) { $0.append($0.last! + $1) }
func rangeSum(_ l: Int, _ r: Int) -> Int { prefix[r + 1] - prefix[l] }

// Count subarrays summing to k: prefix sums + a hash map. O(n)
func subarraysSumming(to k: Int, in nums: [Int]) -> Int {
  var seen: [Int: Int] = [0: 1]        // prefix value -> how many times
  var running = 0, total = 0
  for n in nums {
    running += n
    total += seen[running - k, default: 0]
    seen[running, default: 0] += 1
  }
  return total
}

// Difference array: apply many range increments in O(1) each
var diff = [Int](repeating: 0, count: n + 1)
func addRange(_ l: Int, _ r: Int, _ v: Int) { diff[l] += v; diff[r + 1] -= v }
let final = diff.dropLast().reduce(into: [Int]()) { $0.append(($0.last ?? 0) + $1) }

Sort then sweep: intervals

Sorting first turns a hard problem into a linear scan. O(n log n).

struct Interval { var start: Int; var end: Int }

func merge(_ intervals: [Interval]) -> [Interval] {
  let sorted = intervals.sorted { $0.start < $1.start }
  var result: [Interval] = []
  for i in sorted {
    if var last = result.last, i.start <= last.end {
      last.end = max(last.end, i.end)        // overlap -> extend
      result[result.count - 1] = last
    } else {
      result.append(i)
    }
  }
  return result
}

// Minimum meeting rooms = max concurrent overlap.
// Sweep line: +1 at every start, -1 at every end, walk in time order.
func minRooms(_ intervals: [Interval]) -> Int {
  let events = intervals.flatMap { [($0.start, 1), ($0.end, -1)] }
    .sorted { $0.0 == $1.0 ? $0.1 < $1.1 : $0.0 < $1.0 }   // end before start on ties
  var current = 0, best = 0
  for (_, delta) in events { current += delta; best = max(best, current) }
  return best
}

Stack & monotonic stack

Matching, spans, and "next greater". O(n).

func isBalanced(_ s: String) -> Bool {
  let pairs: [Character: Character] = [")": "(", "]": "[", "}": "{"]
  var stack: [Character] = []
  for ch in s {
    if let open = pairs[ch] {
      guard stack.popLast() == open else { return false }
    } else {
      stack.append(ch)
    }
  }
  return stack.isEmpty
}

// Monotonic decreasing stack: index of the next greater element
func nextGreater(_ a: [Int]) -> [Int] {
  var result = [Int](repeating: -1, count: a.count)
  var stack: [Int] = []                       // indices, values decreasing
  for i in a.indices {
    while let top = stack.last, a[top] < a[i] {
      result[top] = i                         // a[i] is the answer for that index
      stack.removeLast()
    }
    stack.append(i)
  }
  return result
}
// Same skeleton solves: daily temperatures, largest rectangle in a histogram,
// trapping rain water, stock span.

Heap & top-K

When you need the k best out of n, not the whole order. O(n log k).

import Collections                        // swift-collections

// k largest without sorting everything
func kLargest(_ nums: [Int], _ k: Int) -> [Int] {
  var heap = Heap<Int>()                  // min-heap side used as the gate
  for n in nums {
    heap.insert(n)
    if heap.count > k { _ = heap.popMin() }
  }
  return heap.unordered.sorted(by: >)
}

// No dependency? A sorted insert into a bounded array is fine for small k:
func topK(_ nums: [Int], _ k: Int) -> [Int] {
  var best: [Int] = []
  for n in nums {
    let idx = best.firstIndex { $0 < n } ?? best.count
    best.insert(n, at: idx)
    if best.count > k { best.removeLast() }
  }
  return best
}

// Streaming median = a max-heap of the low half + a min-heap of the high half,
// rebalanced so their sizes differ by at most one.

Fast & slow pointers (linked lists)

Cycle, middle, k-from-end. O(n) time, O(1) space.

final class Node { var value: Int; var next: Node?; init(_ v: Int) { value = v } }

func hasCycle(_ head: Node?) -> Bool {
  var slow = head, fast = head
  while fast?.next != nil {
    slow = slow?.next
    fast = fast?.next?.next
    if slow === fast { return true }        // identity, not equality
  }
  return false
}

func middle(_ head: Node?) -> Node? {
  var slow = head, fast = head
  while fast?.next != nil { slow = slow?.next; fast = fast?.next?.next }
  return slow
}

func reversed(_ head: Node?) -> Node? {
  var prev: Node?, current = head
  while let node = current {
    current = node.next
    node.next = prev
    prev = node
  }
  return prev
}

Trees: DFS and BFS

Recursion for depth, a queue for breadth.

final class TreeNode { var val: Int; var left, right: TreeNode?; init(_ v: Int) { val = v } }

// DFS in-order (sorted output for a BST)
func inorder(_ node: TreeNode?, into out: inout [Int]) {
  guard let node else { return }
  inorder(node.left, into: &out); out.append(node.val); inorder(node.right, into: &out)
}

// BFS level order - the queue-of-levels trick
func levelOrder(_ root: TreeNode?) -> [[Int]] {
  guard let root else { return [] }
  var levels: [[Int]] = [], queue = [root]
  while !queue.isEmpty {
    levels.append(queue.map(\.val))
    queue = queue.flatMap { [$0.left, $0.right].compactMap { $0 } }
  }
  return levels
}

// Validate a BST: pass the allowed range down, don't just compare with the parent
func isValid(_ n: TreeNode?, _ lo: Int = .min, _ hi: Int = .max) -> Bool {
  guard let n else { return true }
  return lo < n.val && n.val < hi
      && isValid(n.left, lo, n.val) && isValid(n.right, n.val, hi)
}

Graphs: BFS, DFS, topological sort

O(V + E). Adjacency list, visited set, queue or stack.

typealias Graph = [Int: [Int]]

// Shortest path in an UNWEIGHTED graph = BFS
func shortestPath(_ g: Graph, from s: Int, to t: Int) -> Int? {
  var visited: Set = [s], queue = [(s, 0)], head = 0
  while head < queue.count {
    let (node, d) = queue[head]; head += 1        // head index beats removeFirst()
    if node == t { return d }
    for next in g[node, default: []] where visited.insert(next).inserted {
      queue.append((next, d + 1))
    }
  }
  return nil
}

// Kahn's algorithm: topological order, and cycle detection for free
func topologicalOrder(_ g: Graph, nodeCount n: Int) -> [Int]? {
  var indegree = [Int](repeating: 0, count: n)
  for (_, edges) in g { for e in edges { indegree[e] += 1 } }
  var queue = (0..<n).filter { indegree[$0] == 0 }
  var order: [Int] = [], head = 0
  while head < queue.count {
    let node = queue[head]; head += 1
    order.append(node)
    for next in g[node, default: []] {
      indegree[next] -= 1
      if indegree[next] == 0 { queue.append(next) }
    }
  }
  return order.count == n ? order : nil          // nil => there is a cycle
}
// Weighted graph? Dijkstra = BFS with a min-heap keyed by distance.

Grid / matrix traversal

A grid is just a graph with implicit edges.

let directions = [(0, 1), (1, 0), (0, -1), (-1, 0)]

func numberOfIslands(_ grid: [[Character]]) -> Int {
  var grid = grid, count = 0

  func flood(_ r: Int, _ c: Int) {
    guard grid.indices.contains(r), grid[r].indices.contains(c), grid[r][c] == "1"
    else { return }
    grid[r][c] = "0"                              // mark visited in place
    for (dr, dc) in directions { flood(r + dr, c + dc) }
  }

  for r in grid.indices {
    for c in grid[r].indices where grid[r][c] == "1" {
      count += 1
      flood(r, c)
    }
  }
  return count
}
// Watch the stack: on a 1000x1000 grid switch the recursion to an explicit stack.

Backtracking

Choose → explore → un-choose. O(2ⁿ) subsets, O(n!) permutations.

func subsets(_ nums: [Int]) -> [[Int]] {
  var result: [[Int]] = [], current: [Int] = []
  func explore(_ start: Int) {
    result.append(current)
    for i in start..<nums.count {
      current.append(nums[i])       // choose
      explore(i + 1)                // explore
      current.removeLast()          // un-choose
    }
  }
  explore(0)
  return result
}

func permutations(_ nums: [Int]) -> [[Int]] {
  var result: [[Int]] = [], current: [Int] = [], used = [Bool](repeating: false, count: nums.count)
  func explore() {
    if current.count == nums.count { result.append(current); return }
    for i in nums.indices where !used[i] {
      used[i] = true;  current.append(nums[i])
      explore()
      current.removeLast(); used[i] = false
    }
  }
  explore()
  return result
}
// Prune early (bounds, sorted skips, "used" sets) - that is the whole game.

Dynamic programming

Overlapping subproblems + optimal substructure. Memoize, then flatten.

// 1-D: house robber - keep only the last two states, O(n) time, O(1) space
func rob(_ nums: [Int]) -> Int {
  var skip = 0, take = 0
  for n in nums { (skip, take) = (max(skip, take), skip + n) }
  return max(skip, take)
}

// 2-D: edit distance. dp[i][j] = cost to turn the first i of x into the first j of y.
func editDistance(_ a: String, _ b: String) -> Int {
  let x = Array(a), y = Array(b)
  var dp = Array(repeating: Array(repeating: 0, count: y.count + 1), count: x.count + 1)
  for i in 0...x.count { dp[i][0] = i }         // delete everything
  for j in 0...y.count { dp[0][j] = j }         // insert everything
  for i in stride(from: 1, through: x.count, by: 1) {
    for j in stride(from: 1, through: y.count, by: 1) {
      dp[i][j] = x[i - 1] == y[j - 1]
        ? dp[i - 1][j - 1]                                        // free match
        : 1 + min(dp[i - 1][j],      // delete
                  dp[i][j - 1],      // insert
                  dp[i - 1][j - 1])  // substitute
    }
  }
  return dp[x.count][y.count]
}

// Top-down memoization when the recurrence is easier to see:
var memo: [Int: Int] = [:]
func fib(_ n: Int) -> Int {
  if n < 2 { return n }
  if let hit = memo[n] { return hit }
  let value = fib(n - 1) + fib(n - 2)
  memo[n] = value
  return value
}

Union-Find (disjoint set)

Connectivity questions. Near O(1) per op with both optimisations.

struct UnionFind {
  private var parent: [Int]
  private var rank: [Int]
  private(set) var componentCount: Int

  init(_ n: Int) {
    parent = Array(0..<n); rank = Array(repeating: 0, count: n); componentCount = n
  }

  mutating func find(_ x: Int) -> Int {
    if parent[x] != x { parent[x] = find(parent[x]) }   // path compression
    return parent[x]
  }

  @discardableResult
  mutating func union(_ a: Int, _ b: Int) -> Bool {
    let (ra, rb) = (find(a), find(b))
    guard ra != rb else { return false }                 // already connected
    if rank[ra] < rank[rb] { parent[ra] = rb }           // union by rank
    else if rank[ra] > rank[rb] { parent[rb] = ra }
    else { parent[rb] = ra; rank[ra] += 1 }
    componentCount -= 1
    return true
  }
}
// Solves: number of provinces, redundant connection, Kruskal's MST, account merge.

Trie (prefix tree)

Prefix queries in O(length), independent of dictionary size.

final class Trie {
  private final class Node {
    var children: [Character: Node] = [:]
    var isWord = false
  }
  private let root = Node()

  func insert(_ word: String) {
    var node = root
    for ch in word {
      if node.children[ch] == nil { node.children[ch] = Node() }
      node = node.children[ch]!
    }
    node.isWord = true
  }

  private func node(matching prefix: String) -> Node? {
    var node = root
    for ch in prefix {
      guard let next = node.children[ch] else { return nil }
      node = next
    }
    return node
  }

  func contains(_ word: String) -> Bool { node(matching: word)?.isWord ?? false }
  func hasPrefix(_ prefix: String) -> Bool { node(matching: prefix) != nil }
}

Bit manipulation & cyclic sort

Two tricks that turn O(n) space into O(1).

// XOR: every value appears twice except one
let unique = nums.reduce(0, ^)

x & (x - 1)            // clears the lowest set bit
x & -x                 // isolates the lowest set bit
x.nonzeroBitCount      // popcount
(mask >> i) & 1 == 1   // is bit i set
1 << i                 // bit i

// Enumerate every subset of n elements with a bitmask
for mask in 0..<(1 << n) {
  let subset = (0..<n).filter { mask & (1 << $0) != 0 }.map { nums[$0] }
}

// Cyclic sort: values are 1...n, so put each value at its own index
func findMissing(_ nums: inout [Int]) -> Int {
  var i = 0
  while i < nums.count {
    let target = nums[i] - 1
    if target >= 0, target < nums.count, nums[i] != nums[target] {
      nums.swapAt(i, target)
    } else {
      i += 1
    }
  }
  return (nums.indices.first { nums[$0] != $0 + 1 } ?? nums.count) + 1
}

Working a problem out loud

The process is graded as much as the answer.

  • Restate + clarify first: input size, value ranges, duplicates, sorted?, can I mutate the input, what about empty?
  • Say the brute force and its complexity — it proves you understand the problem and gives you a baseline.
  • Name the pattern you're reaching for and *why* the trigger fits.
  • Write it, then state time and space without being asked.
  • Dry-run one small example by hand, then call out the edge cases: empty, one element, all equal, negatives, overflow.
  • Common speed-ups: sort first (O(n log n) is usually free), trade space for time with a hash map, use two pointers instead of a nested loop, precompute prefixes.
  • Don't silently rewrite. If you change approach, say what you learned that made you change.
  • In Swift, don't use array.removeFirst() in a BFS loop — it's O(n). Use a head index or Deque.

Streaming & Time-Series Patterns

Unbounded data, bounded memory: windows, sketches, rate limits and backpressure.

Windowing a stream

Almost every streaming requirement is one of these three windows plus a lateness policy.

WINDOWING A STREAM OVER TIMEeventstumblingfixed, disjointW1W2W3W4slidingsize 40, step 20overlapping - every event lands in 2 windowssessiongap > 6 closes itsession 1session 2session 3session 4watermarkwatermark: "no more events older than this"late eventdrop it, or re-open the windowtumbling = counters & billingsliding = rolling averages & alertssession = user activity burstsalways decide what happens to late data

The constraints that define the field

  • You see each item once, in order, and you cannot store them all — so answers are incremental, approximate, or windowed.
  • Bounded memory is the whole game: O(1) or O(window), never O(stream).
  • Time is two things: *event time* (when it happened) and *processing time* (when you saw it). They diverge, and that's what watermarks are for.
  • Exact vs approximate — a sketch trades a few percent of accuracy for orders of magnitude of memory. Say the error bound out loud.
  • In Swift, a stream is an AsyncSequence (pull, backpressure-aware) or a Combine Publisher (push, needs explicit buffering).

1 · Ring buffer — fixed-size sliding window

O(1) per event, O(k) memory. The workhorse.

struct RingBuffer<Element> {
  private var storage: [Element?]
  private var head = 0
  private(set) var count = 0
  let capacity: Int

  init(capacity: Int) {
    self.capacity = capacity
    storage = Array(repeating: nil, count: capacity)
  }

  /// Appends and returns the element that was evicted, if any.
  @discardableResult
  mutating func append(_ element: Element) -> Element? {
    let evicted = count == capacity ? storage[head] : nil
    storage[head] = element
    head = (head + 1) % capacity
    count = Swift.min(count + 1, capacity)
    return evicted
  }

  var elements: [Element] {
    guard count > 0 else { return [] }
    let start = (head - count + capacity) % capacity
    return (0..<count).compactMap { storage[(start + $0) % capacity] }
  }
}

// Rolling sum in O(1): add the new value, subtract the evicted one.
var sum = 0.0
if let old = window.append(value) { sum -= old }
sum += value
let movingAverage = sum / Double(window.count)

2 · Running statistics — Welford & EMA

Mean, variance and a smoothed level in O(1) memory.

/// Numerically stable online mean/variance. Never sum-of-squares: it loses precision.
struct RunningStats {
  private(set) var count = 0
  private(set) var mean = 0.0
  private var m2 = 0.0

  mutating func add(_ x: Double) {
    count += 1
    let delta = x - mean
    mean += delta / Double(count)
    m2 += delta * (x - mean)
  }

  var variance: Double { count > 1 ? m2 / Double(count - 1) : 0 }
  var standardDeviation: Double { variance.squareRoot() }
  func zScore(_ x: Double) -> Double {
    standardDeviation > 0 ? (x - mean) / standardDeviation : 0
  }
}

/// Exponential moving average: no window, no history, one multiply.
struct EMA {
  private(set) var value: Double?
  let alpha: Double                       // 2 / (period + 1)

  mutating func add(_ x: Double) -> Double {
    value = value.map { alpha * x + (1 - alpha) * $0 } ?? x
    return value!
  }
}
// Anomaly detection for free: flag when |zScore| > 3 against a rolling RunningStats.

3 · Reservoir sampling

A uniform sample of k items from a stream of unknown length. O(k) memory.

struct Reservoir<Element> {
  private(set) var sample: [Element] = []
  private var seen = 0
  let capacity: Int

  init(capacity: Int) { self.capacity = capacity }

  mutating func add(_ element: Element) {
    seen += 1
    if sample.count < capacity {
      sample.append(element)
    } else {
      // Keep with probability k/n — every item ends up equally likely.
      let j = Int.random(in: 0..<seen)
      if j < capacity { sample[j] = element }
    }
  }
}
// Use it for: sampling logs, A/B assignment audits, picking N events to upload.

4 · Count-Min Sketch — approximate frequencies

O(width × depth) memory for any number of keys. Never undercounts.

struct CountMinSketch {
  private var table: [[Int]]
  private let seeds: [UInt64]
  private let width: Int

  /// width ~ e/epsilon, depth ~ ln(1/delta). 2048 x 5 is a good default.
  init(width: Int = 2048, depth: Int = 5) {
    self.width = width
    table = Array(repeating: Array(repeating: 0, count: width), count: depth)
    seeds = (0..<depth).map { UInt64($0) &* 0x9E37_79B9_7F4A_7C15 &+ 1 }
  }

  private func index(_ key: some Hashable, _ row: Int) -> Int {
    var hasher = Hasher()
    hasher.combine(seeds[row])
    hasher.combine(key)
    let h = hasher.finalize()
    return (h % width + width) % width       // safe for negative hashes
  }

  mutating func add(_ key: some Hashable, count: Int = 1) {
    for row in table.indices { table[row][index(key, row)] += count }
  }

  /// Never underestimates; overestimates with probability delta.
  func estimate(_ key: some Hashable) -> Int {
    table.indices.map { table[$0][index(key, $0)] }.min() ?? 0
  }
}

5 · Bloom filter — approximate membership

"Definitely not present" or "probably present", in a few bits per key.

import Foundation

struct BloomFilter {
  private var bits: [UInt64]
  private let bitCount: Int
  private let hashCount: Int

  init(expectedCount: Int, falsePositiveRate: Double = 0.01) {
    // m = -n·ln(p) / (ln2)^2      k = (m/n)·ln2
    let m = Int(-Double(expectedCount) * log(falsePositiveRate) / (log(2) * log(2)))
    bitCount = max(m, 64)
    hashCount = max(Int(Double(bitCount) / Double(expectedCount) * log(2)), 1)
    bits = Array(repeating: 0, count: (bitCount + 63) / 64)
  }

  private func positions(_ key: some Hashable) -> [Int] {
    (0..<hashCount).map { i in
      var hasher = Hasher()
      hasher.combine(i)
      hasher.combine(key)
      let h = hasher.finalize()
      return (h % bitCount + bitCount) % bitCount
    }
  }

  mutating func insert(_ key: some Hashable) {
    for p in positions(key) { bits[p / 64] |= (1 << UInt64(p % 64)) }
  }

  /// false  => definitely absent.   true => probably present.
  func mayContain(_ key: some Hashable) -> Bool {
    positions(key).allSatisfy { bits[$0 / 64] & (1 << UInt64($0 % 64)) != 0 }
  }
}
// Classic use: "have I already synced this event id?" without keeping every id.

6 · Heavy hitters — Misra–Gries / Space-Saving

Top-K over a stream in O(k) memory.

/// Tracks the k most frequent keys approximately. Anything appearing
/// more than n/(k+1) times is guaranteed to survive.
struct HeavyHitters<Key: Hashable> {
  private var counters: [Key: Int] = [:]
  let capacity: Int

  init(capacity: Int) { self.capacity = capacity }

  mutating func add(_ key: Key) {
    if counters[key] != nil {
      counters[key]! += 1
    } else if counters.count < capacity {
      counters[key] = 1
    } else {
      // Decrement everything; whatever hits zero is evicted.
      for k in counters.keys {
        counters[k]! -= 1
        if counters[k]! == 0 { counters.removeValue(forKey: k) }
      }
    }
  }

  var top: [(key: Key, count: Int)] {
    counters.sorted { $0.value > $1.value }.map { (key: $0.key, count: $0.value) }
  }
}

7 · Streaming quantiles

p50/p95/p99 without storing the stream.

/// The P-square idea, simplified: keep a bounded, sorted sample and
/// interpolate. Good enough for client-side latency telemetry.
struct QuantileEstimator {
  private var reservoir: Reservoir<Double>

  init(sampleSize: Int = 1000) { reservoir = Reservoir(capacity: sampleSize) }

  mutating func add(_ x: Double) { reservoir.add(x) }

  func quantile(_ q: Double) -> Double? {
    let sorted = reservoir.sample.sorted()
    guard !sorted.isEmpty else { return nil }
    let position = q * Double(sorted.count - 1)
    let lower = Int(position.rounded(.down)), upper = Int(position.rounded(.up))
    let weight = position - Double(lower)
    return sorted[lower] * (1 - weight) + sorted[upper] * weight
  }
}
let p95 = estimator.quantile(0.95)
// Report percentiles, not averages: one 4-second request hides behind a 200 ms mean.

8 · Rate limiting

Three algorithms, three different fairness properties.

Token bucket — allows bursts
actor TokenBucket {
  private var tokens: Double
  private var lastRefill = Date.now
  private let capacity: Double
  private let refillPerSecond: Double

  init(capacity: Double, refillPerSecond: Double) {
    self.capacity = capacity
    self.tokens = capacity
    self.refillPerSecond = refillPerSecond
  }

  func allow(cost: Double = 1, now: Date = .now) -> Bool {
    let elapsed = now.timeIntervalSince(lastRefill)
    lastRefill = now
    tokens = min(capacity, tokens + elapsed * refillPerSecond)
    guard tokens >= cost else { return false }
    tokens -= cost
    return true
  }
}

// capacity = how big a burst you tolerate
// refillPerSecond = the sustained rate
Sliding window log — exact, more memory
actor SlidingWindowLimiter {
  private var timestamps: [Date] = []
  private let limit: Int
  private let window: TimeInterval

  init(limit: Int, window: TimeInterval) {
    self.limit = limit; self.window = window
  }

  func allow(now: Date = .now) -> Bool {
    let cutoff = now.addingTimeInterval(-window)
    timestamps.removeAll { $0 < cutoff }     // evict what left the window
    guard timestamps.count < limit else { return false }
    timestamps.append(now)
    return true
  }
}

// Leaky bucket = a fixed-rate queue drain: perfectly
// smooth output, no bursts, requests may wait.

9 · Debounce, throttle & sample over an AsyncSequence

The client-side versions of rate control.

// Debounce: emit only after the source has been quiet for `interval`.
func debounce<T: Sendable>(_ source: AsyncStream<T>, for interval: Duration)
  -> AsyncStream<T> {
  AsyncStream { continuation in
    let task = Task {
      var pending: Task<Void, Never>?
      for await value in source {
        pending?.cancel()                       // a newer value cancels the old timer
        pending = Task {
          try? await Task.sleep(for: interval)
          guard !Task.isCancelled else { return }
          continuation.yield(value)
        }
      }
      await pending?.value
      continuation.finish()
    }
    continuation.onTermination = { _ in task.cancel() }
  }
}

// Throttle: at most one value per interval (keep the FIRST).
// Sample: whatever the latest value is, every interval (keep the LAST).
// Combine gives all three for free: .debounce, .throttle(latest:), and
// Timer.publish + .withLatestFrom-style zip.

10 · Backpressure & buffering policy

When production outruns consumption, you must choose what to lose.

// AsyncStream lets you state the policy up front.
let (stream, continuation) = AsyncStream<Sample>.makeStream(
  bufferingPolicy: .bufferingNewest(100)      // drop the oldest under pressure
)
// .unbounded          -> never drops, can grow without limit (memory risk)
// .bufferingOldest(n) -> keep the first n, drop new arrivals
// .bufferingNewest(n) -> keep the last n  (right for sensor/telemetry data)

switch continuation.yield(sample) {
case .enqueued(let remaining): if remaining < 10 { reduceSamplingRate() }
case .dropped:                 droppedCount += 1
case .terminated:              stopSensor()
@unknown default:              break
}

// Combine: .buffer(size:prefetch:whenFull:) with .dropOldest / .dropNewest / .customError
// Consumer-side relief: batch, or coalesce to the latest value before rendering.

11 · Batching, chunking and coalescing

Amortise per-item cost — the cheapest streaming optimisation there is.

/// Emit either when `size` items have accumulated or when `interval` elapses,
/// whichever comes first. The standard telemetry-upload shape.
func batched<T: Sendable>(
  _ source: AsyncStream<T>, size: Int, every interval: Duration
) -> AsyncStream<[T]> {
  AsyncStream { continuation in
    let task = Task {
      var buffer: [T] = []
      var lastFlush = ContinuousClock.now

      for await element in source {
        buffer.append(element)
        let elapsed = ContinuousClock.now - lastFlush
        if buffer.count >= size || elapsed >= interval {
          continuation.yield(buffer)
          buffer.removeAll(keepingCapacity: true)
          lastFlush = .now
        }
      }
      if !buffer.isEmpty { continuation.yield(buffer) }   // flush the tail
      continuation.finish()
    }
    continuation.onTermination = { _ in task.cancel() }
  }
}

// swift-async-algorithms gives you these directly:
// .chunks(ofCount:), .chunked(by:), .debounce(for:), .throttle(for:),
// .merge(), .combineLatest(), .removeDuplicates()

12 · Deduplication with a TTL

Streams re-deliver. Idempotency is your problem, not the transport's.

struct RecentSet<Key: Hashable> {
  private var seen: [Key: Date] = [:]
  private var order: [Key] = []
  let ttl: TimeInterval
  let capacity: Int

  /// Returns true the first time a key is seen inside the window.
  mutating func insertIfNew(_ key: Key, now: Date = .now) -> Bool {
    evict(before: now.addingTimeInterval(-ttl))
    guard seen[key] == nil else { return false }
    seen[key] = now
    order.append(key)
    if order.count > capacity { seen.removeValue(forKey: order.removeFirst()) }
    return true
  }

  private mutating func evict(before cutoff: Date) {
    while let oldest = order.first, let at = seen[oldest], at < cutoff {
      order.removeFirst()
      seen.removeValue(forKey: oldest)
    }
  }
}
// At scale, swap the dictionary for a Bloom filter and accept a small false-positive rate.

13 · Merging ordered streams & change detection

// Fan-in: interleave k streams, finishing when all of them do.
// NOTE: this preserves arrival order, NOT event-time order.
func merge(_ streams: [AsyncStream<Event>]) -> AsyncStream<Event> {
  AsyncStream { continuation in
    let task = Task {
      await withTaskGroup(of: Void.self) { group in
        for s in streams {
          group.addTask { for await e in s { continuation.yield(e) } }
        }
      }
      continuation.finish()
    }
    continuation.onTermination = { _ in task.cancel() }
  }
}
// For true event-time order you must buffer until the watermark passes,
// then drain the buffer sorted by timestamp. Ordering always costs latency.

// Change detection with hysteresis: two thresholds stop the flapping
// you get from a single one.
struct Hysteresis {
  let enter: Double, exit: Double
  private(set) var isOn = false
  mutating func update(_ x: Double) -> Bool? {
    if !isOn, x > enter { isOn = true;  return true }
    if isOn,  x < exit  { isOn = false; return false }
    return nil                       // no transition — emit nothing
  }
}

// UI diffing: let the framework compute the minimal change set.
let diff = newItems.difference(from: oldItems)   // CollectionDifference
tableView.performBatchUpdates { apply(diff) }

Sketch selection

What to reach for when exact is too expensive.

QuestionStructureMemoryError
How many distinct users?HyperLogLog~12 KB for billions~2% standard error
How often did key X appear?Count-Min Sketchwidth × depth intsover-counts only
Have I seen X before?Bloom filter~10 bits/key at 1%false positives only
What are the top K keys?Misra–Gries / Space-SavingO(k)misses items below n/(k+1)
What's the p95 latency?t-digest / P² / reservoirO(sample)a few % near the tail
A uniform sample of n itemsReservoir samplingO(k)exact and unbiased
A rolling averageRing buffer or EMAO(k) or O(1)exact / smoothed
Exact counts, bounded keysDictionaryO(distinct)exact

Streaming pitfalls

  • Unbounded buffers. AsyncStream defaults to .unbounded — always state a policy for hot sources.
  • Recomputing the whole window per event (O(n·k) instead of O(n)) — slide, don't recompute.
  • Using processing time when the business asks about event time. Sensor and network delays reorder everything.
  • Assuming exactly-once delivery. Design for at-least-once and make the consumer idempotent.
  • Summing squares for variance — it loses precision. Use Welford.
  • Blocking the producer to do work per event. Hand off to a bounded queue and batch.
  • Decide the lateness policy explicitly: drop, hold with a watermark, or re-open the window and re-emit.
  • Instrument the stream itself: input rate, drop count, queue depth, end-to-end lag.

Files, Text & Atomic I/O

Where to put data, how to write it so a crash can't corrupt it, and how to read a file bigger than memory.

  • ios
  • ipados
  • macos
  • tvos
  • watchos
  • visionos
  • Foundation

Sources: FileManager — Apple Developer Documentation

The atomic write

Temp file, flush, rename. Every safe writer on every OS is this shape.

THE ATOMIC WRITE, STEP BY STEP1. Write to tempa NEW file, in the SAMEdirectory as the target(rename needs one volume)2. Flushfsync, or F_FULLFSYNCfor real durabilitybytes leave the page cache3. Rename overrename(2) / replaceItemAtATOMIC on one volume:the swap has no midpoint4. fsync the dirmakes the rename itselfsurvive power lossusually skipped - know that5. Doneevery reader saw eitherthe whole old file orthe whole new oneIF THE PROCESS DIES HERE...old file untouched - a stray temp file is the only damageold OR new, never a mixcontent is correct; on power loss the rename itself may be lostWhat Foundation already does for youtry data.write(to: url, options: .atomic)-> temp file + rename. It does NOT fsync,so it is crash-safe, not power-loss-safe.The classic mistakelet h = FileHandle(forWritingTo: url)h.truncateFile(atOffset: 0) // <- destroys theh.write(newData) // only good copy

Read strategy by file size

The API you choose is a memory decision. Pick it from the size, not from habit.

PICK THE STRATEGY FROM THE SIZEup to ~1 MBLoad it wholeString(contentsOf: url, encoding: .utf8)Data(contentsOf: url)simplest, one syscall,peak memory = file size~1 - 100 MBMemory-map itData(contentsOf: url, options: .mappedIfSafe)the kernel pages it in ondemand; random access isfree, no copy100 MB - GBsStream itfor try await line in url.lineshandle.read(upToCount: 1 << 16)one pass, constant memory,cannot random-accessbigger than RAMChunk, sort, mergesplit into sorted runs ->k-way merge with a heapexternal sort: O(n log n) time,O(k * chunk) memoryPEAK MEMORY, RELATIVE= the whole file= pages touched= one chunk= k chunksfile I/O blocks - never on the main actormmap still page-faults on a slow diskone pass? stream. random access? map.test on a real-sized file

Where things belong

Getting this wrong gets apps rejected and data deleted.

DirectoryModern accessorBacked up?Use for
DocumentsURL.documentsDirectoryyesuser-created content they'd miss — and can see in Files
Application SupportURL.applicationSupportDirectoryyesapp-generated data the user never browses (databases, caches you must keep)
CachesURL.cachesDirectorynore-downloadable data. The system deletes this under pressure
tmpURL.temporaryDirectorynoscratch files for the current operation; purged aggressively
App Group containerFileManager.default.containerURL(forSecurityApplicationGroupIdentifier:)yessharing with widgets and extensions — needs file coordination
Bundle (read-only)Bundle.main.url(forResource:withExtension:)n/ashipped resources. Never writable

URLs, not paths

`URL` is the modern currency; string paths lose information.

// iOS 16+ / macOS 13+ — the terse accessors
let docs = URL.documentsDirectory
let file = docs.appending(path: "notes/today.txt")           // percent-encodes correctly

// Older, and still the only way to ask for creation as a side effect
let support = try FileManager.default.url(for: .applicationSupportDirectory,
                                          in: .userDomainMask,
                                          appropriateFor: nil,
                                          create: true)      // creates it if missing

// Make the parent directory yourself when you build paths by hand
try FileManager.default.createDirectory(at: file.deletingLastPathComponent(),
                                        withIntermediateDirectories: true)

file.lastPathComponent      // "today.txt"
file.deletingPathExtension().lastPathComponent   // "today"
file.pathExtension          // "txt"
file.path(percentEncoded: false)                 // only when a C API demands a path

// Never build a path with string interpolation: "\(docs)/notes/\(name).txt"
// breaks on spaces, unicode, and names containing "/".

Reading a small text file

Do
// Encoding is required — the inferring overload is deprecated.
let text = try String(contentsOf: url, encoding: .utf8)

// Bytes, when it isn't text
let data = try Data(contentsOf: url)

// A malformed-UTF8-tolerant decode
guard let text = String(data: data, encoding: .utf8) else {
  throw FileError.notUTF8(url)
}

// Reading is blocking: keep it off the main actor
nonisolated func load(_ url: URL) throws -> String {
  try String(contentsOf: url, encoding: .utf8)
}
Don't
// TOCTOU race: the answer is already stale
if FileManager.default.fileExists(atPath: url.path) {
  let text = try String(contentsOf: url, encoding: .utf8)
}
// Just try, and handle the error:
do { text = try String(contentsOf: url, encoding: .utf8) }
catch CocoaError.fileNoSuchFile { text = "" }

// Deprecated / lossy
String(contentsOfFile: path)          // no encoding, deprecated
try! String(contentsOf: url)          // crashes on a missing file

// Blocking the main actor on a 50 MB file
@MainActor func load() { text = try! String(contentsOf: url) }

Writing atomically — the one-liner and what it hides

// Data: .atomic writes to a temp file in the same directory, then renames.
try data.write(to: url, options: [.atomic])

// String: same guarantee, different spelling
try text.write(to: url, atomically: true, encoding: .utf8)

// Fail instead of clobbering an existing file
try data.write(to: url, options: [.withoutOverwriting])   // throws if it exists

// Codable round trip, atomically
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]        // stable bytes -> smaller diffs, cheap dedupe
try encoder.encode(model).write(to: url, options: [.atomic])

// What .atomic guarantees:  a reader never sees a half-written file.
// What it does NOT guarantee: that the bytes survive a power cut. That needs fsync.

// On iOS, an atomic write can FAIL while the device is locked if the file has a
// data-protection class, because the temp file inherits it. For background writes:
try data.write(to: url, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication])

Real durability: fsync and F_FULLFSYNC

When "the user pulled the battery" has to be survivable.

/// Writes atomically AND flushes to the physical medium.
func writeDurably(_ data: Data, to url: URL) throws {
  let directory = url.deletingLastPathComponent()
  let temp = directory.appending(path: ".\(UUID().uuidString).tmp")

  // 1. write the new bytes to a sibling temp file
  try data.write(to: temp)

  // 2. flush them past the page cache. On Apple platforms plain fsync only
  //    reaches the drive's write cache; F_FULLFSYNC goes all the way down.
  let handle = try FileHandle(forWritingTo: temp)
  guard fcntl(handle.fileDescriptor, F_FULLFSYNC) != -1 else {
    try? FileManager.default.removeItem(at: temp)
    throw POSIXError(.EIO)
  }
  try handle.close()

  // 3. atomic swap. replaceItemAt handles the same-volume + backup dance,
  //    and preserves the destination's metadata.
  _ = try FileManager.default.replaceItemAt(url, withItemAt: temp)
}

// Cost: F_FULLFSYNC is SLOW (tens of ms). Use it for a ledger or a journal,
// not for every keystroke of an autosave.

Appending: log files and journals

Append-only is the cheapest durable structure there is.

/// One writer, serialized by an actor. FileHandle is not Sendable.
actor AppendLog {
  private let handle: FileHandle

  init(url: URL) throws {
    if !FileManager.default.fileExists(atPath: url.path(percentEncoded: false)) {
      FileManager.default.createFile(atPath: url.path(percentEncoded: false), contents: nil)
    }
    handle = try FileHandle(forWritingTo: url)
    try handle.seekToEnd()
  }

  func append(_ line: String) throws {
    try handle.write(contentsOf: Data((line + "\n").utf8))
  }

  /// Call on a cadence, not per line — each one is a syscall.
  func flush() throws { try handle.synchronize() }

  deinit { try? handle.close() }
}

// Compaction: when the log outgrows its budget, fold it into a snapshot
// and atomically replace both. Never edit a log in place.
func compact() async throws {
  let snapshot = try await replay()                     // fold the log
  try encode(snapshot).write(to: snapshotURL, options: .atomic)
  try Data().write(to: logURL, options: .atomic)        // truncate atomically
}

Streaming a large text file

Constant memory, one pass. The default for anything over ~100 MB.

// Async line-by-line, straight off the URL. Handles buffering for you.
for try await line in url.lines {
  process(line)                       // memory stays O(longest line)
}

// JSON Lines: one independent object per line — the format to reach for
// when a single JSON array would not fit in memory.
let decoder = JSONDecoder()
var total = 0
for try await line in url.lines where !line.isEmpty {
  let event = try decoder.decode(Event.self, from: Data(line.utf8))
  total += event.amount
}

// Raw byte chunks, when the data isn't line-delimited
let handle = try FileHandle(forReadingFrom: url)
defer { try? handle.close() }
while let chunk = try handle.read(upToCount: 1 << 16), !chunk.isEmpty {   // 64 KB
  try consume(chunk)
}

// Memory-mapped, for random access without loading everything
let mapped = try Data(contentsOf: url, options: [.mappedIfSafe])
let slice = mapped[offset ..< (offset + length)]     // pages fault in on demand
// .mappedIfSafe declines to map network volumes; .alwaysMapped insists (and can trap).

Streaming a large write

Buffer in memory, syscall in batches.

/// Writes an arbitrarily long sequence without ever holding it all.
func export(_ rows: some Sequence<Row>, to url: URL) throws {
  let temp = url.deletingLastPathComponent().appending(path: ".export.tmp")
  FileManager.default.createFile(atPath: temp.path(percentEncoded: false), contents: nil)
  let handle = try FileHandle(forWritingTo: temp)
  defer { try? handle.close() }

  var buffer = Data()
  buffer.reserveCapacity(1 << 18)               // 256 KB

  for row in rows {
    buffer.append(contentsOf: row.csvLine.utf8)
    if buffer.count >= 1 << 18 {                // amortise the syscall
      try handle.write(contentsOf: buffer)
      buffer.removeAll(keepingCapacity: true)   // keep the allocation
    }
  }
  if !buffer.isEmpty { try handle.write(contentsOf: buffer) }
  try handle.close()

  _ = try FileManager.default.replaceItemAt(url, withItemAt: temp)   // publish atomically
}

// Rule: one write(contentsOf:) per 64-256 KB. Per-row writes are 1000x slower.

Algorithm — external merge sort

Sort a file larger than RAM: split into sorted runs, then k-way merge.

import Collections

/// Phase 1: read `chunkSize` lines at a time, sort in memory, spill to a run file.
func makeRuns(from url: URL, chunkSize: Int, into dir: URL) async throws -> [URL] {
  var runs: [URL] = []
  var buffer: [String] = []
  buffer.reserveCapacity(chunkSize)

  func spill() throws {
    guard !buffer.isEmpty else { return }
    let run = dir.appending(path: "run-\(runs.count).txt")
    try buffer.sorted().joined(separator: "\n").write(to: run, atomically: true, encoding: .utf8)
    runs.append(run)
    buffer.removeAll(keepingCapacity: true)
  }

  for try await line in url.lines {
    buffer.append(line)
    if buffer.count == chunkSize { try spill() }
  }
  try spill()
  return runs
}

/// Phase 2: merge every run with a k-element heap. O(n log k), memory O(k).
func merge(_ runs: [URL], into out: URL) async throws {
  struct Head: Comparable {
    let line: String
    let run: Int
    static func < (a: Self, b: Self) -> Bool { a.line < b.line }
  }

  var iterators = try runs.map { $0.lines.makeAsyncIterator() }
  var heap = Heap<Head>()
  for i in iterators.indices {
    if let first = try await iterators[i].next() { heap.insert(Head(line: first, run: i)) }
  }

  let temp = out.deletingLastPathComponent().appending(path: ".merge.tmp")
  FileManager.default.createFile(atPath: temp.path(percentEncoded: false), contents: nil)
  let handle = try FileHandle(forWritingTo: temp)
  var buffer = Data()

  while let smallest = heap.popMin() {                  // always the global minimum
    buffer.append(contentsOf: (smallest.line + "\n").utf8)
    if buffer.count > 1 << 18 { try handle.write(contentsOf: buffer); buffer.removeAll(keepingCapacity: true) }
    if let next = try await iterators[smallest.run].next() {
      heap.insert(Head(line: next, run: smallest.run))
    }
  }
  try handle.write(contentsOf: buffer)
  try handle.close()
  _ = try FileManager.default.replaceItemAt(out, withItemAt: temp)
}

Algorithm — an offset index for random access

Build once, then seek to any line in O(1).

/// Byte offset of the start of every line. ~8 bytes of index per line.
func buildIndex(for url: URL) throws -> [UInt64] {
  var offsets: [UInt64] = [0]
  let handle = try FileHandle(forReadingFrom: url)
  defer { try? handle.close() }
  var position: UInt64 = 0

  while let chunk = try handle.read(upToCount: 1 << 16), !chunk.isEmpty {
    for (i, byte) in chunk.enumerated() where byte == UInt8(ascii: "\n") {
      offsets.append(position + UInt64(i) + 1)
    }
    position += UInt64(chunk.count)
  }
  offsets.removeLast()                    // the trailing newline starts nothing
  return offsets
}

func line(_ n: Int, in url: URL, index: [UInt64]) throws -> String {
  let handle = try FileHandle(forReadingFrom: url)
  defer { try? handle.close() }
  try handle.seek(toOffset: index[n])
  let length = Int((n + 1 < index.count ? index[n + 1] : .max) - index[n])
  let data = try handle.read(upToCount: min(length, 1 << 20)) ?? Data()
  return String(decoding: data, as: UTF8.self).trimmingCharacters(in: .newlines)
}

// Fixed-width records? Skip the index entirely: offset = n * recordSize,
// and you can BINARY SEARCH a sorted file in O(log n) seeks.

Algorithm — tail, dedupe, sample, checksum

Four one-pass operations you'll actually be asked for.

// tail -n: seek backwards in blocks until you've counted enough newlines
func lastLines(_ count: Int, of url: URL) throws -> [String] {
  let handle = try FileHandle(forReadingFrom: url)
  defer { try? handle.close() }
  let size = try handle.seekToEnd()
  var offset = size, found = 0, blob = Data()

  while offset > 0, found <= count {
    let block = UInt64(min(1 << 16, Int(offset)))
    offset -= block
    try handle.seek(toOffset: offset)
    let chunk = try handle.read(upToCount: Int(block)) ?? Data()
    found += chunk.filter { $0 == UInt8(ascii: "\n") }.count
    blob = chunk + blob
  }
  return String(decoding: blob, as: UTF8.self)
    .split(separator: "\n", omittingEmptySubsequences: true).suffix(count).map(String.init)
}

// Dedupe a huge file without holding every line: store 8-byte hashes, not strings.
var seen = Set<Int>()
for try await line in url.lines where seen.insert(line.hashValue).inserted {
  try out.append(line)          // collisions are possible - use SHA256 if exactness matters
}

// Uniform sample of k lines from a file of unknown length (reservoir sampling)
var sample: [String] = []; var n = 0
for try await line in url.lines {
  n += 1
  if sample.count < k { sample.append(line) }
  else { let j = Int.random(in: 0..<n); if j < k { sample[j] = line } }
}

// Streaming SHA-256: never load the file to hash it
import CryptoKit
var hasher = SHA256()
let handle = try FileHandle(forReadingFrom: url)
while let chunk = try handle.read(upToCount: 1 << 20), !chunk.isEmpty { hasher.update(data: chunk) }
let digest = hasher.finalize().map { String(format: "%02x", $0) }.joined()

Enumerating directories

The lazy enumerator with prefetched keys is the only scalable option.

let keys: [URLResourceKey] = [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey]

guard let walker = FileManager.default.enumerator(
  at: root,
  includingPropertiesForKeys: keys,             // batch-fetched: one stat per file, not three
  options: [.skipsHiddenFiles, .skipsPackageDescendants],
  errorHandler: { url, error in
    Logger.files.error("Skipping \(url.lastPathComponent): \(error.localizedDescription)")
    return true                                 // keep walking
  }
) else { return }

var totalBytes = 0
for case let url as URL in walker {
  let values = try url.resourceValues(forKeys: Set(keys))
  if values.isDirectory == true {
    if url.lastPathComponent == "node_modules" { walker.skipDescendants() }
    continue
  }
  totalBytes += values.fileSize ?? 0
}

// contentsOfDirectory(at:) loads the whole listing into an array — fine for a
// handful of files, quadratic pain for a deep tree.

Attributes, backup exclusion & protection

// Read metadata
let values = try url.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey,
                                              .totalFileAllocatedSizeKey])
let bytes = values.fileSize ?? 0
let modified = values.contentModificationDate

// Keep a big regenerable cache out of iCloud/iTunes backups
var writable = URLResourceValues()
writable.isExcludedFromBackup = true
try url.setResourceValues(&writable)          // note: mutating, takes inout

// Data protection: the file is unreadable while the device is locked
try FileManager.default.setAttributes(
  [.protectionKey: FileProtectionType.completeUnlessOpen],
  ofItemAtPath: url.path(percentEncoded: false)
)
// .complete                                -> locked device = unreadable
// .completeUnlessOpen                      -> can keep writing a file opened while unlocked
// .completeUntilFirstUserAuthentication    -> readable after the first unlock since boot
// .none                                    -> always readable (background work, low sensitivity)

// Free space before a big download
let capacity = try URL.documentsDirectory
  .resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey])
  .volumeAvailableCapacityForImportantUsage

Shared containers & coordinated access

The moment a widget or extension reads the same file, you need coordination.

// App Group container — shared with widgets, share sheets, Siri intents
guard let shared = FileManager.default
  .containerURL(forSecurityApplicationGroupIdentifier: "group.com.example.app")
else { throw FileError.noContainer }

// Two processes = two writers. NSFileCoordinator serialises them.
let coordinator = NSFileCoordinator(filePresenter: nil)
var coordinationError: NSError?
coordinator.coordinate(writingItemAt: url, options: .forReplacing,
                       error: &coordinationError) { writableURL in
  try? data.write(to: writableURL, options: .atomic)
}
if let coordinationError { throw coordinationError }

// Reading side
coordinator.coordinate(readingItemAt: url, options: [], error: &coordinationError) { readableURL in
  text = try? String(contentsOf: readableURL, encoding: .utf8)
}

// User-picked files (document picker, drag & drop) are security-scoped:
guard url.startAccessingSecurityScopedResource() else { throw FileError.denied }
defer { url.stopAccessingSecurityScopedResource() }
// To keep the permission across launches, persist a bookmark:
let bookmark = try url.bookmarkData(options: .minimalBookmark)
var stale = false
let restored = try URL(resolvingBookmarkData: bookmark, bookmarkDataIsStale: &stale)

Concurrency: files are blocking work

// Foundation's file APIs are synchronous and block the calling thread.
// Never call them from @MainActor code, and never from an actor that other
// work needs to enter quickly.

// Good: nonisolated so it runs off the caller's actor
struct FileStore: Sendable {
  let root: URL

  nonisolated func read(_ name: String) throws -> Data {
    try Data(contentsOf: root.appending(path: name))
  }
}

// Good: one actor owning one file, so writes can't interleave
actor DocumentStore {
  private let url: URL
  private var cached: Document?

  func save(_ document: Document) throws {
    try JSONEncoder().encode(document).write(to: url, options: .atomic)
    cached = document
  }
}

// FileHandle, FileManager and NSFileCoordinator are NOT Sendable.
// Create and use them inside one isolation domain; pass URLs (which are Sendable) around.

// Parallel reads of independent files are a genuine win on SSDs:
let contents = try await withThrowingTaskGroup(of: (URL, Data).self) { group in
  for url in urls { group.addTask { (url, try Data(contentsOf: url)) } }
  return try await group.reduce(into: [:]) { $0[$1.0] = $1.1 }
}

Error handling

Match on the specific error instead of checking first.

SituationError you'll catch
File missingCocoaError.fileNoSuchFile / POSIXError(.ENOENT)
No permissionCocoaError.fileReadNoPermission, .fileWriteNoPermission
Disk fullCocoaError.fileWriteOutOfSpace
Read-only volumeCocoaError.fileWriteVolumeReadOnly
Bad text encodingCocoaError.fileReadInapplicableStringEncoding
Destination exists (.withoutOverwriting)CocoaError.fileWriteFileExists
Device locked (data protection)POSIXError(.EPERM) on open
Any of the above, genericallycatch let error as CocoaError { error.code }

Rules that prevent data loss

  • Every user-visible write is atomic. .atomic or replaceItemAt, no exceptions.
  • Write to a sibling temp file, never to another volume — rename is only atomic within one filesystem.
  • Append-only + periodic compaction beats in-place mutation for anything log-shaped.
  • Size determines the API. Whole-file load under a megabyte, mmap in the middle, stream above.
  • Batch your syscalls. 64–256 KB per write; per-line writes are orders of magnitude slower.
  • Clean up your temp files in a defer, including on the error path.
  • Test with a real-sized fixture. A 4 KB test file proves nothing about a 4 GB import.
  • Don't fileExists then act — the answer is stale before you use it.
  • Don't truncate a file before you have the replacement bytes safely on disk.
  • Don't put a growing database in Caches — the system will delete it, usually in the field.
  • Don't assume the main thread. Blocking file I/O on @MainActor is a watchdog termination waiting to happen.
  • If the data is structured, mutable and queried, stop writing files: use SQLite, SwiftData or Core Data. They already solved journaling, atomicity and partial updates.

Testing file code

Isolated temp directory per test, torn down whatever happens.

struct FileStoreTests {
  /// A unique directory per test instance — Swift Testing runs them in parallel.
  final class Sandbox {
    let root: URL
    init() throws {
      root = URL.temporaryDirectory.appending(path: "test-\(UUID().uuidString)")
      try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
    }
    deinit { try? FileManager.default.removeItem(at: root) }
  }

  @Test func atomicWriteLeavesNoTempFiles() throws {
    let sandbox = try Sandbox()
    let target = sandbox.root.appending(path: "doc.json")

    try Data("{}".utf8).write(to: target, options: .atomic)

    let remaining = try FileManager.default
      .contentsOfDirectory(atPath: sandbox.root.path(percentEncoded: false))
    #expect(remaining == ["doc.json"])
  }

  @Test func readingAMissingFileThrowsNoSuchFile() {
    #expect(throws: CocoaError.self) {
      _ = try Data(contentsOf: URL.temporaryDirectory.appending(path: "nope"))
    }
  }
}

Core Bluetooth (BLE)

Central role end to end, the GATT model, and the traps that fail interviews.

  • ios
  • ipados
  • macos
  • tvos
  • watchos
  • visionos
  • Core Bluetooth

Sources: Core Bluetooth — Apple Developer Documentation

The connection lifecycle

Every BLE bug is a step done out of order. Left to right, then wrap to row two.

CENTRAL ROLE - THE ONLY SEQUENCE THAT WORKS→ you call ← the delegate answers1. Create manager→ CBCentralManager(delegate:)← centralManagerDidUpdateStategate everything on .poweredOn2. Scan→ scanForPeripherals(svcUUIDs)← didDiscover + advData + RSSIstopScan() as soon as found3. Connect→ central.connect(peripheral)← didConnect / didFailToConnectSTRONGLY retain the peripheral4. Discover services→ p.discoverServices([svcUUID])← didDiscoverServicesset p.delegate = self first5. Discover characteristics→ discoverCharacteristics(_:for:)← didDiscoverCharacteristicsForcache the ones you use6. Subscribe→ setNotifyValue(true, for: c)← didUpdateNotificationStateFor← didUpdateValueFor (the stream)7. Read / Write→ readValue(for: c)→ writeValue(d, for: c, type:)← didWriteValueFor (.withResponse)8. Tear down→ cancelPeripheralConnection(p)← didDisconnectPeripheral(error)reconnect with backoffconnected - now you own the peripheral's delegateevery step is async + delegate basederrors arrive in the SAME callbackiOS caches the GATT table per deviceone CBCentralManager is enough

GATT object model

Peripheral → Service → Characteristic → Descriptor. Everything else is detail.

GATT OBJECT MODEL - FOUR NOUNS, NOTHING ELSECBPeripheralthe remote deviceidentifier: UUID(not the MAC address)CBServicea feature groupUUID 0x180DHeart RateCBCharacteristic0x2A37 Measurement.notify → the stream→ didUpdateValueForCBCharacteristic0x2A38 Body Location.read → one-shot→ readValue(for:)CBCharacteristic0x2A39 Control Point.write / .withoutResponse→ writeValue(_:for:type:)CBDescriptor0x2902 CCCDtoggled for you bysetNotifyValue(true:for:)characteristic.properties (an OptionSet - always check it before you act).read.write.writeWithoutResponse.notify.indicate.broadcast.authenticatedSignedWritesperipheral.services → service.characteristics → characteristic.descriptors (all Optional, all nil until you discover them)

Vocabulary in 8 lines

  • Central — scans, connects, consumes data (usually your iPhone). CBCentralManager.
  • Peripheral — advertises and serves data (the accessory). CBPeripheralManager if *you* are the accessory.
  • GATT — the attribute database exposed by the peripheral; ATT is the wire protocol underneath.
  • Service — a named group of characteristics, identified by a CBUUID (16-bit for adopted, 128-bit for custom).
  • Characteristic — the actual value + its properties (read / write / notify / indicate).
  • Descriptor — metadata about a characteristic; 0x2902 (CCCD) is the notify on/off switch.
  • Advertising packet — ≤31 bytes; carries the local name and service UUIDs you can filter on.
  • MTU — negotiated automatically; maximumWriteValueLength(for:) tells you the real payload size (often 20 or 182 bytes).

Central: setup, scan, connect

The Info.plist key is `NSBluetoothAlwaysUsageDescription`.

import CoreBluetooth

final class BLEClient: NSObject {
  private var central: CBCentralManager!
  private var peripheral: CBPeripheral?          // MUST be retained or it deallocates
  private let hrService = CBUUID(string: "180D")
  private let hrMeasure = CBUUID(string: "2A37")

  override init() {
    super.init()
    // A nil queue means every callback arrives on the MAIN queue.
    central = CBCentralManager(delegate: self, queue: .main,
                               options: [CBCentralManagerOptionShowPowerAlertKey: true])
  }
}

extension BLEClient: CBCentralManagerDelegate {
  func centralManagerDidUpdateState(_ c: CBCentralManager) {
    switch c.state {
    case .poweredOn:     c.scanForPeripherals(withServices: [hrService])
    case .unauthorized:  showPermissionHelp()
    case .poweredOff:    showTurnOnBluetooth()
    default:             break        // .resetting, .unsupported, .unknown
    }
  }

  func centralManager(_ c: CBCentralManager, didDiscover p: CBPeripheral,
                      advertisementData: [String: Any], rssi RSSI: NSNumber) {
    guard RSSI.intValue > -70 else { return }          // ignore far-away devices
    c.stopScan()                                       // scanning drains the battery
    peripheral = p                                     // retain BEFORE connecting
    p.delegate = self
    c.connect(p, options: nil)                         // no timeout: cancel it yourself
  }

  func centralManager(_ c: CBCentralManager, didConnect p: CBPeripheral) {
    p.discoverServices([hrService])
  }

  func centralManager(_ c: CBCentralManager, didFailToConnect p: CBPeripheral, error: Error?) { retry() }

  func centralManager(_ c: CBCentralManager, didDisconnectPeripheral p: CBPeripheral, error: Error?) {
    // error != nil means it dropped; nil means you called cancelPeripheralConnection.
    if error != nil { c.connect(p, options: nil) }     // iOS reconnects when it reappears
  }
}

Peripheral delegate: discover, subscribe, parse

extension BLEClient: CBPeripheralDelegate {
  func peripheral(_ p: CBPeripheral, didDiscoverServices error: Error?) {
    guard error == nil, let services = p.services else { return }
    for service in services {
      p.discoverCharacteristics([hrMeasure], for: service)
    }
  }

  func peripheral(_ p: CBPeripheral, didDiscoverCharacteristicsFor s: CBService, error: Error?) {
    for c in s.characteristics ?? [] {
      if c.properties.contains(.notify)  { p.setNotifyValue(true, for: c) }
      if c.properties.contains(.read)    { p.readValue(for: c) }
    }
  }

  // Fires for BOTH readValue(for:) and every notification.
  func peripheral(_ p: CBPeripheral, didUpdateValueFor c: CBCharacteristic, error: Error?) {
    guard error == nil, let data = c.value else { return }
    switch c.uuid {
    case hrMeasure: bpm = Self.parseHeartRate(data)
    default: break
    }
  }

  func peripheral(_ p: CBPeripheral, didWriteValueFor c: CBCharacteristic, error: Error?) {
    // ONLY called for .withResponse writes.
  }

  func peripheralIsReady(toSendWriteWithoutResponse p: CBPeripheral) {
    drainWriteQueue()          // back-pressure signal for .withoutResponse writes
  }
}

// BLE is little-endian; the first byte is a flags bitfield.
static func parseHeartRate(_ data: Data) -> Int {
  let is16Bit = data[0] & 0x01 == 1
  return is16Bit ? Int(UInt16(data[1]) | UInt16(data[2]) << 8) : Int(data[1])
}

Writing data

Choosing the wrong write type is the most common accessory bug.

`.withResponse` — reliable
p.writeValue(payload, for: control, type: .withResponse)

// - acknowledged by the peripheral
// - you get didWriteValueFor(error:)
// - ONE outstanding write at a time
// - slower: ~1 write per connection interval
`.withoutResponse` — fast
guard p.canSendWriteWithoutResponse else { queue.append(payload); return }
p.writeValue(payload, for: stream, type: .withoutResponse)

// - fire and forget, no callback, can be DROPPED
// - respect canSendWriteWithoutResponse +
//   peripheralIsReady(toSendWriteWithoutResponse:)
// - chunk to maximumWriteValueLength(for: .withoutResponse)

Modern wrapper: delegates → AsyncStream

How to make Core Bluetooth testable and await-able.

protocol BluetoothClient: Sendable {
  func scan(services: [CBUUID]) -> AsyncStream<DiscoveredDevice>
  func connect(_ id: UUID) async throws
  func values(for characteristic: CBUUID) -> AsyncStream<Data>
  func write(_ data: Data, to characteristic: CBUUID) async throws
}

// Live implementation bridges each delegate callback:
private var discoveries: AsyncStream<DiscoveredDevice>.Continuation?

func scan(services: [CBUUID]) -> AsyncStream<DiscoveredDevice> {
  AsyncStream { continuation in
    discoveries = continuation
    continuation.onTermination = { [weak self] _ in self?.central.stopScan() }
    central.scanForPeripherals(withServices: services)
  }
}

// One-shot calls use a continuation, keyed so the delegate can resume it:
private var connectContinuations: [UUID: CheckedContinuation<Void, Error>] = [:]

func connect(_ id: UUID) async throws {
  try await withCheckedThrowingContinuation { c in
    connectContinuations[id] = c
    central.connect(peripheral(for: id))
  }
}
// In didConnect:   connectContinuations.removeValue(forKey: p.identifier)?.resume()
// In didFail:      ...?.resume(throwing: error)   // exactly once, always

Traps that fail interviews

  • Not retaining the CBPeripheral — it deallocates and the connection silently dies.
  • Calling anything before .poweredOn. Queue your intent and act in centralManagerDidUpdateState.
  • Assuming peripheral.identifier is stable across devices — it is a per-device, per-app UUID, not the MAC address.
  • Forgetting that didUpdateValueFor serves both reads and notifications; switch on characteristic.uuid.
  • Scanning without a service filter, or never calling stopScan() — battery killer, and required for background scanning.
  • Writing more than maximumWriteValueLength(for:) — the data is silently truncated or the write fails.
  • Ignoring that iOS caches the GATT database; a firmware change may need a Bluetooth toggle or a Service Changed indication.
  • connect(_:) has no timeout. Start your own Task.sleep race and call cancelPeripheralConnection.
  • Serialize commands: BLE has one outstanding operation per connection — use an actor or a command queue.

State & background reference

TopicWhat to say
CBManagerState.unknown .resetting .unsupported .unauthorized .poweredOff .poweredOn
PermissionNSBluetoothAlwaysUsageDescription in Info.plist (iOS 13+)
BackgroundUIBackgroundModes: bluetooth-central and/or bluetooth-peripheral
Background scanningservice UUID filter is mandatory; slower duty cycle, no CBCentralManagerScanOptionAllowDuplicates
State restorationCBCentralManagerOptionRestoreIdentifierKey + willRestoreState: to survive termination
Reconnectconnect() a known peripheral — iOS keeps a pending connection indefinitely
Finding known devicesretrievePeripherals(withIdentifiers:), retrieveConnectedPeripherals(withServices:)
Peripheral roleCBPeripheralManager + CBMutableService / CBMutableCharacteristic + startAdvertising
Throughputnotify ≫ read; batch into the MTU; expect ~1–5 KB/s in practice
Testinghide it behind a protocol; simulator has no Bluetooth radio