Swift code review: the practical guide (2026)

Swift code review: the practical guide (2026)

Swift review is hard for a specific reason: the compiler already caught the easy stuff. Anything that survives to a pull request compiles, type-checks, and probably passes swiftlint. What's left is the category of bug a diff doesn't make obvious - a retain cycle that only shows up in Instruments three screens later, a @StateObject that gets recreated on every parent redraw, an actor that reads stale state because something changed underneath an await. None of that trips a compiler error. All of it reaches production regularly.

That's also where most AI code review tools fall down. They're pattern-matchers trained mostly on Python and JavaScript, and Swift concurrency and SwiftUI have moved fast enough in the last few years that a lot of what they've memorized is already out of date. More on that below - but first, the checklist, because that's the part worth bookmarking.

The Swift code review checklist

This is organized the way a review actually goes: memory, then optionals, then SwiftUI, then concurrency, then the rest. Each section has what a reviewer is actually looking for, not just the rule name.

Memory & retain cycles

What reviewers look for: any closure captured by a reference type that could outlive the thing it's capturing, and any two objects that can hold strong references to each other.

  • Escaping closures (completion handlers, Combine sinks, anything stored for later) that reference self should almost always capture [weak self]. [unowned self] is only correct when you can prove self outlives the closure - a narrower case than most PRs that use it.
  • Delegates should be weak var, full stop. A strong delegate reference is still the most common retain cycle in UIKit-adjacent Swift code.
  • Watch for parent-child cycles without closures: a view controller strongly holding a child that strongly holds it back, or a manager stored on a view that the manager also references.
  • After guard let self else { return }, self is a strong reference for the rest of that scope - fine, but worth checking that scope doesn't itself escape further.

Optionals & force-unwraps

What reviewers look for: any ! that isn't provably safe, and any implicitly unwrapped optional that's reachable before it's guaranteed to be set.

  • Force-unwraps (foo!) should have a clear reason - a preceding precondition, or an obviously-safe context. Anything else should be guard let, if let, or nil-coalescing.
  • Implicitly unwrapped optionals (var label: UILabel!) are fine for @IBOutlet-style properties set before first use. Flag them anywhere they might be read before that setup point, especially in initializers that call other methods.
  • Failable initializers (init?) should fail rather than force-unwrap past bad input - an init that force-unwraps something plausibly nil is a hidden crash waiting for real data.
  • Chained optional access (a?.b?.c) is fine; watch for a chain that silently swallows a nil case the caller actually needed to know about.

SwiftUI state correctness

What reviewers look for: the property wrapper that matches who owns the data and how long it lives, and a single source of truth for anything that can be looked at two ways.

  • @State is for value-type state owned by the view itself, and should almost always be private. It shouldn't hold reference types that need identity across view updates.
  • @StateObject creates and owns an observable object for the lifetime of the view - use it exactly once, at the point of creation. @ObservedObject is for an object passed in that something else owns. Mixing these up is the classic bug: a view declares @ObservedObject where it should own the object with @StateObject, so the parent's re-render recreates it and silently loses state.
  • @Binding is for two-way access to state owned by a parent. A view mutating something it was only handed as a plain value, instead of a binding, is a sign the ownership model is wrong.
  • @Environment is for values injected down the view tree (theme, locale, a shared service) - watch for it standing in for proper dependency injection, or environment reads that make a view harder to preview or test in isolation.
  • The @Observable macro (replacing ObservableObject + @Published) changes the rules again: property-level observation instead of whole-object invalidation, and it interacts differently with @State vs @Bindable. A PR mixing @Observable and the older ObservableObject pattern in the same feature is worth a second look.
  • Never mutate @State (or anything else driving the view) from inside body. body should be a pure function of its inputs - a state mutation triggered from within it, directly or via a side-effecting computed property, causes update loops that are miserable to debug from a stack trace.
  • Two properties tracking overlapping information (isLoading and error that can both be true, a selectedItem that can disagree with an index) signal that state should collapse into one enum-backed source of truth.

SwiftUI view/logic separation

What reviewers look for: a body that's mostly declarative layout, with anything resembling business logic pulled out.

  • Formatting, filtering, sorting, and networking calls inline in body are a smell even when they work - they re-run on every view update, not once.
  • Large body implementations should decompose into smaller subviews or computed view properties. SwiftUI's diffing works better against smaller, stable view identities, and the screen's intent reads at a glance.
  • Logic that decides what to show belongs in a view model or plain function that's unit-testable without instantiating SwiftUI. If verifying "wrong text when the cart is empty" requires rendering a view, the logic was in the wrong place.

Concurrency - async/await

What reviewers look for: code that assumes something about which thread or actor it resumes on after an await, and loops that accidentally serialize or accidentally over-parallelize.

  • Execution after await doesn't necessarily resume on the same thread it suspended on. Code implicitly relying on "still on the main thread" after an await, because it happened to work in testing, is a real bug.
  • A for loop that awaits a network call per iteration runs serially. If the calls are independent, that's usually wrong - async let or a TaskGroup gets genuine concurrency. Conversely, a TaskGroup mutating shared state at the end (appending to a plain array from multiple tasks) is a data race.
  • Multiple dependent awaits should read top-to-bottom in the order they actually need to happen - easy to reorder for readability and quietly change what's awaited before what.

Actors & data races

What reviewers look for: whether an actor is actually protecting something, and whether its isolation assumptions hold across every await inside it.

  • Actor isolation prevents concurrent access to actor state, but not state changing while a method is suspended at an await. This is actor reentrancy: two calls into the same actor method can interleave across an await, and code that reads a value, awaits, then acts on that value as if nothing changed is a real and common bug.
  • Sendable conformance on types crossing actor or task boundaries isn't paperwork - a type marked Sendable (or @unchecked Sendable) that isn't actually safe to share defeats the compiler's one useful check here. Any @unchecked Sendable deserves a specific justification.
  • An actor with no mutable state to protect doesn't need to be an actor - it's suspension overhead for no isolation benefit. Smaller issue, still worth flagging.

@MainActor discipline

What reviewers look for: UI-touching code that's properly isolated to the main actor, and non-UI code that isn't isolated to it unnecessarily.

  • Anything touching UIKit/AppKit views, or SwiftUI state that drives the view, should run on @MainActor. Code that hops to a background context and touches UI without hopping back is a crash or a race depending on what it touches.
  • The opposite mistake is just as common: marking an entire type or file @MainActor because it made the compiler stop complaining. That serializes everything in that type onto the main actor - including CPU-bound work that has no business there - and can visibly freeze the UI. Scope @MainActor to what actually needs it, not blanket-applied to silence warnings.

Task lifecycle & cancellation

What reviewers look for: tasks whose lifetime is tied to something meaningful, and code that actually checks for cancellation rather than assuming it doesn't matter.

  • Structured concurrency (.task {} on a SwiftUI view, child tasks in a TaskGroup) ties a task's lifetime to its parent scope - a .task cancels automatically when the view disappears. Unstructured tasks (Task { } fired from a button action, handle unheld) don't get that for free, and a long-running one can keep referencing view state well after the user has navigated away.
  • Long-running work should check Task.isCancelled (or let try Task.checkCancellation() throw) at reasonable points, particularly in loops. A cancelled task that keeps burning CPU or network is a bug.
  • Watch for unbounded task fan-out - a Task per item in a large collection with no concurrency limit. Fine with ten items, falls over with ten thousand.

Continuation safety

What reviewers look for: exactly one resume, on every code path, guaranteed.

  • withCheckedContinuation / withCheckedThrowingContinuation (bridging callback-based APIs into async/await) must call resume exactly once. Resuming twice crashes; never resuming leaks the awaiting task. Every early-return, error branch, and callback the wrapped API might fire more than once needs accounting for.
  • Prefer the checked variant over the unchecked one - it traps on a double-resume in debug builds instead of silently corrupting behavior, worth the trivial overhead.

Error handling

What reviewers look for: errors that are actually handled, not just made to stop appearing.

  • try? silently discards the error, turning a failure into a nil. Right tool when the caller truly doesn't care why - wrong tool when it's really just making a warning or do-catch block go away.
  • An empty catch {} block is close to the worst version of this: the failure is now invisible. Every catch should handle the error meaningfully, log it, or rethrow.
  • Custom error types should carry enough to act on - a generic enum AppError: Error { case somethingWentWrong } thrown from ten call sites tells the next person nothing about which one fired.

Value vs reference semantics

What reviewers look for: whether the type's semantics match what the code actually relies on.

  • struct gives value semantics (copies are independent); class gives reference semantics (shared mutable state). A model type that's a class out of habit, mutated in one place while another part of the code still holds an "old" reference expecting it unchanged, is a recurring source of bugs.
  • The inverse happens too: a struct holding a reference type (a class-based cache, a Set of AnyObject) can look value-typed while actually sharing mutable state through that one property. Worth a second look whenever a struct holds anything that isn't itself a value type.

Idiom & naming

What reviewers look for: whether the code reads the way idiomatic Swift is expected to read.

  • Swift API Design Guidelines still apply: methods read as grammatical phrases at the call site, parameter labels omit redundant type info, booleans read as assertions (isEmpty, not checkEmpty).
  • Access control should be as narrow as possible - private/fileprivate by default, public/open only at real module boundaries. A PR that widens access "just in case" is worth asking about.
  • Magic numbers and repeated hardcoded strings (timeouts, retry counts, API paths) should be named constants.
  • Deprecated APIs are worth flagging even when they still compile - Apple's frameworks deprecate aggressively, and today's warning is often tomorrow's removal.

The hygiene layer

SwiftLint, Xcode Previews, unit tests, and accessibility checks all belong in the pipeline, and none substitute for the review above. SwiftLint catches style and some structural smells, not a reentrant actor. Previews confirm a view renders, not that its state model is correct. Tests catch what someone thought to test. None of them reason about a retain cycle spanning three files, or a @StateObject misused two components up the tree - that's what a real review, human or otherwise, is still for.

Why general AI reviewers miss Swift bugs

There are structural reasons AI code review tools tend to be weaker on Swift than on Python or JavaScript, and they're worth understanding rather than just taking on faith:

  • Less Swift in the training data. Public code corpora skew heavily toward web-stack languages. Swift's open-source footprint is smaller by comparison, so models see fewer examples of idiomatic, modern Swift.
  • The language keeps moving. Swift concurrency, SwiftUI's property wrappers, and the @Observable macro are all recent, and Apple iterates on the frameworks around them yearly. A model trained on a year-or-two-old snapshot will confidently suggest APIs that have since been deprecated, renamed, or replaced - sometimes producing Swift that no longer compiles.
  • Benchmarks don't measure it. Most popular AI-coding benchmarks used to tune and evaluate these models are Python-heavy. A model can score well on the benchmarks that get published and still be undertested on Swift specifically.

One anecdote worth including because it's illustrative, not because it's a study: one developer reported that roughly three-quarters of GitHub Copilot's review comments on their Swift pull requests were wrong, including cases where Copilot flagged valid, compilable Swift as broken. That's a single user's experience, not a benchmark result, but it lines up with the structural reasons above - and it's the kind of failure mode worth testing for before trusting any AI reviewer with your Swift PRs.

How CodePulse reviews Swift

CodePulse runs language-server-backed analysis for Swift, using sourcekit-lsp - the same engine Xcode itself uses for type information, cross-references, and semantic understanding. Instead of pattern-matching source text, it reasons about the real Swift types, retain relationships, and framework APIs in front of it, rather than guessing from what similar-looking code usually does. That's the gap covered above: it's why LSP-backed review catches the retain cycle, the actor reentrancy bug, and the @StateObject misuse a text-pattern reviewer tends to miss.

Two other things matter for the workflow. CodePulse can submit an actual approving review on GitHub, or request changes, rather than only leaving comments - a clean PR gets unblocked instead of waiting on a human to rubber-stamp it. And it's Slack-native: post a PR link in Slack, the review lands on GitHub, and the summary comes back to the same thread, with a fix triggerable from there too.

Try CodePulse for your iOS team

If your team ships Swift and lives in Slack, this is the workflow CodePulse is built around: sourcekit-lsp-backed review depth on the checklist above, submitted straight to GitHub as a real approve-or-request-changes decision, with the summary in the Slack thread where the PR was posted.

Pricing is per developer, per month: BYOK at $6/dev/mo (bring your own Anthropic or OpenAI key), Team at $29/dev/mo (unlimited reviews, frontier model, auto-fix from Slack), and Business at $49/dev/mo (our most capable model on complex PRs, Semgrep SAST pre-review, custom rules).

Start a free trial or post your next Swift PR link in Slack and see the review come back.

Try CodePulse on your next PR

Post a PR link in Slack. Get a real review in seconds — with first-party LSP support for Swift and Kotlin.

Start free