Review Swift
Swift review that covers what the compiler and SwiftLint cannot decide for you — how state is modeled, whether optionals and errors carry the right information, whether isolation and ownership reflect real intent, and whether an escape hatch is papering over a design problem.
Run on .swift files. Complements review-cleancode (SOLID/DRY/smells) — don't repeat it.
Relationship to the toolchain (read first)
Three layers already cover the mechanical work. Your job is what's left.
1. The compiler. In Swift 6 language mode (or -strict-concurrency=complete), data-race safety is enforced: non-Sendable values crossing isolation boundaries, non-isolated global/static mutable state, actor state accessed from another domain, deinit isolation, @Sendable closure captures. Never report these as review findings when that mode is on.
2. SwiftLint — but check what's actually enabled. Many safety-relevant rules are opt-in, so in a default config they do not run. Verified against SwiftLint 0.55.1 (swiftlint rules):
| Rule | Opt-in? |
|---|---|
force_cast, force_try |
on by default |
identifier_name, type_name, line_length, function_body_length, type_body_length, cyclomatic_complexity |
on by default |
redundant_void_return, redundant_optional_initialization |
on by default |
force_unwrapping, implicitly_unwrapped_optional |
opt-in |
unowned_variable_capture, weak_delegate, strong_iboutlet |
opt-in |
first_where, last_where, contains_over_filter_count, empty_count, toggle_bool |
opt-in |
discouraged_optional_boolean, discouraged_optional_collection, redundant_type_annotation |
opt-in |
unhandled_throwing_task |
opt-in |
Recent SwiftLint versions add async_without_await, incompatible_concurrency_annotation, redundant_sendable, and rename some rules. Read the project's .swiftlint.yml rather than assuming — suppress a mechanical finding only if that project actually runs the rule. If it doesn't, fold it into the one-time recommendation below instead of reporting it per-occurrence.
SwiftLint has no SwiftUI property-wrapper or ownership rules, so all of §4 is yours.
3. Xcode runtime diagnostics catch some SwiftUI misuse at runtime (off-main-thread ObservedObject/StateObject mutation, cross-actor Binding access, StateObject accessed without being installed on a view). These are runtime issues, not review findings. Note they reportedly have no @Observable equivalent — so migrating to @Observable loses that safety net, which makes §4 review more important, not less.
The baseline read is a silencer, not a finding. Read the config so you don't report what a rule already covers. Recommending a lint or language-mode change is a project-level action — see §P — and belongs in an --all audit, not a diff review.
What this skill checks
Swift design decisions are made once; the ways they get violated recur with every change. So the default review looks only at what the diff introduces — a new struct whose fields are correlated, a new ! on external data, a new Task with no owner, a new @State holding a passed-in object. Whole-module properties (is SwiftLint configured, is the target on Swift 6 mode, is the public surface coherent) belong to §P, which runs at --all and nowhere else.
Usage
/review-swift # Review context-related code
/review-swift --staged # Review staged changes
/review-swift --unpushed # Review files changed across all unpushed commits
/review-swift --changed # Review unstaged changes
/review-swift --all # Full codebase audit (parallel agents)
/review-swift --multi # Also get external advisor opinionsScope
| Flag | Scope | Method |
|---|---|---|
| (none) | Context-related code | Files from the current conversation context. If no context, ask the user to specify files or use --staged/--changed/--all. |
--staged |
Staged changes | git diff --cached --name-only |
--unpushed |
Files changed across unpushed commits | git diff --name-only $(git rev-list HEAD --not --remotes | tail -1)^..HEAD |
--changed |
Unstaged changes | git diff --name-only |
--all |
Full codebase + §P project-level checks | Glob *.swift, parallel agents |
--multi |
Add external opinions | Combines with any scope above; invokes second-opinion --quick |
--unpushed derives its range from git rev-list HEAD --not --remotes (oldest unpushed commit's parent → HEAD). If nothing is unpushed, or there is no remote/upstream (or the range walks back to the root commit) so it can't be determined reliably, stop and ask the user to pick another scope. Restrict the resolved file list to .swift before reviewing.
Workflow
Determine scope (see table) and filter to Swift files only.
Read CLAUDE.md in the repo root for project conventions.
Establish the baseline — do this before reading any code. Since Swift 6.2 the same source text means different things depending on per-module settings, so isolation is unreviewable without them. Check:
- Language mode / strict concurrency —
Package.swift(swiftLanguageMode(.v6),.enableUpcomingFeature,.unsafeFlags) or Xcode'sSWIFT_VERSION/SWIFT_STRICT_CONCURRENCY. - Default actor isolation —
-default-isolation MainActor/SwiftSetting.defaultIsolation(MainActor.self)(SE-0466). If on, unannotated code is@MainActorand your default reading inverts. NonisolatedNonsendingByDefault(SE-0461) — changes wherenonisolated asyncfunctions run..swiftlint.yml— which rules actually run.- Mixed-mode workspaces are common (a package on v6, the app target on v5). If genuinely ambiguous, review concurrency as if strict checking is off (more findings) and say so in the Baseline line.
- Language mode / strict concurrency —
Review each file against the categories below — against what the diff introduces. Add §P only at
--all. Load the matching reference file when the code touches that area.Parallelize if scope has >5 files: one sub-agent per category, merge and dedupe.
External opinions (if
--multi): invokesecond-opinion --quickwith this prompt:Read-only Swift review. Assume SwiftLint and the Swift compiler already handle mechanical rules — do NOT repeat lint-level or plain compiler-diagnostic findings. Focus on DESIGN judgment: are invalid states representable that should be modeled away with an enum + associated values? Are structs/classes chosen for the right semantics? Do optionals and error types carry the information callers need, or is failure flattened to nil? Does isolation reflect real ownership? Are there state assumptions across an `await` inside an actor? Do any `!`, `as!`, `try!`, `@unchecked Sendable`, or `nonisolated(unsafe)` hide a wrong upstream type or an unchecked invariant? 300 words or less.Wait for all external results before proceeding.
Classify severity and report, grouped by severity.
Checklist (judgment only)
1. State & type modeling
The core value. The compiler checks that types are consistent; only a human checks that they're right.
- Invalid states are representable.
struct LoadState { var isLoading: Bool; var value: T?; var error: Error? }permits loading-with-value-and-error. Recommend anenumwith associated values (case idle, loading, loaded(T), failed(Error)) so illegal states won't compile. Swift's single highest-leverage modeling move. - Bag-of-optionals instead of an enum — a struct where "if
kind == .circlethenradiusis set" is enforced by convention, not by the type. - Value vs. reference semantics chosen by habit. A
classholding inert data with no identity or shared mutation should be astruct; astructwhose copies silently diverge where callers assume one shared instance is the reverse bug. Watch the leak: a struct holding a class has value semantics only at the top level. - Stringly-typed and in-band sentinels. Raw
String/Intwhere an enum or wrapper type prevents mix-ups;-1/""/0overloaded to mean "none"; interchangeableStringIDs swappable at a call site. Recommend wrapper types only where mix-ups are a real risk — note the ergonomic cost. - Types wider than callers need —
[Item]?where "possibly empty" suffices;Any/[String: Any]at an internal boundary. Liberal in inputs, strict in outputs. static varis global mutable state — preferstatic letor a computed property.switchwithdefaultover an enum you own — enumerating cases forces every site to be reconsidered when a case is added.- Access control as design.
public/opensurface that leaks internal types or was public by default rather than by decision;internalmutable state that should beprivate(set). - Naming, per the Swift API Design Guidelines — only where it genuinely misleads, not as a style sweep. Real rules: mutating/non-mutating pairs (
sort()/sorted(),stripNewlines()/strippingNewlines(),union/formUnion); non-mutating methods read as noun phrases (x.distance(to: y)); factory methods begin withmake; argument labels form a phrase at the call site. There is no "don't prefix withget" rule — it isn't in the guidelines; don't flag it. - Docs as a design signal — "If you struggle to describe an API simply, you may have designed the wrong API." Also flag undocumented computed properties that aren't O(1).
2. Optionals & error modeling
- Optional as a silent failure channel.
T?where the caller needs to know why it failed shouldthrowor returnResult. - Nested/double optionals and optional-of-collection —
[Item]??, or[Item]?where empty already means empty. - Optionality scattered through the interior instead of normalized at the perimeter (decoding, network, DB), so interior code deals in non-optional values.
- Error types that erase information — a
catchmapping every failure to one generic case, or an API throwing untypedErrorwhere callers must string-match. - Typed throws: ask whether it's justified, not whether it's missing. SE-0413 is explicit that "the existing (untyped)
throwsremains the better default error-handling mechanism for most Swift code," and warns: "Resist the temptation to use typed throws because there is only a single kind of error that the implementation can throw." The three sanctioned cases are within-module/package implementation detail, generic code passing errors through, and constrained/embedded environments. Flag unjustifiedthrows(SomeError)on an evolving API — it constrains future implementations. - Swallowed errors —
try?discarding a failure that mattered, emptycatch {}. Distinguish "genuinely don't care" from "lost the diagnostic". - Assertion ladder —
assert+ logging when recoverable;precondition/fatalErrorwhen not;fatalErrorspecifically when the message is dynamic (preconditionwon't surface one in the crash log).assertcompiles out in release, so anassertguarding an invariant that matters in production is a finding. - Codable design — all-or-nothing array decoding, raw-value enums that fail on unknown values, partial
CodingKeyssilently dropping fields,try?erasingDecodingError, and whetherinit(from:)validates or merely parses. Seereferences/memory-and-performance.md§4.
3. Concurrency & isolation intent
Load references/concurrency.md when reviewing this area — it carries the version matrix, the official quotes, and the full pattern catalogue.
The essentials:
- Do not flag broad
@MainActorapplication. Official guidance runs the other way: "It is completely normal for programs with a user interface to have a large set ofMainActor-isolated state," and under-isolation is called the most common latent problem. The real defect is blocking the main actor with long synchronous work.nonisolatedalone is not an unsafe keyword. - State assumptions across an
awaitinside actor-isolated or@MainActorcode — the highest-value blind spot, explicitly out of scope for the compiler. Check-then-act, read-modify-write, and set-a-flag-after-awaiting are the three shapes. @unchecked Sendable/nonisolated(unsafe)as silencers rather than as documented proof obligations over real synchronization. Prefer scoping the hatch to one property over disabling checking for the whole type.- Unstructured
Taskwith no lifetime owner — nothing stores or cancels it. Cancellation is neither inherited nor automatic, and is cooperative (nothing happens unless something checks).Task { [weak self] in guard let self else { return } }is a no-op. - Design smells — stateless actors, split isolation (some properties
@MainActor, some not),MainActor.runwhereawaitsuffices,assumeIsolatedin new 6.2+ code,DispatchSemaphoreblocking on async work.
4. SwiftUI — identity, lifetime, dependencies
Load references/swiftui.md for the full pairing table and identity rules. Apple's own lens is Identity · Lifetime · Dependencies.
- Ownership is the judgment; the property wrapper is downstream of it. With
@Observable:@Statefor the object the view creates, a plain property for one passed in,@Bindablefor bindings,@Environment(T.self)from the environment.@Stateholding an@Observableclass is the documented correct pattern — flag it only for a non-@Observablereference type, or for a passed-in object (which@Statewill silently pin to its first value). - Duplicated source of truth copied into local
@Statethat then drifts. @ObservedObjecton a view-created object (legacy stack);@ObservedObject/@StateObjectwrapping an@Observabletype (half-migrated).- Expensive work in
@Statedefaults or viewinit—@Statedoes not memoize the way@StateObjectdid. - Identity churn —
AnyViewdestroying structural identity,id: \.self(a hash — mutating any field resets state), index/offset as identity, unstable.id(). - Lifetime assumptions — lazy-stack row
@Stateis destroyed on scroll (Apple: "don't depend on view state for data that needs to be kept alive after scrolling");onAppearhas no once-only guarantee. - Do not raise "migrate to
@Observable" on existing code —ObservableObjectis legacy, not deprecated, and mixing is endorsed.
5. ARC & ownership
Load references/memory-and-performance.md for the full treatment.
- A cycle exists only when the callee retains the closure. A closure stored as a property of the object it captures is a cycle; the same closure in
asyncAfteris fine strong — and marking itweakthere is silently broken. Ask who retains whom, not "is there a[weak self]". - Method references used as closures (
self.handler = doThing) create cycles the compiler does not diagnose. - Delegates — ask who owns the object.
weakis wrong for a helper the object created. - Over-use of
weakis itself a defect — "actively harmful to use weak references in places where they aren't needed." unownedis contested and not faster — decide on lifetime semantics, never performance.- Capture lists snapshot value types at creation, a silent staleness bug when later mutation is expected.
6. Escape hatches — the judgment residue
Lint flags the presence of !, as!, try! (when enabled). What's left:
- A force-unwrap that compiles but hides a wrong upstream type.
URL(string: endpoint)!becauseendpointis aStringthat should have been aURLthree layers up. The fix is upstream. Highest-value finding here. as!/as?standing in for a modeling gap — a downcast offAnyor an untyped dictionary means the container type is wrong. Say what the right type is.- A force-unwrap or
try!at a trust boundary — decoding, network payloads,Bundleresources,UserDefaults. Distinguish from a genuinely-safe unwrap of a compile-time-known literal. fatalError/preconditionFailureon a reachable path — fine for programmer error, a production crash when the "impossible" case is external input.- IUOs surviving past initialization — used as ordinary storage rather than as a two-phase-init workaround.
@retroactiveconformances — two modules doing it conflict at runtime.
State your reasoning. "Force-unwrap hides that configURL is modeled as String? when it's always a valid URL" is a finding; "there's a force-unwrap here" is not.
Conditional: noncopyable types
~Copyable, borrowing/consuming (Swift 5.9+) are genuinely niche — file handles, locks, once-only operations. One question at most: does this represent a resource that must not be duplicated? Treat unexplained borrowing/consuming on ordinary copyable code as noise; SE-0377 notes adding or removing them "does not have any source-breaking effects," so they're performance annotations, not contracts. No authoritative review guidance exists for macros — don't invent any.
P. Project-level checks — --all only
Whole-module properties. They change rarely, so raising them on a diff review buries the findings that matter. Never report these outside an --all audit.
- Toolchain configuration. No
.swiftlint.yml; the safety opt-ins missing (force_unwrapping,implicitly_unwrapped_optional,unowned_variable_capture); the target not on Swift 6 language mode or-strict-concurrency=complete. One recommendation covers the whole mechanical class better than eyeballing diffs ever will. - Public surface coherence. In a package (
Package.swift), sweep what ispublicversus what needs to be:publicsurface that leaks internal types,publicwhereinternalwas the intent, mutablepublic varthat should beprivate(set), and missing@frozen/@inlinabledecisions on a library that has made an ABI commitment. Adding a case to apublic enuma client switches over is a source break — worth one whole-module pass, not a per-diff nag. - Naming against the API Design Guidelines, as a sweep rather than a style hunt: mutating/non-mutating pairs (
sort()/sorted(),union/formUnion), factory methods beginning withmake, non-mutating methods reading as noun phrases. Flag only where it genuinely misleads — and remember there is no "don't prefix withget" rule. @retroactiveconformances across the module — two modules conforming the same external type conflict at runtime, which only a whole-module view surfaces.- Test posture — whether
sleep-based synchronization, real clock, network or home-directory access appear across the suite. Do not demand migration off XCTest; it is not deprecated and still has no equivalent for UI automation or performance testing.
Do NOT flag these
- Anything in §P, on a diff review. Lint configuration, language mode,
publicsurface and naming sweeps are module properties; this diff did not change them.
Common reviewer instincts that are wrong or unsupported. See references/memory-and-performance.md §7 for sources.
finalfor speed — WMO already infers it. Legit reasons are enforcement and API semantics.ContiguousArrayeverywhere — identical efficiency for struct/enum elements.@inlinablefor speed — it's an ABI commitment, and SwiftPM has conservative CMO by default.- Blanket
reserveCapacity— inside a loop it makesappendO(n²). - "String
+=is quadratic" — folklore. - Broad
@MainActor— see §3. - "Don't prefix with
get" — not an API Design Guidelines rule. - Demanding
-strict-memory-safety— officially "best left for projects with the strongest security requirements." - Demanding migration off XCTest to swift-testing — XCTest is not deprecated and still has no equivalent for UI automation, performance testing, or Objective-C exception handling.
- Anything the project's enabled SwiftLint rules or Swift 6 mode already catch.
Severity
- Critical: crashes or corrupts at runtime — force-unwrap/
try!on external data,@unchecked Sendableover genuinely unsynchronized shared mutable state, illegal state reachable in a load-bearing model. - High: will cause bugs as the code evolves — invalid states representable, state assumption across a suspension, unstructured
Taskwith no cancellation owner, retain cycle, wrong SwiftUI source of truth, downcast hiding a wrong upstream type. - Medium: optional/error modeling, value/reference semantics mismatch, Codable failure handling, access-control leaks, unjustified typed throws, identity churn.
- Suggestion: single-conformer protocols,
any→some, wrapper types, naming polish where the code is awkward rather than buggy.
Output Format
## Swift Review: {scope}
### Baseline
{Language mode / default isolation / strict concurrency, and any assumption you had to make — these change how the code *reads*, so state them whenever they were ambiguous. **At `--all` only:** the §P results, including the toolchain recommendation. On a diff review, omit this block if the isolation picture was unambiguous.}
### Critical (crash / data race / corruption reachable)
- {file}:{line} — {category}: {description}
**Why it's not a lint or compiler finding:** {what judgment this needed}
**Impact:** {what breaks}
**Fix:** {model change / isolation change / real failure path — with code}
### High (design problems that will cause bugs)
- {file}:{line} — {category}: {description}
**Fix:** {solution}
### Medium (optionals, errors, semantics, ownership)
- {file}:{line} — {category}: {description} — {suggested change}
### Suggestions
- {opportunities}If --multi was used, append one subsection per advisor that responded (titled with the advisor's name as reported by second-opinion), then a Cross-Model Agreement subsection.
Examples
Invalid state representable:
/review-swift --staged
Finds struct FeedState { var isLoading: Bool; var items: [Item]?; var error: Error? } — nothing prevents isLoading == true with both items and error set, and the view has three if branches guessing. Reports High with an enum FeedState { case idle, loading, loaded([Item]), failed(Error) } rewrite. Not a lint finding: the struct is perfectly valid Swift.
Force-unwrap hiding a mistyped source:
/review-swift --changed
Finds URLRequest(url: URL(string: config.host + path)!) where config.host is a String decoded from a plist. Reports Critical — decode host as a URL or validate once at config load, rather than guarding each call site. force_unwrapping is opt-in and may not even be running; even when it is, it only says "there's a !".
State assumption across a suspension:
/review-swift
Finds an actor cache whose value(for:) checks a dictionary, awaits a network fetch, then writes back. Reports High — concurrent callers each see an empty cache and issue duplicate requests. Fix: store the in-flight Task so later callers await the same one. The compiler is silent; this is explicitly outside its scope.
Troubleshooting
Findings overlap with SwiftLint or the compiler
Solution: Check whether the project actually runs that rule — many safety rules are opt-in and off by default. If it does, drop the finding. If it doesn't, fold it into the one-time baseline recommendation rather than reporting each occurrence.
Can't tell which concurrency dialect the target uses
Solution: Check Package.swift for swiftLanguageMode / defaultIsolation / .enableUpcomingFeature, and Xcode for SWIFT_VERSION, SWIFT_STRICT_CONCURRENCY, and the Approachable Concurrency / Default Actor Isolation settings. Mixed-mode workspaces are normal. If ambiguous, review as if strict checking is off and say so in the Baseline line.
Can't tell if a force-unwrap is safe
Solution: Trace the value's origin. Compile-time literal or a value the same function just built → probably fine. Decoded, networked, user-supplied, or resource-loaded → recommend a real failure path.
Notes
- Model first, isolate second, force-unwrap never. Most escape-hatch findings dissolve once the underlying type is modeled correctly.
- Respect project conventions in CLAUDE.md (a codebase may deliberately use IUOs for two-phase init or standardize on
Result). - Don't be dogmatic: wrapper types, typed throws,
some-over-any, and noncopyable types all have costs — recommend them where they prevent real bugs. - Test code: a force-unwrap of test-supplied fixture data or a
setUp-assigned IUO is defensible. A force-unwrap of a value produced by the system under test should betry #require(x)/try XCTUnwrap(x)— a crash loses the diagnostic and, under swift-testing's in-process parallelism, can take down concurrently running tests. Also flagsleep-based synchronization and real clock/network access in unit tests. - Sources: Swift API Design Guidelines, the Swift 6 Concurrency Migration Guide, and swift-evolution proposals (swift.org); Apple SwiftUI documentation and WWDC sessions; Matt Massicotte's problematic-patterns catalogue; the Airbnb Swift style guide's judgment-only subset. SwiftLint and swift-format own the mechanical rules.