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 stackGeometryReader { proxy in// greedy: fills all offered spaceCircle().frame(width: proxy.size.width * 0.5)
}
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.
importComposableArchitecture@ReducerstructCounter {
@ObservableStatestructState: Equatable {
var count = 0var fact: String?
var isLoading = false
}
enumAction {
case incrementTapped
case decrementTapped
case factButtonTapped
case factResponse(Result<String, Error>)
}
@Dependency(\.numberFact) var numberFact
var body: someReducerOf<Self> {
Reduce { state, action inswitch action {
case.incrementTapped:
state.count += 1return.nonecase.decrementTapped:
state.count -= 1return.nonecase.factButtonTapped:
state.isLoading = truereturn.run { [count = state.count] send inawait send(.factResponse(Result { tryawait numberFact.fetch(count) }))
}
caselet.factResponse(.success(fact)):
state.isLoading = false
state.fact = fact
return.nonecase.factResponse(.failure):
state.isLoading = falsereturn.none
}
}
}
}
The view + entry point
`StoreOf<F>` is `Store<F.State, F.Action>`.
structCounterView: View {
let store: StoreOf<Counter> // @Bindable var store when you need bindingsvar body: someView {
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() }
iflet fact = store.fact { Text(fact) }
}
}
}
// App entry@mainstructMyApp: App {
staticlet store = Store(initialState: Counter.State()) {
Counter()
._printChanges() // debug: logs every action + state diff
}
var body: someScene {
WindowGroup { CounterView(store: Self.store) }
}
}
Action naming
Actions describe *facts*, not commands to the store.
Do — user intent + results
enumAction {
case onAppear
case saveButtonTapped
case emailChanged(String)
case userResponse(Result<User, Error>)
case timerTicked
}
Don't — imperative / logic-y
enumAction {
case setLoadingTrue // that's state, not an eventcase fetchUserAndSave // two thingscase reloadEverything // what happened?case doTheThing(() -> Void)// not Equatable, untestable
}
Effects cheat list
Everything `Effect` gives you.
return.none// no side effectreturn.send(.otherAction) // synchronous follow-up (avoid for logic sharing)return.run { send in// async worklet value = tryawait api.load()
await send(.loaded(value))
} catch: { error, send inawait send(.failed(error))
}
return.merge(.run { ... }, .run { ... }) // run in parallelreturn.concatenate(.run { ... }, .run { ... })// run in orderreturn.run { _ inawait analytics.log("x") } // fire-and-forget// CancellationenumCancelID { 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.
`await` = a possible suspension point, not a thread hop guarantee.
func loadUser(id: Int) asyncthrows -> User {
let (data, response) = tryawaitURLSession.shared.data(from: url(id))
guard (response as? HTTPURLResponse)?.statusCode == 200else { throwAPIError.badStatus }
returntryJSONDecoder().decode(User.self, from: data)
}
// Sequential: total time = a + blet user = tryawait loadUser(id: 1)
let posts = tryawait loadPosts(for: user)
// Concurrent: total time = max(a, b)asynclet user = loadUser(id: 1)
asynclet stats = loadStats(id: 1)
let (u, s) = tryawait (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]) asyncthrows -> [String: UIImage] {
tryawait withThrowingTaskGroup(of: (String, UIImage).self) { group infor id in ids {
group.addTask { (id, tryawait fetchThumbnail(id)) } // runs concurrently
}
var result: [String: UIImage] = [:]
fortryawait (id, image) in group { // results in COMPLETION order
result[id] = image
}
return result
}
}
// Bounded concurrency (don't launch 10 000 tasks):tryawait withThrowingTaskGroup(of: Void.self) { group invar it = ids.makeIterator()
for _ in0..<4 { iflet id = it.next() { group.addTask { tryawait work(id) } } }
whiletryawait group.next() != nil {
iflet id = it.next() { group.addTask { tryawait work(id) } }
}
}
Cancellation
Cooperative: nothing is killed, you must check.
Task.isCancelled// non-throwing checktryTask.checkCancellation() // throws CancellationErrorlet task = Task {
for chunk in chunks {
tryTask.checkCancellation()
await process(chunk)
}
}
task.cancel() // cancels task AND all its childrenawait task.value// await result (rethrows)// Clean up when cancelled mid-flight:await withTaskCancellationHandler {
tryawait 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.
// Consumefortryawait line in url.lines { print(line) }
forawait note inNotificationCenter.default.notifications(named: .didLogin) { ... }
// Produce — bridging a callback APIfunc locations() -> AsyncStream<CLLocation> {
AsyncStream { continuation inlet manager = LocationManager()
manager.onUpdate = { continuation.yield($0) }
continuation.onTermination = { _ in manager.stop() }
manager.start()
}
}
// Throwing variant + explicit finishlet stream = AsyncThrowingStream<Int, Error> { c in
c.yield(1); c.yield(2); c.finish()
}
// Operators exist too:forawait x in stream.map { $0 * 2 }.filter({ $0 > 2 }).prefix(10) { ... }
Bridging legacy callbacks
Continuations: resume exactly once, or you crash / leak.
func load() asyncthrows -> Data {
tryawait withCheckedThrowingContinuation { continuation in
legacyLoad { data, error iniflet error { continuation.resume(throwing: error) }
else { continuation.resume(returning: data!) }
}
}
}
// Cancellable versionfunc load() asyncthrows -> Data {
tryawait withTaskCancellationHandler {
tryawait withCheckedThrowingContinuation { c in
task = legacyLoad { ... }
}
} onCancel: { task?.cancel() }
}
// withUnsafeContinuation = same, no runtime checks. Use Checked while developing.
Task API quick reference
API
Inherits isolation?
Inherits priority?
Use for
async let
yes
yes
fixed number of parallel calls
withTaskGroup
yes
yes
dynamic fan-out / bounded parallelism
Task { }
yes
yes
sync → async boundary (button taps, onAppear)
Task.detached { }
no
no
background 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.
@MainActorfinalclassProfileViewModel: ObservableObject {
@Publishedprivate(set) var user: User?
func load() async { // inherits @MainActorlet fetched = try? await api.user() // api call suspends off the main actorself.user = fetched // back on main automatically
}
}
// Explicit hop from a nonisolated context:awaitMainActor.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.
Every `await` inside an actor is a chance for other calls to interleave.
actorDownloader {
privatevar cache: [URL: Data] = [:]
privatevar inFlight: [URL: Task<Data, Error>] = [:]
// BUG: two callers can both see an empty cache and both download.func badFetch(_ url: URL) asyncthrows -> Data {
iflet hit = cache[url] { return hit }
let data = tryawaitURLSession.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) asyncthrows -> Data {
iflet hit = cache[url] { return hit }
iflet running = inFlight[url] { returntryawait running.value }
let task = Task { tryawaitURLSession.shared.data(from: url).0 }
inFlight[url] = task
defer { inFlight[url] = nil }
let data = tryawait 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 letSendable properties → can be Sendable.
actor types are always Sendable.
Non-final classes and classes with var state are notSendable.
@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
structUser: Sendable {
let id: UUIDlet name: String
}
finalclassCounter: @uncheckedSendable {
privatelet lock = NSLock()
privatevar _value = 0var value: Int { lock.withLock { _value } }
func increment() { lock.withLock { _value += 1 } }
}
@MainActorfinalclassViewModel { var items: [User] = [] }
// MainActor isolation makes it safe to share.
Don't
finalclassCounter: @uncheckedSendable {
var value = 0// no lock -> a lie to the compiler
}
classModel: Sendable { } // non-final: subclass can add mutable state// Capturing mutable local state in a @Sendable closurevar total = 0Task { total += 1 } // error / race
Global actors & custom isolation
`@MainActor` is just the built-in global actor.
@globalActoractorDatabaseActor {
staticlet shared = DatabaseActor()
}
@DatabaseActorfinalclassStore {
privatevar rows: [Row] = [] // all members isolated to DatabaseActorfunc insert(_ r: Row) { rows.append(r) }
}
@DatabaseActorfunc migrate() async { ... }
// Opting a member out@MainActorfinalclassVM {
nonisolatedlet id = UUID()
nonisolatedfunc pureHelper(_ x: Int) -> Int { x * 2 }
}
Isolated & sending parameters
Advanced but very quotable in an interview.
// Run code *in* someone else's isolation domainfunc mutate(_ cache: isolatedImageCache, 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: sendingNonSendableModel) async {
awaitactor.consume(model)
}
// #isolation gives the caller's isolation to a generic APIfunc run(isolation: isolated (anyActor)? = #isolation, _ body: () async -> Void) async {
await body()
}
Isolation quick decisions
Situation
Answer
UI / view model state
@MainActor on the type
Shared mutable cache or connection pool
actor
Pure value passed between tasks
struct + Sendable
Legacy class you can't change
wrap in an actor, or @unchecked Sendable + lock
One-off background computation
Task.detached or await a nonisolated func
Callback API from a C/ObjC lib
AsyncStream or continuation, marked @Sendable
Swift 6 Concurrency — Do's & Don'ts
Migration traps, and how modern code meets legacy GCD/Combine code.
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 = 0Task {
result = await compute()
sem.signal()
}
sem.wait() // deadlock risk, priority inversionreturn 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 { @MainActorinlet v = await compute()
self.label.text = "\(v)"
}
Task { @MainActorinlet result = awaitTask.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 = tryawait withThrowingTaskGroup(of: UIImage.self) { group infor url in urls { group.addTask { tryawait download(url) } }
var out: [UIImage] = []
fortryawait 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
actorCounter {
privatevar value = 0func 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 interruptURLSessionTask.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 { tryawait work() }
task.cancel() // propagates to children// cooperative check inside:tryTask.checkCancellation()
ifTask.isCancelled { return }
Delay / debounce / throttle
GCD
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { ... }
// manual debouncevar work: DispatchWorkItem?
func search(_ q: String) {
work?.cancel()
let w = DispatchWorkItem { performSearch(q) }
work = w
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: w)
}
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 == NeverEmpty<Int, Never>() // finish immediatelyFail<Int, MyError>(error: .boom) // fail immediately
[1, 2, 3].publisher// SequenceFuture<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 valuelet 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 disguisefinalclassVM: ObservableObject {
@Publishedvar query = ""@Publishedprivate(set) var results: [Item] = []
}
privatevar cancellables = Set<AnyCancellable>()
publisher
.sink(
receiveCompletion: { completion inifcaselet.failure(error) = completion { self.show(error) }
},
receiveValue: { value inself.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
forawait value in publisher.values { ... }
// single valuelet user = tryawait publisher
.first()
.values.first(where: { _ intrue })
// or the common helper:extensionPublisher {
var firstValue: Output {
getasyncthrows {
fortryawait v in values { return v }
throwCancellationError()
}
}
}
Use `onReceive` for system publishers; use the VM for business logic.
onReceive — view-local
structClockView: View {
@Stateprivatevar now = Date()
privatelet timer = Timer.publish(every: 1, on: .main, in: .common)
.autoconnect()
var body: someView {
Text(now, format: .dateTime.hour().minute().second())
.onReceive(timer) { now = $0 }
}
}
// Also great for system notifications:.onReceive(NotificationCenter.default.publisher(
for: UIApplication.willEnterForegroundNotification)) { _ inTask { await vm.refresh() }
}
Don't put logic here
structFeedView: View {
@Stateprivatevar items: [Item] = []
var body: someView {
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.
finalclassDocument: 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 parentfinalclassParent: ObservableObject {
@Publishedvar child = Child()
privatevar bag = Set<AnyCancellable>()
init() {
child.objectWillChange.sink { [weakself] inself?.objectWillChange.send() }
.store(in: &bag) // nested ObservableObjects are NOT automatic
}
}
Combine VM vs @Observable VM
Same screen, before and after iOS 17.
Combine / ObservableObject
@MainActorfinalclassCartVM: ObservableObject {
@Publishedprivate(set) var items: [Item] = []
@Publishedprivate(set) var total: Decimal = 0privatevar bag = Set<AnyCancellable>()
init() {
$items
.map { $0.reduce(0) { $0 + $1.price } }
.assign(to: &$total)
}
}
structCartView: View {
@StateObjectprivatevar vm = CartVM()
var body: someView { Text("\(vm.total)") }
}
// Whole view re-renders when ANY @Published changes.
Observation (iOS 17+)
@MainActor@ObservablefinalclassCartVM {
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() }
}
structCartView: View {
@Stateprivatevar vm = CartVM()
var body: someView {
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 {
forawait location in locationManager.publisher.values {
region = MKCoordinateRegion(center: location.coordinate, span: span)
}
}
// @Observable model that still consumes a legacy Combine API@ObservablefinalclassLocationModel {
private(set) var last: CLLocation?
@ObservationIgnoredprivatevar bag = Set<AnyCancellable>()
init(service: LegacyLocationService) {
service.publisher.receive(on: RunLoop.main)
.sink { [weakself] inself?.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
Goal
Code
React to a publisher in a view
.onReceive(pub) { value in ... }
Drive a @Published from a publisher
pub.assign(to: &$property)
Drive any property, manual lifetime
pub.sink { [weak self] ... }.store(in: &bag)
Two-way binding to a VM property
$vm.query (needs @Published var, non-private setter)
Read down the column: at 200 ms every operator has made a different decision.
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
Combine
Semantics
Drops values?
Concurrency equivalent
debounce(for:scheduler:)
emit only after N of silence; keeps the last of a burst
yes, most of them
AsyncSequence.debounce(for:)* or a cancel-and-restart Task
throttle(for:scheduler:latest:)
at most one per window; latest picks first vs last
yes
.throttle(for:latest:)* or a last-emit timestamp on an actor
delay(for:tolerance:scheduler:)
shift every value later in time
no
try await Task.sleep(for:) before yielding
timeout(_:scheduler:customError:)
fail if no value arrives within N
terminates
TaskGroup race: work vs Task.sleep, then cancelAll()
collect(.byTime(s, n))
buffer a window into an array
no (batches)
.chunks(ofCount:or:)* or a manual buffer + deadline
collect(.byTimeOrCount(s, n, c))
flush on time or on size
no
same, with both conditions
measureInterval(using:)
emit the gap between upstream values
no
subtract two clock.now instants
Timer.publish(every:on:in:)
repeating ticks on a RunLoop
n/a
AsyncTimerSequence* or a clock.sleep(until:) loop
Publishers.Debounce + removeDuplicates
the search-as-you-type pair
yes
.debounce().removeDuplicates()*
.receive(on:)
deliver downstream on that scheduler
no
@MainActor / await on an actor
.subscribe(on:)
start the subscription there
no
Task.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 { [weakself] text inself?.search(text)
}
.store(in: &cancellables)
// The operator owns the timer. Cancelling the// AnyCancellable cancels the pending emission.
Swift Concurrency
@MainActor@ObservablefinalclassSearchModel {
var query = "" { didSet { schedule() } }
private(set) var results: [Item] = []
privatevar pending: Task<Void, Never>?
privatefunc schedule() {
pending?.cancel() // restart the clock
pending = Task { [query] intry? awaitTask.sleep(for: .milliseconds(300))
guard !Task.isCancelledelse { 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:forawait 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.actorThrottle {
privatevar last: ContinuousClock.Instant?
privatelet interval: Durationinit(interval: Duration) { self.interval = interval }
func allow(now: ContinuousClock.Instant = .now) -> Bool {
iflet last, now - last < interval { returnfalse }
last = now
returntrue
}
}
let gate = Throttle(interval: .milliseconds(200))
ifawait gate.allow() { recordScroll(offset) }
// Trailing edge ("latest") = a debounce with a// maximum wait, or simply:forawait 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
// delaytryawaitTask.sleep(for: .seconds(2))
showToast = false// tolerance saves battery on long sleepstryawaitTask.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 () asyncthrows -> T
) asyncthrows -> T {
tryawait withThrowingTaskGroup(of: T.self) { group in
group.addTask { tryawait operation() }
group.addTask {
tryawaitTask.sleep(for: limit)
throwTimeoutError()
}
let winner = tryawait group.next()!
group.cancelAll() // <- do not forget thisreturn winner
}
}
let user = tryawait withTimeout(.seconds(5)) {
tryawait 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-algorithmsforawait 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))
forawait 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 {
tryawaitTask.sleep(for: .seconds(1))
await tick()
}
// RIGHT: advance an absolute deadline, so the work's cost is absorbed.func run() asyncthrows {
let clock = ContinuousClock()
var next = clock.nowwhile !Task.isCancelled {
next = next.advanced(by: .seconds(1))
tryawait clock.sleep(until: next, tolerance: .milliseconds(50))
await tick() // late ticks don't shift the schedule
}
}
// swift-async-algorithms does it for youforawait instant inAsyncTimerSequence(interval: .seconds(1), clock: .continuous) {
await tick()
}
// Combine equivalent - the operator handles the schedule, you handle the lifetimeTimer.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 UIRunLoop.main// PAUSES during scroll tracking// and in modal run-loop modesDispatchQueue.global(qos: .utility)
OperationQueue() // has maxConcurrentOperationCountImmediateScheduler.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 aroundlet deadline = ContinuousClock.now.advanced(by: .seconds(30))
tryawait clock.sleep(until: deadline)
// Measurelet elapsed = tryawaitContinuousClock().measure {
tryawait work()
}
// Where the work runs is a separate decision:awaitMainActor.run { label.text = "done" }
Make time injectable — and your tests instant
// Depend on the `Clock` protocol, never on Task.sleep directly.structPoller<C: Clock> {
let clock: Clet interval: C.Durationfunc run(_ body: @Sendable () async -> Void) asyncthrows {
var next = clock.nowwhile !Task.isCancelled {
next = next.advanced(by: interval)
tryawait clock.sleep(until: next, tolerance: nil)
await body()
}
}
}
// Productionlet poller = Poller(clock: ContinuousClock(), interval: .seconds(30))
// Tests: pointfreeco/swift-clocks — time only moves when you say so@Testfunc pollsEverySecond() asyncthrows {
let clock = TestClock()
var ticks = 0let task = Task { tryawaitPoller(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 typefinalclassModel<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.@MainActorfinalclassTickerViewModel {
private(set) var seconds = 0privatevar task: Task<Void, Never>? // only ever touched on the main actorfunc start() {
task?.cancel() // idempotent: no double timers
task = Task { [weakself] in// weak: don't keep the screen alivelet clock = ContinuousClock()
var next = clock.nowwhile !Task.isCancelled {
next = next.advanced(by: .seconds(1))
try? await clock.sleep(until: next, tolerance: .milliseconds(50))
guardletself, !Task.isCancelledelse { 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.isCancelledafter 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 daysvar list1: [User]
var flag = falselet strName: String// Hungarian notationfunc proc(_ a: [User]) -> [User]
func getUserData() -> Data// "data"? "info"? "manager"?classUserInfoDataManagerHelper { }
if user.t == 2 { ... } // magic number
Do
var elapsedDays: Intvar activeUsers: [User]
var isEligibleForRefund = falselet customerName: Stringfunc removingBannedUsers(from users: [User]) -> [User]
func fetchUserProfile() asyncthrows -> UserProfilestructUserProfileRepository { }
enumAccountTier: 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 = 0for item in cart.items {
guard item.stock > 0else { throwCheckoutError.outOfStock(item.id) }
var price = item.priceif 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.21let charge = total + tax
let token = try payments.charge(charge)
analytics.log("checkout", ["total": charge])
returnReceipt(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))
returnReceipt(total: discounted + tax, tax: tax, token: token)
}
privatefunc 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.
// increment the counter by one
counter += 1// MARK: - Propertiesvar 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.isEmptyelse { throwAPIError.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? {
guardlet data = try? Data(contentsOf: url) else { returnnil }
returntry? JSONDecoder().decode(User.self, from: data)
}
// Caller has no idea WHY it failed.func parse(_ s: String) -> Int { Int(s) ?? -1 } // magic sentinelif result == nil { /* network? decoding? not found? */ }
Do
enumUserError: Error {
case notFound(id: String)
case decoding(underlying: Error)
}
func loadUser(id: String) asyncthrows -> User {
let data = tryawait api.data(for: id)
do { returntryJSONDecoder().decode(User.self, from: data) }
catch { throwUserError.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?) {
iflet response {
if response.statusCode == 200 {
iflet body = response.body {
if !body.isEmpty {
process(body)
}
}
}
}
}
Flat
func handle(_ response: Response?) {
guardlet response else { return }
guard response.statusCode == 200else { return log(.badStatus) }
guardlet body = response.body, !body.isEmptyelse { return }
process(body)
}
// Happy path stays at the left margin.
Typed wrappers beat primitives
Cheap in Swift, and the compiler catches whole bug classes.
structUserID: Hashable, Codable, RawRepresentable { let rawValue: String }
structEmail: Hashable {
let value: Stringinit?(_ raw: String) {
guard raw.contains("@") else { returnnil } // 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// UnitsstructMeters: 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
@Testfunc refund_isRejected_whenOrderIsOlderThan30Days() throws {
// Arrangelet order = Order.stub(placedAt: .distantPast, state: .delivered)
let sut = RefundService(now: { .fixed }) // injected clock -> repeatable// Actlet 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 rule
Swift way to satisfy it
Avoid null
Optional + guard let; return empty collections
Don't return error codes
throws + typed Error enums, or Result
Prefer polymorphism to switch
protocols, or an enum when the set is closed
Encapsulate conditionals
computed properties: var isEligible: Bool
Avoid output arguments
return values; inout only for local, obvious mutation
Keep classes small (SRP)
struct + protocol composition; extensions per concern
Dependency injection
initialiser injection, closures, @Dependency in TCA
Separate construction from use
factories, 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
finalclassUserManager {
func fetchUser(id: String) asyncthrows -> User {
let (data, _) = tryawaitURLSession.shared.data(from: url(id))
let user = tryJSONDecoder().decode(User.self, from: data)
UserDefaults.standard.set(data, forKey: id) // persistenceAnalytics.log("user_fetched") // analyticsreturn user
}
func displayName(_ u: User) -> String { ... } // formatting
}
Respects
structUserAPI { // transport onlyfunc user(id: String) asyncthrows -> User
}
structUserCache { // persistence onlyfunc save(_ user: User); func load(id: String) -> User?
}
structUserFormatter { // presentation onlyfunc displayName(_ u: User) -> String
}
// A thin coordinator composes them:structUserRepository {
let api: UserAPI, cache: UserCachefunc user(id: String) asyncthrows -> User {
iflet hit = cache.load(id: id) { return hit }
let u = tryawait 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.
finalclassCheckoutService {
func pay(_ order: Order) asyncthrows {
let token = tryawaitStripeSDK.shared.charge(order.total)
Database.shared.save(token)
Analytics.shared.log("paid")
}
}
// Untestable: every test hits the real world.
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 = 0for 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, +)
let names = users.map(\.name) // KeyPath as (User) -> Stringlet 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 pathfunc 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 >>>: AdditionPrecedencefunc >>> <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 functionsfunc 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 flatMaplet length: Int? = name.map(\.count) // Optional<Int>let user: User? = id.flatMap(repository.user(id:)) // avoids Optional<Optional<User>>// Coalescing + guard for early exitguardlet email = form.email, email.contains("@") else { throwValidationError.email }
// Resultlet parsed = Result { tryJSONDecoder().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 nonsensestructViewState {
var isLoading = falsevar items: [Item] = []
var error: Error?
}
// Good: exactly 4 possible statesenumViewState {
case idle
case loading
case loaded([Item])
case failed(Error)
}
// Enums with associated values + exhaustive switch = compiler-checked logicswitch state {
case.idle: EmptyView()
case.loading: ProgressView()
case.loaded(let xs): List(xs) { ItemRow($0) }
case.failed(let e): ErrorView(error: e)
}
Opaque type vs existential — a very common question.
`some` — opaque, static
func makeShape() -> someShape { Circle() }
// - ONE concrete type, known to the compiler// - no boxing, full specialisation, fast// - caller can't see which type it isfunc area(of s: someShape) -> Double { s.area }
// sugar for: func area<S: Shape>(of s: S) -> Double
`any` — existential, dynamic
let shapes: [anyShape] = [Circle(), Square()]
// - heterogeneous storage// - boxed, dynamic dispatch (slower)// - needed for heterogeneous collectionsfunc area(of s: anyShape) -> Double { s.area }
// Swift 5.7+ can open existentials automatically
Protocols with associated types & primary associated types
protocolRepository {
associatedtypeItem: Identifiablefunc all() asyncthrows -> [Item]
func save(_ item: Item) asyncthrows
}
// Swift 5.7 primary associated types make PATs usable as `some`/`any`protocolStorage<Value> {
associatedtypeValuefunc load() -> Value?
}
func use(_ s: someStorage<User>) { ... }
let boxed: anyStorage<User> = KeychainStorage()
// Protocol with default implementationextensionRepository {
func first() asyncthrows -> Item? { tryawait all().first }
}
Error handling
enumAPIError: Error, Equatable {
case badStatus(Int)
case decoding(String)
}
extensionAPIError: LocalizedError {
var errorDescription: String? {
switchself {
case.badStatus(let c): "Server returned \(c)"case.decoding(let m): "Could not read the response: \(m)"
}
}
}
do { let u = tryawait api.user() }
catchlet e asAPIError { show(e) }
catch { report(error) }
try? await api.user() // Optional resulttry! 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)
Generic code should demand the weakest rung that still works.
Pick the container
The need on the left decides the type; the cost on the right decides whether you got it right.
Costs, per type
The table interviewers and profilers both care about.
Type
Lookup
Insert
Remove
Ordered?
Notes
Array
O(1) by index, O(n) by value
O(1)* append, O(n) insert
O(1)* last, O(n) other
yes
*amortised; contiguous storage
ContiguousArray
same
same
same
yes
skips ObjC bridging — for class elements
ArraySlice
O(1)
—
—
yes
shares the parent's buffer and keeps it alive
Set
O(1) avg
O(1) avg
O(1) avg
no
order varies between launches
Dictionary
O(1) avg
O(1) avg
O(1) avg
no
same; Key: Hashable
String
O(n) — no Int index
O(1) append
O(n)
yes
grapheme clusters; count is O(n)
Deque
O(1) by index
O(1) both ends
O(1) both ends
yes
swift-collections
OrderedSet
O(1) by element, O(1) by index
O(1) append
O(n) middle
yes
Set semantics + a RandomAccess view
OrderedDictionary
O(1)
O(1) append
O(n) middle
yes
stable iteration order
Heap
O(1) min/max only
O(log n)
O(log n)
partial
priority queue; no arbitrary lookup
BitSet
O(1)
O(1)
O(1)
yes (numeric)
1 bit per element; fast set algebra
TreeDictionary / TreeSet
O(log n)
O(log n)
O(log n)
no
persistent: 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 meantfor (offset, value) in tail.enumerated() { }
// zip(indices, self) when you need real indicesfor (index, value) in zip(tail.indices, tail) { }
// Copy out to break the storage linkfunc 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 orderlet 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:structPermissions: OptionSet {
let rawValue: Intstaticlet read = Permissions(rawValue: 1 << 0)
staticlet write = Permissions(rawValue: 1 << 1)
staticlet 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 pairsDictionary(uniqueKeysWithValues: zip(ids, names)) // traps on a duplicate keyDictionary(pairs, uniquingKeysWith: { first, _ in first }) // pick a winner insteadDictionary(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// Mergevar settings = defaults
settings.merge(overrides) { _, new in new } // last one winslet 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 arraysArray(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()
iflet range = s.range(of: "fé") { s[range] } // Range<String.Index>
s.firstIndex(of: "é")
// Building strings in a loop: reserve, and append — never result = result + xvar out = ""
out.reserveCapacity(1024)
for line in lines { out += line; out += "\n" }
importCollections// Deque — a queue that isn't O(n) at the frontvar 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 ordervar 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 missingvar 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 sequencesfunc firstTwo<S: Sequence>(of s: S) -> [S.Element] { Array(s.prefix(2)) }
// Needs count / indices / multiple passesfunc middle<C: Collection>(of c: C) -> C.Element? {
guard !c.isEmptyelse { returnnil }
return c[c.index(c.startIndex, offsetBy: c.count / 2)]
}
// Needs O(1) index math — binary search would be O(n log n) without thisfunc binarySearch<C: RandomAccessCollection>(_ c: C, _ target: C.Element) -> C.Index?
whereC.Element: Comparable {
var lo = c.startIndex, hi = c.endIndexwhile 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 }
}
returnnil
}
// Needs in-place element assignment (length fixed)func swapEnds<C: MutableCollection>(_ c: inoutC) whereC: BidirectionalCollection {
guard !c.isEmptyelse { return }
c.swapAt(c.startIndex, c.index(before: c.endIndex))
}
// Needs to grow/shrinkfunc 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 infinitestructFibonacci: Sequence, IteratorProtocol {
privatevar (a, b) = (0, 1)
mutatingfunc 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 methodsstructMatrix<Element>: RandomAccessCollection {
privatevar storage: [Element]
let columns: Intvar startIndex: Int { 0 }
var endIndex: Int { storage.count }
subscript(position: Int) -> Element { storage[position] }
// index(after:) / index(before:) are free for Int indicessubscript(row: Int, column: Int) -> Element { storage[row * columns + column] }
}
// Now you get: map, filter, first, count, sorted, reversed, max, indices...// An AsyncSequence: the streaming equivalentstructTicks: AsyncSequence {
typealiasElement = Datefunc makeAsyncIterator() -> AsyncIterator { AsyncIterator() }
structAsyncIterator: AsyncIteratorProtocol {
mutatingfunc next() asyncthrows -> Date? {
tryawaitTask.sleep(for: .seconds(1))
return.now
}
}
}
Performance levers
// 1. Reserve when you know the size — the single biggest easy winvar out: [Result] = []
out.reserveCapacity(inputs.count)
// 2. reduce(into:) instead of reduce — no copy per elementlet 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 intermediateslet 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 typestructBuffer {
privatevar storage: Storagemutatingfunc mutate() {
if !isKnownUniquelyReferenced(&storage) { storage = storage.copy() }
storage.value += 1
}
}
// 7. Drop to a raw buffer for numeric hot loopslet total = values.withUnsafeBufferPointer { buffer -> Doubleinvar sum = 0.0for v in buffer { sum += v }
return sum
}
// 8. Set/Dictionary beat Array.contains the moment you ask more than oncelet 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.
removeAll(where:) · swapAt · replaceSubrange · loop over indices
diff two collections
new.difference(from: old) → CollectionDifference
defer the work
.lazy.map { }.filter { }.first { }
Transform & fold
let names = users.map(\.name) // key path as a functionlet ages = users.map { $0.age }
let ids = rows.compactMap { Int($0.id) } // drops the unparseablelet 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 elementlet 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 totalslet 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 allocatesif !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
// Grouplet 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 grouplet totalPerCountry = orders.reduce(into: [String: Decimal]()) {
$0[$1.country, default: 0] += $1.amount
}
// Count occurrences, then ranklet ranked = Dictionary(grouping: events, by: \.type)
.mapValues(\.count)
.sorted { $0.value > $1.value }
// Sectioned list for SwiftUIstructSection: 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-algorithmsfor page in items.chunks(ofCount: 50) { tryawait upload(Array(page)) }
// Without the packagelet chunks = stride(from: 0, to: items.count, by: 50).map {
Array(items[$0 ..< min($0 + 50, items.count)])
}
// Sliding windows and pairsfor 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 keylet runs = items.chunked { $0.category == $1.category } // swift-algorithms
Combine & dedupe
// Zip stops at the shorter onefor (name, score) in zip(names, scores) { }
let pairs = Array(zip(ids, names))
// Concatenatelet all = a + b
let flat = [[1, 2], [3]].joined() // lazy FlattenSequenceArray([[1, 2], [3]].joined()) // [1,2,3]// Dedupe, order lostlet unique = Array(Set(items))
// Dedupe, order kept — no packagevar seen = Set<Item.ID>()
let ordered = items.filter { seen.insert($0.id).inserted }
// Dedupe, order kept — swift-algorithmslet ordered2 = items.uniqued(on: \.id)
// Set algebra over model objectslet added = Set(new.map(\.id)).subtracting(Set(old.map(\.id)))
// Interleave / intersperseArray([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 updateslet diff = newItems.difference(from: oldItems) // Element: Equatablefor change in diff {
switch change {
caselet.insert(offset, element, _): rows.insert(element, at: offset)
caselet.remove(offset, _, _): rows.remove(at: offset)
}
}
// Or apply it wholesaleiflet 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 IDSForEach(items) { item inRow(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 elementsfor 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 elementextensionMutableCollection {
mutatingfunc mutateEach(_ body: (inoutElement) -> Void) {
for index in indices { body(&self[index]) }
}
}
items.mutateEach { $0.isRead = true }
// inout parameters keep value semantics while allowing local mutationfunc normalize(_ values: inout [Double]) {
guardlet maximum = values.max(), maximum > 0else { 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 elementlet first = items.map(expensive).filter(\.isValid).first// Lazy: runs expensive() until the first valid element, allocates nothinglet first = items.lazy.map(expensive).first(where: \.isValid)
// Infinite sequences are only possible lazilylet 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 traversallet 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.
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 Dictionaryonce 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 signaturefunc add(_ observer: Observer) // not addObserver(observer:)extensionCollection {
func removingBoundaries() -> [Element]
}
// Booleans read as assertionsif line.isEmpty, path.isAbsolute { }
// Protocols: -able / -ible for capability, nouns for what it ISprotocolCancellable { }
protocolCollection { }
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 sitevar flag = false// flag for what?classMyDataManagerHelperUtil { } // four nouns, zero meaning// Abbreviations the reader has to decodefunc calcTtlAmt(_ i: [Itm]) -> Dec// Type name repeated in every memberstructUser {
var userName: String// user.userNamevar userAge: Int
}
Errors: model them, don't stringly-type them
enumSyncError: Error, Equatable {
case offline
case unauthorized
case server(status: Int)
case decoding(field: String)
}
extensionSyncError: LocalizedError {
// What the USER sees - never the raw error.var errorDescription: String? {
switchself {
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() asyncthrows(SyncError) -> Snapshot { ... }
// At the boundary, translate - never leak a transport error into the domain.do { returntryawait api.fetch() }
catchlet error asURLErrorwhere error.code == .notConnectedToInternet {
throwSyncError.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
guardlet user = session.userelse { return.signedOut }
// Give the caller a real defaultlet name = user.nickname ?? user.legalName// Optional chaining over pyramidslet city = user.address?.city?.capitalized// Empty collections instead of optional collectionsvar tags: [Tag] = [] // not [Tag]?// Non-optional after init: use a factory or a failable initinit?(rawValue: String) { ... }
Don't
let user = session.user! // crash waiting to happenvar name: String! // implicitly unwrapped: no safety at allif 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 reasonlet data = try? Data(contentsOf: url) // was it missing? unreadable? corrupt?
Logging & diagnostics
`OSLog` is structured, fast, privacy-aware and visible in Instruments.
importOSLogextensionLogger {
privatestaticlet subsystem = Bundle.main.bundleIdentifier!
staticlet sync = Logger(subsystem: subsystem, category: "sync")
staticlet 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' timelinelet 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:enumAppConfig {
staticlet apiBaseURL: URL = {
guardlet 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 asString: kSecClassGenericPassword,
kSecAttrAccount asString: account,
kSecValueData asString: Data(token.utf8),
kSecAttrAccessible asString: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
]
SecItemDelete(query asCFDictionary)
guardSecItemAdd(query asCFDictionary, nil) == errSecSuccess else { throwKeychainError.write }
}
// 3. Feature flags are typed, defaulted, and have an owner + an expiry date.structFeatureFlags: Sendable {
var newCheckout = false// owner: payments, remove after 2026-11var useLocalCache = true
}
Performance checklist
In rough order of how often each one is the actual culprit.
downsample with ImageIO before display; don't decode full-res
Slow launch
App Launch template
defer non-critical work off didFinishLaunching; fewer dynamic frameworks
Growing memory
Leaks + Memory Graph
retain cycles in closures/delegates; unbounded caches
Battery / thermal
Energy Log, Points of Interest
coalesce network calls, back off timers and BLE scans
Slow build
-Xfrontend -warn-long-function-bodies=100
annotate types on big expressions; split modules
Hitches after a change
Signposts + XCTMetric
add 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.publicfunc cancel(orderID id: Order.ID, reason: String) asyncthrows -> 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.isEmptyelse { throwSyncError.unauthorized }
// MARK: - marks sections; // TODO: needs an owner and a ticket.
Project hygiene
What a healthy Swift repo has.
Area
Practice
Formatting
swift-format or SwiftFormat in a pre-commit hook — never argue about style in review
Linting
SwiftLint with the ruleset committed; warnings fail CI, not the reviewer's patience
Build settings
.xcconfig files per configuration; keep the .pbxproj diff small
Warnings
SWIFT_TREAT_WARNINGS_AS_ERRORS on CI; a warning nobody fixes is a warning everybody ignores
Dependencies
SPM, pinned with Package.resolved committed; audit before adding — every dependency is a liability
Tests
Xcode test plans; unit tests on every PR, UI tests nightly
CI
build + test + lint on PR; archive on merge to main; keep it under 10 minutes
Releases
semantic versioning, an automated changelog, and a tagged commit per release
Crash reporting
symbolicated crashes with breadcrumbs; triage weekly, not at release time
Code owners
CODEOWNERS 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.
Clean Architecture layers
The only rule that matters: dependencies point inward. Everything else is naming.
Comparison
Pattern
Strength
Weakness
Testability
Best for
MVC (Apple)
trivial to start, zero ceremony
Massive View Controller
low
prototypes, tiny screens
MVP
passive view, explicit contract
protocol boilerplate
high
UIKit teams migrating off MVC
MVVM
bindings, works natively with SwiftUI
says nothing about navigation or side effects
high
most SwiftUI apps
VIPER
strict boundaries, router for navigation
5 files per screen
very high
large UIKit codebases, many teams
Clean / Onion
domain independent of frameworks
mapping layers everywhere
very high
long-lived, multi-platform domains
Redux / TCA
one state, replayable, exhaustive tests
learning curve, ceremony for small screens
very high
complex state, deep navigation
Coordinator
removes navigation from view controllers
an extra object graph to own
high
any UIKit app, pairs with MVVM
MVVM done properly
The view is dumb, the view model is pure logic, the model is injected.
// ---------- CatalogInterface (tiny, stable, no heavy imports) ----------publicprotocolCatalogService: Sendable {
func products(matching query: String) asyncthrows -> [Product]
}
publicstructProduct: Identifiable, Sendable, Equatable { publiclet id: UUID; publiclet name: String }
// ---------- FeatureCheckout: imports the INTERFACE only ----------importCatalogInterfacepublicstructCheckoutView: View {
privatelet catalog: anyCatalogService// never knows the concrete typepublicinit(catalog: anyCatalogService) { self.catalog = catalog }
}
// ---------- CatalogLive: the heavy implementation ----------importCatalogInterfacepublicstructLiveCatalog: CatalogService { ... }
// ---------- App: the ONLY place that sees both ----------importFeatureCheckoutimportCatalogLive@mainstructMyApp: App {
var body: someScene {
WindowGroup { CheckoutView(catalog: LiveCatalog(client: .live)) }
}
}
// Tests & previews inject CatalogMock instead - no rebuild of CatalogLive.
Access control is the enforcement mechanism
publicstructPaymentSheet: View { // visible to other modulespublicinit(...) { } // public types need an explicit public init!internallet engine: Engine// module-only (the default)privatevar attempts = 0// file/type-onlyfileprivatefunc log() { }
}
openclassThemable { } // subclassable outside the modulepackagefunc sharedHelper() { } // Swift 5.9: visible to the same package only@_exportedimportCatalogInterface// 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
Strategy
Looks like
Use when
Single app target
everything in the app
solo dev, prototype, < ~20 screens
Layer-based
UI, Domain, Data
small team wanting rules without much plumbing
Feature-based
FeatureCart, FeatureSearch
multiple squads; the common default
Feature + interface modules
XInterface / XLive / XMock
big codebase where build time and previews matter
Local SPM packages
one Package.swift, many targets
start here — cheapest, fully Xcode-native
Separate repos + versions
one package per repo
genuine reuse across products; adds release overhead
Tuist / XcodeGen
generated projects
many 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.
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.
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, Swift6 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: SwiftTesting (`@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: Swift6 concurrency conventions for this app
globs: ["**/*.swift"]
alwaysApply: false # attach when a matching file isin context
---
- The language mode isSwift6, strict concurrency complete.
- View models are `@MainActor@Observablefinalclass`.
- Shared mutable state is an `actor`. Never a serial DispatchQueue.
- Anything crossing an isolation boundary must be `Sendable`; if you are
tempted to write `@uncheckedSendable`, 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
---
UseSwiftTesting. 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 in
Because
always true about the repo
CLAUDE.md / AGENTS.md (short)
loaded every turn — length costs you context
true for some files only
.cursor/rules/*.mdc with globs
attached automatically when relevant
reusable know-how or a procedure
a Claude Code skill (SKILL.md + scripts)
routed in on demand by its description
a one-tap workflow you invoke
a slash command
explicit, repeatable, parameterised
a delegation boundary
a subagent
own context window; parallel search / review / migration
non-negotiable
a hook or a permission rule
runs deterministically, not "if the model remembers"
access to an external system
an MCP server
real tools instead of guessed output
a big refactor across the tree
a subagent per module, or a git worktree
keeps 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
Failure
What you see
Antidote
Hallucinated API
.onChange(of:perform:) on iOS 17, invented modifiers
MCP build tool; "cite where this is declared"
Era drift
ObservableObject, DispatchQueue.main.async
pin iOS/Swift versions in CLAUDE.md and rules
Concurrency cargo cult
@unchecked Sendable added until it builds
rule: "explain the isolation in one sentence first"
Scope creep
a 900-line diff you didn't ask for
"only these files", "under N lines", plan mode first
Confident wrongness
"Fixed!" with no build run
make green tests the definition of done
Context rot
it forgets a decision from ten turns ago
restart, re-ground, or promote the decision into the rules file
Package invention
a version or product name that doesn't exist
have it read Package.resolved before editing Package.swift
Silent test weakening
assertions deleted to make a suite pass
review 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.
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.mdunder 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.
Complexity, and what it costs
Know where the cliff is: n log n is fine at a million, n² is not.
Swift standard library complexity
The numbers interviewers expect you to know.
Operation
Cost
Note
array[i]
O(1)
random access, contiguous storage
array.append
O(1) amortised
O(n) on the occasional grow — reserveCapacity(n) to avoid
array.insert(at:) / remove(at:)
O(n)
shifts every later element
array.contains
O(n)
use a Set if you ask more than once
dict[key], set.insert
O(1) average
O(n) worst case; needs a good Hashable
sort() / sorted()
O(n log n)
introsort; not stable (use enumerated() as a tiebreak)
min() / max() / reduce
O(n)
single pass
string.count
O(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 ends
O(1)
swift-collections; Array.removeFirst() is O(n)
Heap.insert / popMin
O(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 arrayfunc twoSum(_ a: [Int], _ target: Int) -> (Int, Int)? {
var lo = 0, hi = a.count - 1while lo < hi {
let sum = a[lo] + a[hi]
if sum == target { return (lo, hi) }
sum < target ? (lo += 1) : (hi -= 1)
}
returnnil
}
// In-place removal, the "slow writer / fast reader" variantfunc removeDuplicates(_ a: inout [Int]) -> Int {
guard !a.isEmptyelse { return0 }
var write = 1for read in1..<a.countwhere 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)
// Fixed size: max sum of any k consecutive elementsfunc 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 characterfunc longestUnique(_ s: String) -> Int {
var lastSeen: [Character: Int] = [:]
var start = 0, best = 0for (i, ch) inArray(s).enumerated() {
iflet seen = lastSeen[ch], seen >= start { start = seen + 1 }
lastSeen[ch] = i
best = max(best, i - start + 1)
}
return best
}
Binary search
Sorted data, or any monotonic predicate. O(log n).
func binarySearch(_ a: [Int], _ target: Int) -> Int? {
var lo = 0, hi = a.count - 1while lo <= hi {
let mid = lo + (hi - lo) / 2// avoids overflowif a[mid] == target { return mid }
if a[mid] < target { lo = mid + 1 } else { hi = mid - 1 }
}
returnnil
}
// Lower bound: first index where a[i] >= target (the more useful variant)func lowerBound(_ a: [Int], _ target: Int) -> Int {
var lo = 0, hi = a.countwhile lo < hi {
let mid = (lo + hi) / 2if a[mid] < target { lo = mid + 1 } else { hi = mid }
}
return lo
}
// "Binary search the ANSWER": minimise a value subject to a monotonic checkfunc minCapacity(_ check: (Int) -> Bool, in range: ClosedRange<Int>) -> Int {
var lo = range.lowerBound, hi = range.upperBoundwhile lo < hi {
let mid = (lo + hi) / 2if check(mid) { hi = mid } else { lo = mid + 1 }
}
return lo
}
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 seenfunc twoSum(_ a: [Int], _ target: Int) -> (Int, Int)? {
var seen: [Int: Int] = [:] // value -> indexfor (i, v) in a.enumerated() {
iflet j = seen[target - v] { return (j, i) }
seen[v] = i
}
returnnil
}
// Frequency map - reduce(into:) avoids copying the dictionary each steplet counts = text.reduce(into: [Character: Int]()) { $0[$1, default: 0] += 1 }
// Group anagrams: build a canonical keylet groups = Dictionary(grouping: words) { String($0.sorted()) }
// First non-repeating characterlet 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 elementslet 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 timesvar running = 0, total = 0for 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) eachvar diff = [Int](repeating: 0, count: n + 1)
func addRange(_ l: Int, _ r: Int, _ v: Int) { diff[l] += v; diff[r + 1] -= v }
letfinal = 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).
structInterval { 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 {
ifvar 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 tiesvar current = 0, best = 0for (_, 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 {
ifletopen = pairs[ch] {
guard stack.popLast() == openelse { returnfalse }
} else {
stack.append(ch)
}
}
return stack.isEmpty
}
// Monotonic decreasing stack: index of the next greater elementfunc nextGreater(_ a: [Int]) -> [Int] {
var result = [Int](repeating: -1, count: a.count)
var stack: [Int] = [] // indices, values decreasingfor i in a.indices {
whilelet 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).
importCollections// swift-collections// k largest without sorting everythingfunc kLargest(_ nums: [Int], _ k: Int) -> [Int] {
var heap = Heap<Int>() // min-heap side used as the gatefor 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.
finalclassNode { 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?.nextif slow === fast { returntrue } // identity, not equality
}
returnfalse
}
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
whilelet node = current {
current = node.next
node.next = prev
prev = node
}
return prev
}
Trees: DFS and BFS
Recursion for depth, a queue for breadth.
finalclassTreeNode { 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]) {
guardlet node else { return }
inorder(node.left, into: &out); out.append(node.val); inorder(node.right, into: &out)
}
// BFS level order - the queue-of-levels trickfunc levelOrder(_ root: TreeNode?) -> [[Int]] {
guardlet 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 parentfunc isValid(_ n: TreeNode?, _ lo: Int = .min, _ hi: Int = .max) -> Bool {
guardlet n else { returntrue }
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.
typealiasGraph = [Int: [Int]]
// Shortest path in an UNWEIGHTED graph = BFSfunc shortestPath(_ g: Graph, from s: Int, to t: Int) -> Int? {
var visited: Set = [s], queue = [(s, 0)], head = 0while 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))
}
}
returnnil
}
// Kahn's algorithm: topological order, and cycle detection for freefunc 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 = 0while head < queue.count {
let node = queue[head]; head += 1
order.append(node)
for next in g[node, default: []] {
indegree[next] -= 1if 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 = 0func 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 placefor (dr, dc) in directions { flood(r + dr, c + dc) }
}
for r in grid.indices {
for c in grid[r].indiceswhere 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.
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.indiceswhere !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) spacefunc rob(_ nums: [Int]) -> Int {
var skip = 0, take = 0for 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 in0...x.count { dp[i][0] = i } // delete everythingfor j in0...y.count { dp[0][j] = j } // insert everythingfor 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 }
iflet 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.
Prefix queries in O(length), independent of dictionary size.
finalclassTrie {
privatefinalclassNode {
var children: [Character: Node] = [:]
var isWord = false
}
privatelet 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
}
privatefunc node(matching prefix: String) -> Node? {
var node = root
for ch in prefix {
guardlet next = node.children[ch] else { returnnil }
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 onelet 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 set1 << i // bit i// Enumerate every subset of n elements with a bitmaskfor mask in0..<(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 indexfunc findMissing(_ nums: inout [Int]) -> Int {
var i = 0while i < nums.count {
let target = nums[i] - 1if 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.
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.
structRingBuffer<Element> {
privatevar storage: [Element?]
privatevar head = 0private(set) var count = 0let capacity: Intinit(capacity: Int) {
self.capacity = capacity
storage = Array(repeating: nil, count: capacity)
}
/// Appends and returns the element that was evicted, if any.@discardableResultmutatingfunc 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 > 0else { 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.0iflet 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.structRunningStats {
private(set) var count = 0private(set) var mean = 0.0privatevar m2 = 0.0mutatingfunc add(_ x: Double) {
count += 1let 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.structEMA {
private(set) var value: Double?
let alpha: Double// 2 / (period + 1)mutatingfunc 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.
structReservoir<Element> {
private(set) var sample: [Element] = []
privatevar seen = 0let capacity: Intinit(capacity: Int) { self.capacity = capacity }
mutatingfunc add(_ element: Element) {
seen += 1if 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.
structCountMinSketch {
privatevar table: [[Int]]
privatelet seeds: [UInt64]
privatelet 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 }
}
privatefunc index(_ key: someHashable, _ 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
}
mutatingfunc add(_ key: someHashable, count: Int = 1) {
for row in table.indices { table[row][index(key, row)] += count }
}
/// Never underestimates; overestimates with probability delta.func estimate(_ key: someHashable) -> 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.
actorSlidingWindowLimiter {
privatevar timestamps: [Date] = []
privatelet limit: Intprivatelet window: TimeIntervalinit(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 windowguard timestamps.count < limit else { returnfalse }
timestamps.append(now)
returntrue
}
}
// 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 inlet task = Task {
var pending: Task<Void, Never>?
forawait value in source {
pending?.cancel() // a newer value cancels the old timer
pending = Task {
try? awaitTask.sleep(for: interval)
guard !Task.isCancelledelse { 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 += 1case.terminated: stopSensor()
@unknowndefault: 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 inlet task = Task {
var buffer: [T] = []
var lastFlush = ContinuousClock.nowforawait 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.
structRecentSet<Key: Hashable> {
privatevar seen: [Key: Date] = [:]
privatevar order: [Key] = []
let ttl: TimeIntervallet capacity: Int/// Returns true the first time a key is seen inside the window.mutatingfunc insertIfNew(_ key: Key, now: Date = .now) -> Bool {
evict(before: now.addingTimeInterval(-ttl))
guard seen[key] == nilelse { returnfalse }
seen[key] = now
order.append(key)
if order.count > capacity { seen.removeValue(forKey: order.removeFirst()) }
returntrue
}
privatemutatingfunc evict(before cutoff: Date) {
whilelet 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 inlet task = Task {
await withTaskGroup(of: Void.self) { group infor s in streams {
group.addTask { forawait 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.structHysteresis {
let enter: Double, exit: Doubleprivate(set) var isOn = falsemutatingfunc update(_ x: Double) -> Bool? {
if !isOn, x > enter { isOn = true; returntrue }
if isOn, x < exit { isOn = false; returnfalse }
returnnil// 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.
Question
Structure
Memory
Error
How many distinct users?
HyperLogLog
~12 KB for billions
~2% standard error
How often did key X appear?
Count-Min Sketch
width × depth ints
over-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-Saving
O(k)
misses items below n/(k+1)
What's the p95 latency?
t-digest / P² / reservoir
O(sample)
a few % near the tail
A uniform sample of n items
Reservoir sampling
O(k)
exact and unbiased
A rolling average
Ring buffer or EMA
O(k) or O(1)
exact / smoothed
Exact counts, bounded keys
Dictionary
O(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.
sharing with widgets and extensions — needs file coordination
Bundle (read-only)
Bundle.main.url(forResource:withExtension:)
n/a
shipped resources. Never writable
URLs, not paths
`URL` is the modern currency; string paths lose information.
// iOS 16+ / macOS 13+ — the terse accessorslet docs = URL.documentsDirectorylet file = docs.appending(path: "notes/today.txt") // percent-encodes correctly// Older, and still the only way to ask for creation as a side effectlet support = tryFileManager.default.url(for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true) // creates it if missing// Make the parent directory yourself when you build paths by handtryFileManager.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 = tryString(contentsOf: url, encoding: .utf8)
// Bytes, when it isn't textlet data = tryData(contentsOf: url)
// A malformed-UTF8-tolerant decodeguardlet text = String(data: data, encoding: .utf8) else {
throwFileError.notUTF8(url)
}
// Reading is blocking: keep it off the main actornonisolatedfunc load(_ url: URL) throws -> String {
tryString(contentsOf: url, encoding: .utf8)
}
Don't
// TOCTOU race: the answer is already staleifFileManager.default.fileExists(atPath: url.path) {
let text = tryString(contentsOf: url, encoding: .utf8)
}
// Just try, and handle the error:do { text = tryString(contentsOf: url, encoding: .utf8) }
catchCocoaError.fileNoSuchFile { text = "" }
// Deprecated / lossyString(contentsOfFile: path) // no encoding, deprecatedtry! String(contentsOf: url) // crashes on a missing file// Blocking the main actor on a 50 MB file@MainActorfunc 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 spellingtry text.write(to: url, atomically: true, encoding: .utf8)
// Fail instead of clobbering an existing filetry data.write(to: url, options: [.withoutOverwriting]) // throws if it exists// Codable round trip, atomicallylet encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys] // stable bytes -> smaller diffs, cheap dedupetry 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 filetry 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 = tryFileHandle(forWritingTo: temp)
guard fcntl(handle.fileDescriptor, F_FULLFSYNC) != -1else {
try? FileManager.default.removeItem(at: temp)
throwPOSIXError(.EIO)
}
try handle.close()
// 3. atomic swap. replaceItemAt handles the same-volume + backup dance,// and preserves the destination's metadata.
_ = tryFileManager.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.actorAppendLog {
privatelet handle: FileHandleinit(url: URL) throws {
if !FileManager.default.fileExists(atPath: url.path(percentEncoded: false)) {
FileManager.default.createFile(atPath: url.path(percentEncoded: false), contents: nil)
}
handle = tryFileHandle(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() asyncthrows {
let snapshot = tryawait replay() // fold the logtry encode(snapshot).write(to: snapshotURL, options: .atomic)
tryData().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.fortryawait 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 = 0fortryawait line in url.lineswhere !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-delimitedlet handle = tryFileHandle(forReadingFrom: url)
defer { try? handle.close() }
whilelet chunk = try handle.read(upToCount: 1 << 16), !chunk.isEmpty { // 64 KBtry consume(chunk)
}
// Memory-mapped, for random access without loading everythinglet mapped = tryData(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: someSequence<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 = tryFileHandle(forWritingTo: temp)
defer { try? handle.close() }
var buffer = Data()
buffer.reserveCapacity(1 << 18) // 256 KBfor row in rows {
buffer.append(contentsOf: row.csvLine.utf8)
if buffer.count >= 1 << 18 { // amortise the syscalltry handle.write(contentsOf: buffer)
buffer.removeAll(keepingCapacity: true) // keep the allocation
}
}
if !buffer.isEmpty { try handle.write(contentsOf: buffer) }
try handle.close()
_ = tryFileManager.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.
importCollections/// 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) asyncthrows -> [URL] {
var runs: [URL] = []
var buffer: [String] = []
buffer.reserveCapacity(chunkSize)
func spill() throws {
guard !buffer.isEmptyelse { 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)
}
fortryawait 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) asyncthrows {
structHead: Comparable {
let line: Stringlet run: Intstaticfunc < (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 {
iflet first = tryawait 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 = tryFileHandle(forWritingTo: temp)
var buffer = Data()
whilelet 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) }
iflet next = tryawait iterators[smallest.run].next() {
heap.insert(Head(line: next, run: smallest.run))
}
}
try handle.write(contentsOf: buffer)
try handle.close()
_ = tryFileManager.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 = tryFileHandle(forReadingFrom: url)
defer { try? handle.close() }
var position: UInt64 = 0whilelet 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 nothingreturn offsets
}
func line(_ n: Int, in url: URL, index: [UInt64]) throws -> String {
let handle = tryFileHandle(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()
returnString(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 newlinesfunc lastLines(_ count: Int, of url: URL) throws -> [String] {
let handle = tryFileHandle(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
}
returnString(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>()
fortryawait line in url.lineswhere 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 = 0fortryawait line in url.lines {
n += 1if 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 itimportCryptoKitvar hasher = SHA256()
let handle = tryFileHandle(forReadingFrom: url)
whilelet 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]
guardlet walker = FileManager.default.enumerator(
at: root,
includingPropertiesForKeys: keys, // batch-fetched: one stat per file, not three
options: [.skipsHiddenFiles, .skipsPackageDescendants],
errorHandler: { url, error inLogger.files.error("Skipping \(url.lastPathComponent): \(error.localizedDescription)")
returntrue// keep walking
}
) else { return }
var totalBytes = 0forcaselet url asURLin 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 metadatalet values = try url.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey,
.totalFileAllocatedSizeKey])
let bytes = values.fileSize ?? 0let modified = values.contentModificationDate// Keep a big regenerable cache out of iCloud/iTunes backupsvar writable = URLResourceValues()
writable.isExcludedFromBackup = truetry url.setResourceValues(&writable) // note: mutating, takes inout// Data protection: the file is unreadable while the device is lockedtryFileManager.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 downloadlet capacity = tryURL.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 intentsguardlet shared = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.com.example.app")
else { throwFileError.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 intry? data.write(to: writableURL, options: .atomic)
}
iflet 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 { throwFileError.denied }
defer { url.stopAccessingSecurityScopedResource() }
// To keep the permission across launches, persist a bookmark:let bookmark = try url.bookmarkData(options: .minimalBookmark)
var stale = falselet restored = tryURL(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 actorstructFileStore: Sendable {
let root: URLnonisolatedfunc read(_ name: String) throws -> Data {
tryData(contentsOf: root.appending(path: name))
}
}
// Good: one actor owning one file, so writes can't interleaveactorDocumentStore {
privatelet url: URLprivatevar cached: Document?
func save(_ document: Document) throws {
tryJSONEncoder().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 = tryawait withThrowingTaskGroup(of: (URL, Data).self) { group infor url in urls { group.addTask { (url, tryData(contentsOf: url)) } }
returntryawait group.reduce(into: [:]) { $0[$1.0] = $1.1 }
}
Error handling
Match on the specific error instead of checking first.
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.
structFileStoreTests {
/// A unique directory per test instance — Swift Testing runs them in parallel.finalclassSandbox {
let root: URLinit() throws {
root = URL.temporaryDirectory.appending(path: "test-\(UUID().uuidString)")
tryFileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
}
deinit { try? FileManager.default.removeItem(at: root) }
}
@Testfunc atomicWriteLeavesNoTempFiles() throws {
let sandbox = trySandbox()
let target = sandbox.root.appending(path: "doc.json")
tryData("{}".utf8).write(to: target, options: .atomic)
let remaining = tryFileManager.default.contentsOfDirectory(atPath: sandbox.root.path(percentEncoded: false))
#expect(remaining == ["doc.json"])
}
@Testfunc readingAMissingFileThrowsNoSuchFile() {
#expect(throws: CocoaError.self) {
_ = tryData(contentsOf: URL.temporaryDirectory.appending(path: "nope"))
}
}
}
Core Bluetooth (BLE)
Central role end to end, the GATT model, and the traps that fail interviews.
Every BLE bug is a step done out of order. Left to right, then wrap to row two.
GATT object model
Peripheral → Service → Characteristic → Descriptor. Everything else is detail.
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`.
importCoreBluetoothfinalclassBLEClient: NSObject {
privatevar central: CBCentralManager!
privatevar peripheral: CBPeripheral? // MUST be retained or it deallocatesprivatelet hrService = CBUUID(string: "180D")
privatelet hrMeasure = CBUUID(string: "2A37")
overrideinit() {
super.init()
// A nil queue means every callback arrives on the MAIN queue.
central = CBCentralManager(delegate: self, queue: .main,
options: [CBCentralManagerOptionShowPowerAlertKey: true])
}
}
extensionBLEClient: 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) {
guardRSSI.intValue > -70else { 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
extensionBLEClient: CBPeripheralDelegate {
func peripheral(_ p: CBPeripheral, didDiscoverServices error: Error?) {
guard error == nil, let services = p.serviceselse { 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.valueelse { 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.staticfunc parseHeartRate(_ data: Data) -> Int {
let is16Bit = data[0] & 0x01 == 1return 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.canSendWriteWithoutResponseelse { 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.
protocolBluetoothClient: Sendable {
func scan(services: [CBUUID]) -> AsyncStream<DiscoveredDevice>
func connect(_ id: UUID) asyncthrowsfunc values(for characteristic: CBUUID) -> AsyncStream<Data>
func write(_ data: Data, to characteristic: CBUUID) asyncthrows
}
// Live implementation bridges each delegate callback:privatevar discoveries: AsyncStream<DiscoveredDevice>.Continuation?
func scan(services: [CBUUID]) -> AsyncStream<DiscoveredDevice> {
AsyncStream { continuation in
discoveries = continuation
continuation.onTermination = { [weakself] _ inself?.central.stopScan() }
central.scanForPeripherals(withServices: services)
}
}
// One-shot calls use a continuation, keyed so the delegate can resume it:privatevar connectContinuations: [UUID: CheckedContinuation<Void, Error>] = [:]
func connect(_ id: UUID) asyncthrows {
tryawait 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.