Interview prep · Junior → Senior

Swift Interview Questions

The questions a Swift interviewer actually asks about the language — the type system, optionals, value vs reference types (the load-bearing one), protocols and protocol-oriented programming, generics, closures, ARC and memory management, async/await and actors, error handling, and the classic gotchas — each answered with a short example and a link to the source. This page covers the Swift language and its observable semantics; for the Apple platform it targets see the iOS version reference, and for the toolchain the Xcode version reference. Pair it with the Swift version reference and the open Swift roles on the jobs board.

Difficulty

Junior — expected of anyone writing Swift; the everyday syntax, let/var, and optionals basics.
Mid — the language in practice: value vs reference types, protocols, generics, closures, and error handling.
Senior — ARC and retain cycles, actor isolation and Sendable, opaque vs existential types, and the gotchas.
Difficulty

Showing 0 of 0 questions

1 · Basics & the type system

let vs var — and does let mean the value is immutable? Junior

var declares a reassignable binding; let declares a constant you can assign once. Whether that makes the value immutable depends on the type — and this is where Swift differs sharply from a reference-heavy language. For a value type (a struct, enum, Array, Dictionary), let genuinely freezes the whole value: you can't even call a mutating method or change a stored property. For a reference type (a class), let only freezes the reference — you can't repoint it, but you can still mutate the object's var properties. Type inference fills in the type from the initializer, but the type is fixed at compile time; Swift is statically typed.

let a = 1
// a = 2                       // error: cannot assign to value 'a'

var nums = [1, 2]
nums.append(3)                 // fine — var array
let frozen = [1, 2]
// frozen.append(3)            // error: array is a value type in a let

let view = UIView()
view.alpha = 0.5               // fine — class: the object's vars still mutate
// view = UIView()             // error: can't repoint the let reference

Why it's asked / follow-up: it separates “knows the keyword” from “understands value vs reference semantics.” Follow-up: “prefer let or var?” — default to let; the compiler even warns when a var is never mutated. See the Swift version reference for how the language has evolved around value semantics.

Source: The Swift Programming Language — The Basics.

Value types vs reference types — what's the difference and why does it matter? Mid

This is the load-bearing Swift distinction. A value type (struct, enum, and the standard-library collections) is copied on assignment or when passed to a function — each variable holds its own independent value, so mutating one never affects another. A reference type (class, actor) is shared: assignment copies the reference, so two variables point at the same instance and a mutation through one is visible through the other. Swift leans hard on value types (its String, Array, and Dictionary are all structs) because independent copies eliminate a whole class of shared-mutable-state and aliasing bugs. Reach for a class when you need identity, shared mutable state, a deinitializer, or inheritance.

struct SPoint { var x: Int }
var s1 = SPoint(x: 1)
var s2 = s1                    // COPY
s2.x = 99
print(s1.x, s2.x)              // 1 99  — independent

class CPoint { var x = 1 }
let c1 = CPoint()
let c2 = c1                    // shared reference
c2.x = 99
print(c1.x, c2.x)             // 99 99  — same object

Why it's asked / follow-up: nearly every subtler Swift question (mutation, ARC, thread safety, copy-on-write) reduces to this. Follow-up: “isn't copying every array expensive?” — no; the collections use copy-on-write, sharing storage until the first mutation (see §7). Follow-up: “struct or class by default?” — prefer struct; use a class only when you need reference semantics.

Source: The Swift Programming Language — Structures and Classes.

What's the difference between Any and AnyObject? Mid

Any is the type that every value conforms to — a struct, an enum, a class instance, a function, all of it. AnyObject is narrower: the type any class instance (a reference type) conforms to, the modern stand-in for Objective-C's id. So an Int or a String fits Any but not AnyObject (they're structs). You reach for Any at genuinely heterogeneous boundaries (a JSON dictionary of mixed values); you reach for AnyObject when you specifically need reference semantics — most commonly a class-only protocol (protocol Delegate: AnyObject), which lets a conformer be held weak to break a retain cycle (§7).

let mixed: [Any] = [1, "two", true]     // value + reference — all fit Any
// let objs: [AnyObject] = [1]           // error: Int is not a class

protocol Delegate: AnyObject {}         // class-only ⇒ can be held weak
weak var delegate: Delegate?

Why it's asked / follow-up: the answer forces you to articulate value vs reference again, and the class-only-protocol use is the practical payoff. Follow-up: “why must a weak reference's type be a class?” — weak is an ARC mechanism, and only reference types are reference-counted; that's why delegate protocols are declared : AnyObject.

Source: The Swift Programming Language — Type Casting (Any and AnyObject).

What's the difference between == and ===? Mid

== is value equality, defined by the Equatable protocol: it asks whether two values are equal by content, and you (or the compiler, synthesizing it) decide what that means. === is identity: it asks whether two class references point at the exact same instance, and it's only defined for reference types (AnyObject). So == applies to structs, enums, and classes that conform to Equatable; === applies only to class instances and ignores their contents. Two distinct objects with identical fields are == (if Equatable says so) but not ===.

final class Box: Equatable {
    let n: Int
    init(_ n: Int) { self.n = n }
    static func == (l: Box, r: Box) -> Bool { l.n == r.n }
}
let a = Box(1), b = Box(1)
a == b     // true  — same contents
a === b    // false — two distinct instances
a === a    // true

Why it's asked / follow-up: conflating equality and identity is a common source of bugs, and the answer again pins down value vs reference. Follow-up: “how do you get == for a struct?” — conform to Equatable; if every stored property is already Equatable, the compiler synthesizes == for you (same deal for Hashable).

Source: Swift standard library — Equatable.

2 · Optionals

What is an optional, really? Junior

An optional is Swift's way of encoding “a value, or the absence of one” in the type. Under the hood it's just an enum with two cases — Optional<Wrapped> is .none or .some(Wrapped) — and String? is sugar for Optional<String>, nil is sugar for .none. Because absence lives in the type, a plain String can never be nil, and the compiler forces you to unwrap a String? before using it as a String. That's how Swift eliminates the null-reference crashes that plague languages where any reference can silently be null.

var name: String? = "Ada"
name = nil                 // fine — String? can be absent

// Optional IS an enum:
switch name {
case .some(let value): print(value)
case .none:            print("absent")
}

Why it's asked / follow-up: “it's an enum” is the answer that separates people who use optionals from people who understand them. Follow-up: “so what does ? after a type actually do?” — nothing magic; it's shorthand for wrapping the type in Optional. Swift's null-safety story is entirely built on this one enum.

Source: Swift standard library — Optional.

How do you safely unwrap an optional — if let, guard let, and the shorthand? Junior

Optional binding unwraps a value into a new non-optional constant if it's present. if let scopes the unwrapped value to the if body; guard let unwraps into the enclosing scope and requires you to exit (via return/throw/break) if the value is absent — the “early return” idiom that keeps the happy path un-indented. Since Swift 5.7 you can write the shorthand if let name (no = name) when you're binding to the same name.

func greet(_ name: String?) -> String {
    guard let name else { return "Hi guest" }   // exit if absent
    return "Hi \(name)"                          // name is String here
}

if let name = optionalName {           // classic form
    print(name.count)                    // unwrapped in the if body
}

Why it's asked / follow-up: it's the everyday mechanic, and the if let-vs-guard let choice is a real style signal. Follow-up: “when guard over if?” — when the absent case is an error/exit and you want the rest of the function to run with a non-optional value; guard keeps the nesting flat. The shorthand shipped in Swift 5.7.

Source: The Swift Programming Language — The Basics (Optional Binding).

What do optional chaining ?. and nil-coalescing ?? do? Mid

Optional chaining (?.) calls a property or method on an optional only if it's non-nil; the whole expression short-circuits to nil the moment any link is absent, and its result is always optional (so a?.b?.c is an optional even if c isn't). Nil-coalescing (??) supplies a default when the left side is nil, unwrapping in the process: optional ?? fallback returns a non-optional. The two compose constantly — chain to reach a value that might not be there, coalesce to land on a concrete default.

let count = user?.profile?.posts?.count   // Int? — nil if any link is nil
let shown = user?.name ?? "Anonymous"      // String — chain then coalesce

Why it's asked / follow-up: it checks that you know the result of a chain is still optional (a common surprise) and that ?? unwraps. Follow-up: “does ?? evaluate its right side eagerly?” — no; the default is @autoclosure, so it's computed only when the left side is nil (see §6).

Source: The Swift Programming Language — Optional Chaining.

When does force-unwrap ! trap, and what's an implicitly unwrapped optional? Mid

The force-unwrap operator ! asserts “this optional is not nil” and gives you the wrapped value — but if it is nil, the program traps (a fatal runtime crash, not a catchable error). It's the deliberate escape hatch, safe only where you can prove non-nil and the compiler can't. An implicitly unwrapped optional (String!) is an optional that force-unwraps itself on every use: it can still hold nil, but you access it without ? or !. IUOs exist mainly for values that are nil only briefly during setup (an Interface Builder @IBOutlet, a two-phase-initialized property) — not as a way to dodge unwrapping.

let maybe: Int? = nil
// let n = maybe!            // 💥 runtime trap: unexpectedly found nil

var label: String! = nil       // implicitly unwrapped optional
label = "ready"
print(label.count)          // used like a plain String — but traps if still nil

Why it's asked / follow-up: the “! can crash” fact and the narrow, justified uses of IUOs are a maturity signal. Follow-up: “alternatives to !?” — guard let with a clear fatalError message, ?? with a sensible default, or if let; a codebase littered with ! has thrown away the safety optionals give.

Source: The Swift Programming Language — The Basics (Implicitly Unwrapped Optionals).

guard let vs if let — how do you choose? Junior

They unwrap the same way but scope and intent differ. if let binds the unwrapped value inside its block — use it when the optional case is one branch among several and you don't need the value afterward. guard let binds into the enclosing scope and requires its else to leave that scope, so it reads as “we can't continue without this,” unwraps for the rest of the function, and keeps the happy path flat instead of marching rightward into nested ifs. The rule of thumb: precondition that must hold → guard; optional side-path → if.

func loadUser(_ id: Int?) {
    guard let id, id > 0 else { return }   // bail early; id valid below
    fetch(id)
}

if let cached = cache[key] {              // side path; cached only here
    use(cached)
}

Why it's asked / follow-up: it's a daily choice and a readability signal — overuse of nested if let is a code smell guard was designed to fix. Follow-up: “can guard bind multiple values / add conditions?” — yes; comma-separate bindings and boolean clauses, and all must pass or the else runs.

Source: The Swift Programming Language — Control Flow (Early Exit).

3 · Structs, classes & enums

When should you choose a struct over a class? Mid

Apple's own guidance is default to a struct, and reach for a class only when you specifically need one of the things a reference type gives you. Use a struct (or enum) for data whose identity doesn't matter — a model value, a coordinate, a configuration — and get independent copies, no shared-mutable-state bugs, and free thread-safety for immutable values. Use a class when you need identity (two references to the same object), shared mutable state, a deinit, or inheritance / Objective-C interop. Enums are the third value type: a closed set of cases, optionally with associated data.

struct Coordinate { var lat, lon: Double }   // value: identity irrelevant

class DownloadTask {                        // reference: identity + shared state
    var progress = 0.0
    deinit { print("cancelled") }             // only classes have deinit
}

Why it's asked / follow-up: it's the design decision you make constantly, and “default to struct” is the modern-Swift answer. Follow-up: “name something only a class can do” — inheritance, deinit, identity comparison (===), and being referenced weak/unowned for ARC.

Source: Apple — Choosing Between Structures and Classes.

Why do some struct methods need the mutating keyword? Mid

A method on a value type can't change the value's own stored properties unless it's marked mutating — because, semantically, mutating a value type means replacing the whole value, and the compiler needs to know a method does that. A mutating method effectively receives self as inout and can even reassign self entirely. The direct consequence: you cannot call a mutating method on a value stored in a let, which is exactly what makes let deep-immutability work for structs. Classes never need mutating — a class method mutates the shared instance through its reference.

struct Counter {
    var count = 0
    mutating func bump() { count += 1 }     // modifies self
}
var c = Counter()
c.bump()                                    // ok — var
let fixed = Counter()
// fixed.bump()   // error: cannot use mutating member on a 'let' constant

Why it's asked / follow-up: it ties the keyword back to value semantics rather than treating it as boilerplate. Follow-up: “can a mutating method replace self?” — yes; e.g. self = Counter() is legal inside a mutating method, which is how enum state-machine transitions are written.

Source: The Swift Programming Language — Methods (Modifying Value Types).

Stored vs computed properties, observers, and lazy — and static vs class? Mid

A stored property holds a value; a computed property has no storage — it runs a get (and optional set) each access. Property observers willSet/didSet run code around a stored property's mutation. lazy defers a stored property's initializer until first access (it must be a var, and its init can reference other properties). For type-level members, static declares one shared across all instances; on a class, class is the same but overridable by a subclass (static is effectively final).

struct Circle {
    var radius: Double { didSet { print("r → \(radius)") } }  // observer
    var area: Double { .pi * radius * radius }              // computed
    static let maxRadius = 100.0                             // type property
    lazy var label = "r=\(radius)"                          // built on first read
}

Why it's asked / follow-up: distinguishing storage from computation (and knowing observers don't fire during init, or on a computed property) is bread-and-butter. Follow-up: “can a computed property be lazy?” — no; lazy is about deferring storage, and a computed property has none. Observers also can't be combined with a computed property (use the setter instead).

Source: The Swift Programming Language — Properties.

What can Swift enums do that other languages' enums can't — associated values, raw values, indirect? Mid

Swift enums are full-fledged value types with methods and computed properties, and three features make them a modeling powerhouse. Associated values let each case carry its own payload of different types — this is a proper sum type / tagged union, ideal for modeling states and results. Raw values back every case with a constant of one underlying type (Int, String, …), giving free rawValue and a failable init?(rawValue:). indirect lets a case recursively hold the enum itself (via a layer of indirection), which is how you model trees or expressions. Note a case can carry associated values or a raw value, not both.

enum Result {                       // associated values (a sum type)
    case success(Data)
    case failure(Error)
}
enum Suit: String { case hearts, spades }   // raw values: Suit.hearts.rawValue == "hearts"

indirect enum Expr {                 // recursive
    case value(Int)
    case add(Expr, Expr)
}

Why it's asked / follow-up: associated values are what make Swift enums special, and they pair with exhaustive switch (the compiler forces you to handle every case). Follow-up: “why indirect?” — a value type can't directly contain itself (infinite size); indirect boxes the recursive payload behind a reference so the size is finite.

Source: The Swift Programming Language — Enumerations.

4 · Protocols & protocol-oriented programming

What is a protocol extension, and what does it give you? Mid

A protocol declares requirements; a protocol extension supplies default implementations of methods and computed properties to every conforming type. That's the mechanism behind protocol-oriented programming: you factor shared behavior into extensions instead of a base class, so structs and enums (which can't inherit) get reusable behavior too, and a conformer can override a default just by providing its own version. You can also constrain an extension with where so the defaults apply only to conformers that meet extra bounds.

protocol Greeter { var name: String { get } }

extension Greeter {
    func greet() -> String { "Hi, \(name)" }   // default for all conformers
}
struct Person: Greeter { let name: String }
Person(name: "Ada").greet()                    // "Hi, Ada" — no boilerplate

Why it's asked / follow-up: it's the “how do you share code without a base class” answer and sets up the dispatch gotcha below. Follow-up: “what happens if a method is in the extension but not in the protocol's requirements?” — it's dispatched statically by the variable's compile-time type, which surprises people (see the static-vs-dynamic-dispatch question).

Source: The Swift Programming Language — Protocols (Protocol Extensions).

What is protocol-oriented programming, and how does it compare to class inheritance? Senior

Protocol-oriented programming (the design Swift's standard library is built on) composes behavior from protocols + protocol extensions rather than from a class hierarchy. Instead of a single base class handing down state and behavior through inheritance, a type conforms to several small protocols and picks up default implementations from their extensions — so behavior is composed, not inherited. The advantages: it works for value types (structs/enums can't subclass but can conform), it avoids single-inheritance and “fat base class” problems, and a type can adopt many capabilities without a diamond. Inheritance still has its place — when you genuinely need shared mutable state, overridable behavior with super calls, or Objective-C interop — but the Swift default is “start with a protocol.”

protocol Identifiable { var id: String { get } }
protocol Loggable {}
extension Loggable { func log() { print("\(self)") } }

struct Order: Identifiable, Loggable {   // compose capabilities
    let id: String
}

Why it's asked / follow-up: it's a Swift-philosophy question that separates people who write Swift like Java from those who use its grain. Follow-up: “downsides of POP?” — default-method static dispatch can surprise you, and protocols with associated types can't be used as plain existential types the way a base class can (that's what some/any address, below).

Source: The Swift Programming Language — Protocols.

some vs any — opaque vs existential types, and why it matters? Senior

Both let a protocol stand in for a concrete type, but they mean opposite things. some P is an opaque type: there is one specific underlying type the compiler knows but the caller doesn't — it's fixed and static, so there's no boxing and full type information is preserved (this is what makes SwiftUI's some View work). any P is an existential: a box that can hold any conforming type, possibly different ones at runtime, at the cost of dynamic dispatch and (often) heap indirection. Rule of thumb: use some when a single concrete type flows through (a return value, a parameter you don't need to vary); use any only when you genuinely need to store or pass differing conforming types together, e.g. a heterogeneous array.

func makeShape() -> some Shape { Circle() }   // opaque: one fixed type, no box

let shapes: [any Shape] = [Circle(), Square()] // existential: mixed types, boxed

Why it's asked / follow-up: it's a modern-Swift litmus test, and getting it right shows you understand performance and generics, not just syntax. Follow-up: “why did Swift 5.6 make you write any?” — to make the (real) cost of existentials visible at the use site; bare protocol-as-type now reads as any P. Opaque parameters (some in parameter position) arrived in Swift 5.7.

Source: The Swift Programming Language — Opaque and Boxed Types.

What is an associated type, and what's a where clause for? Senior

An associated type is a placeholder type inside a protocol that each conformer fills in — it's how a protocol stays generic over “whatever element/output this conformer uses.” Sequence's Element and IteratorProtocol's Element are the canonical examples. A protocol with associated types can't be used as a bare existential in older Swift (it had “can only be used as a generic constraint” restrictions), which is one reason generics and some/any matter. A where clause adds constraints on those associated types — on the protocol, on an extension, or on a generic function — so behavior applies only when, say, Element: Equatable.

protocol Container {
    associatedtype Item                         // placeholder
    var items: [Item] { get }
}
extension Container where Item: Equatable {   // constrained default
    func contains(_ x: Item) -> Bool { items.contains(x) }
}

Why it's asked / follow-up: associated types are where protocols get genuinely powerful (and confusing), so it separates surface knowledge from real generics fluency. Follow-up: “what are primary associated types?” — Swift 5.7 lets you constrain them at the use site with angle brackets, e.g. any Sequence<Int> / some Collection<String>.

Source: The Swift Programming Language — Generics (Associated Types).

Protocol-extension dispatch: why does calling through the protocol type give a different result? Senior

This is the classic protocol gotcha. If a method is a protocol requirement, it's dispatched dynamically — the conformer's implementation is chosen at runtime through a witness table, even when you hold the value as the protocol type. But if a method exists only in the protocol extension (not declared as a requirement), it's dispatched statically by the variable's compile-time type. So the same call resolves differently depending on whether you're looking at the value as its concrete type or as the protocol.

protocol P { func req() }              // requirement → dynamic
extension P {
    func req()  { print("ext req") }
    func extra() { print("ext extra") }   // NOT a requirement → static
}
struct S: P {
    func req()   { print("S req") }
    func extra() { print("S extra") }
}
let p: P = S()
p.req()      // "S req"    — dynamic: sees S's version
p.extra()    // "ext extra"— static: sees P's version, NOT S's

Why it's asked / follow-up: it's a real bug source and a deep-understanding check on how protocol witness tables work. Follow-up: “how do you make extra() dispatch dynamically?” — declare it as a requirement in the protocol body (leaving the extension as the default), so it enters the witness table.

Source: The Swift Programming Language — Protocols.

5 · Generics

How do generics work — type parameters and constraints? Mid

Generics let you write one function or type that works over many types while staying fully type-safe. A type parameter (written <T>) is a placeholder the caller fills in; the compiler checks each use concretely, so there's no casting and no Any. A constraint restricts what that placeholder can be — <T: Comparable> requires ordering, a where clause expresses richer relationships — which is what lets the body actually use the type (you can only call < on a T the compiler knows is Comparable). The whole standard-library collection API is generic.

func maxOf<T: Comparable>(_ a: T, _ b: T) -> T {
    a > b ? a : b                       // > allowed because T: Comparable
}
maxOf(3, 9)          // T = Int
maxOf("a", "z")      // T = String — same function, checked concretely

Why it's asked / follow-up: constraints are the difference between “generic” and “Any in disguise”; the answer shows you understand compile-time type safety. Follow-up: “constraint on the function vs a where clause?” — equivalent for a single bound; where shines for constraints on associated types (where T.Element: Equatable).

Source: The Swift Programming Language — Generics.

What is conditional conformance / a constrained extension? Mid

A constrained extension adds behavior to a generic type only when its type parameter meets a bound, using where. Conditional conformance is the special case where the added behavior is a protocol conformance — e.g. an Array is Equatable exactly when its Element is Equatable. This is why [Int] can be compared with == but [SomeNonEquatable] can't: the conformance itself is conditional on the element type, and the standard library relies on it heavily (Optional, Array, and Dictionary are all conditionally Equatable/Hashable/Codable).

extension Array where Element: Numeric {   // constrained extension
    func total() -> Element { reduce(0, +) }
}
[1, 2, 3].total()    // 6  — only available because Int: Numeric

// conditional conformance (already in the stdlib):
// extension Array: Equatable where Element: Equatable { … }

Why it's asked / follow-up: it explains a lot of “why does == work here but not there” behavior and shows real generics depth. Follow-up: “when did it arrive?” — conditional conformance landed in Swift 4.2 and unlocked much of the modern collection API.

Source: The Swift Programming Language — Generics (Extensions with a Generic Where Clause).

Generics vs existentials (any) — what's the performance difference? Senior

They look similar but compile very differently. A generic <T: P> preserves the concrete type through the call, and the optimizer can specialize (monomorphize) the function for each concrete T — inlining and static dispatch, often as fast as hand-written code. An existential any P erases the concrete type into a box (with value-witness and protocol-witness tables): calls go through dynamic dispatch, and a payload larger than the box's inline buffer is heap-allocated. So “a function that takes some P / <T: P>” is generally cheaper than one taking any P; you pay the existential cost only for the flexibility of holding differing types together.

func fast<T: Shape>(_ s: T) { s.draw() }   // specializable, static dispatch
func flexible(_ s: any Shape) { s.draw() }    // boxed, dynamic dispatch

Why it's asked / follow-up: it's the “do you know what your abstractions cost” question. Follow-up: “is some P the same as <T: P>?” — nearly, for a single parameter: some is sugar for an implicit generic parameter, so it gets the same specialization benefits — the difference is you can't name or relate the type across several positions the way an explicit <T> lets you.

Source: The Swift Programming Language — Opaque and Boxed Types.

6 · Closures & functions

What is a closure, and what does “capturing” mean? Mid

A closure is a self-contained block of functionality you can pass around — functions are just named closures. The defining trait is capture: a closure can reference constants and variables from the surrounding scope, and it keeps them alive by holding a reference to them, even after that scope has returned. Swift closures capture by reference by default, so a captured var reflects later mutations. Swift also gives you syntactic sugar — trailing-closure syntax (the closure moves outside the parentheses), $0 shorthand argument names, and implicit return for single-expression closures — which is why so much Swift API reads fluently.

func makeCounter() -> () -> Int {
    var n = 0
    return { n += 1; return n }        // captures n by reference, keeps it alive
}
let next = makeCounter()
next(); next()                        // 1, then 2 — same captured n

[3, 1, 2].sorted { $0 < $1 }          // trailing closure + $0/$1 shorthand

Why it's asked / follow-up: capture semantics underlie both the elegant parts (stateful closures) and the dangerous parts (retain cycles). Follow-up: “capture by value instead?” — list it in a capture list, e.g. [n], which snapshots the value at closure-creation time.

Source: The Swift Programming Language — Closures.

What is a capture list, and why [weak self] vs [unowned self]? Mid

A capture list is the […] at the start of a closure that controls how named values are captured. Its main job is breaking retain cycles: a closure stored by an object that itself references self keeps self alive forever (see §7). Writing [weak self] captures a weak, optional reference that becomes nil if the object is deallocated — safe, but you must unwrap. [unowned self] captures a non-optional reference you promise outlives the closure — no unwrap, but a crash if you're wrong. Choose weak when self might legitimately be gone (async callbacks); unowned only when the closure provably can't outlive self.

class ViewModel {
    var onLoad: (() -> Void)?
    func setup() {
        onLoad = { [weak self] in
            guard let self else { return }   // self may be nil — safe
            self.refresh()
        }
    }
    func refresh() {}
}

Why it's asked / follow-up: it's the daily tool for avoiding leaks and the answer that shows you understand ARC ownership. Follow-up: “default to weak or unowned?” — weak is the safe default; use unowned only for a guaranteed-shorter-lifetime relationship (and never across an async boundary you can't reason about).

Source: The Swift Programming Language — Closures (Capturing Values).

Escaping vs non-escaping closures — what does @escaping mean? Senior

A closure parameter is non-escaping by default: it must be called (or not) before the function returns, and can't be stored for later. Marking it @escaping tells the compiler the closure may outlive the call — stored in a property, dispatched to another queue, kept for an async callback. This matters for two reasons: the compiler can optimize non-escaping closures more aggressively (no need to keep captures alive past the call), and inside an escaping closure you must refer to self explicitly, which is a deliberate nudge that a retain cycle is possible and you should think about capture lists.

func runNow(_ work: () -> Void) { work() }          // non-escaping: called before return

var handlers: [() -> Void] = []
func store(_ work: @escaping () -> Void) {
    handlers.append(work)                              // stored ⇒ must be @escaping
}

Why it's asked / follow-up: the “why must I write self. here” question leads straight to escaping and capture. Follow-up: “what is @autoclosure?” — it wraps an argument expression in a closure automatically so it's evaluated lazily; that's how ??, assert, and &&/|| avoid evaluating their right side unless needed.

Source: The Swift Programming Language — Closures (Escaping Closures).

map, filter, reduce, compactMap, flatMap — what's each for? Mid

These are the standard-library higher-order functions on Sequence. map transforms each element, returning a new array of the same count. filter keeps elements matching a predicate. reduce folds the sequence into a single value with an accumulator. compactMap maps and drops nils (so [String] → [Int] discarding the ones that don't parse). flatMap maps each element to a sequence and concatenates the results (flattening one level of nesting). They compose into clear, declarative pipelines with no manual loop or index.

let nums = [1, 2, 3, 4]
nums.map { $0 * $0 }              // [1, 4, 9, 16]
nums.filter { $0 % 2 == 0 }       // [2, 4]
nums.reduce(0, +)                 // 10
["1", "x", "3"].compactMap { Int($0) }   // [1, 3] — nils dropped
[[1, 2], [3]].flatMap { $0 }      // [1, 2, 3] — one level flattened

Why it's asked / follow-up: fluency with these signals idiomatic Swift and functional thinking. Follow-up: “compactMap vs flatMap?” — on optionals, use compactMap (the flatMap-that-drops-nils was renamed to compactMap in Swift 4.1); flatMap now means “map to sequences and concatenate.”

Source: Swift standard library — Sequence.

7 · Memory management & ARC (extended)

What is ARC, and how does it differ from a tracing garbage collector? Mid

ARC (Automatic Reference Counting) frees a class instance the moment its reference count drops to zero. The compiler inserts the retain/release calls at compile time based on ownership, so there's no runtime tracer and no unpredictable GC pause. A tracing GC (JVM, Go) instead runs periodically to find unreachable objects — more forgiving of cycles, but with pauses and non-deterministic finalization. The trade-offs: ARC gives deterministic deallocation (deinit runs exactly when the last reference goes away) and low overhead, but it can't collect reference cycles on its own — you break those manually with weak/unowned. ARC applies only to reference types; value types aren't reference-counted at all.

class File {
    init()  { print("open") }
    deinit { print("close") }        // runs deterministically at refcount 0
}
var f: File? = File()               // "open"
f = nil                              // "close" — immediately, no GC pass

Why it's asked / follow-up: it frames every other ARC question and surfaces the “ARC can't break cycles” fact. Follow-up: “is ARC the same as manual retain/release?” — the same counting mechanism, but the compiler writes the calls, so you don't — except at cycles, where you still supply the weak/unowned annotation.

Source: The Swift Programming Language — Automatic Reference Counting.

strong, weak, unowned — when is each correct? Mid

These are the three ownership kinds for a class reference. A strong reference (the default) keeps its target alive — it increments the retain count. A weak reference does not keep the target alive and automatically becomes nil when the target is deallocated; it must therefore be an optional var. An unowned reference also doesn't retain, but is assumed to always be valid while used — it's non-optional and does not auto-nil, so accessing it after the target is gone is a crash. The decision: use weak when the reference can legitimately outlive its target (delegates, back-references that might vanish); use unowned when the reference's lifetime is guaranteed ≤ the target's (a child that never outlives its parent).

class Customer { var card: Card? }        // strong (default)
class Card { unowned let holder: Customer       // a card never outlives its holder
    init(holder: Customer) { self.holder = holder }
}
weak var delegate: SomeDelegate?          // weak: optional, auto-nils

Why it's asked / follow-up: “weak vs unowned” is one of the most-asked Swift questions because it's where leaks and crashes both live. Follow-up: “why must weak be optional but unowned not?” — weak becomes nil on deallocation (so it needs to be nullable); unowned promises it won't be accessed after deallocation, so it stays non-optional and traps if that promise is broken.

Source: The Swift Programming Language — ARC (Resolving Strong Reference Cycles).

What is a retain cycle, and how do you break the classic delegate and closure cycles? Senior

A retain (strong reference) cycle happens when two objects hold strong references to each other — each keeps the other's count above zero, so neither is ever freed, even after everything else lets go of them. That's a permanent memory leak. ARC can't detect it; you prevent it by making one side of the relationship non-owning. The two classic cycles: the delegate cycle (a view holds a child that strongly references the view back — fixed by declaring the delegate weak) and the closure cycle (an object stores a closure that captures self strongly — fixed with a [weak self] capture list).

protocol Delegate: AnyObject {}
class View {
    weak var delegate: Delegate?         // weak breaks the delegate cycle
    var onTap: (() -> Void)?
    func wire() {
        onTap = { [weak self] in self?.handle() }  // weak breaks the closure cycle
    }
    func handle() {}
}

Why it's asked / follow-up: retain cycles are the number-one Swift memory bug, and both fixes are things you write constantly. Follow-up: “how do you detect one?” — a deinit that never prints, or Xcode's Memory Graph Debugger / Instruments' Leaks showing objects that should be gone. Follow-up: “are value types ever in a cycle?” — only if they capture a reference type; pure value graphs can't cycle.

Source: The Swift Programming Language — ARC (Strong Reference Cycles for Closures).

What is deinit, and when exactly does it run? Mid

deinit is a class's deinitializer — it runs immediately before the instance is deallocated, i.e. the instant its last strong reference goes away (ARC's determinism is exactly what makes this predictable). It's where you release resources ARC can't manage for you: invalidate a timer, close a file handle, remove an observer, cancel a task. Only classes have deinit (value types have no shared lifetime to end), you don't call it yourself, and each class has at most one. It's also the standard trick for spotting leaks: if a deinit you expect never fires, something is still holding a strong reference.

class Timer {
    deinit {
        stop()                     // clean up right as the last reference drops
        print("timer released")
    }
    func stop() {}
}

Why it's asked / follow-up: understanding when it runs (deterministically, at refcount 0) versus a GC finalizer (whenever the collector gets around to it) is the payoff. Follow-up: “can deinit run on any thread?” — yes, it runs on whichever thread releases the last reference, so keep it lightweight and thread-safe.

Source: The Swift Programming Language — Deinitialization.

What is copy-on-write, and how do value-type collections avoid copying on every assignment? Senior

Copy-on-write (COW) is how Swift gets value semantics without paying to copy large buffers on every assignment. Array, Dictionary, Set, and String are structs backed by a reference to shared storage: assigning one to another just shares the buffer and bumps a reference count. Only when you mutate a value whose storage is shared does it make a real copy first — so reads and passes are cheap, and you still get independent values. The stdlib implements this with isKnownUniquelyReferenced(_:), which you can use to give your own reference-backed value type the same behavior.

var a = [1, 2, 3]
var b = a            // no copy yet — a and b share storage
b.append(4)          // storage is shared ⇒ copy happens NOW, then mutate
// a == [1,2,3], b == [1,2,3,4] — independent, but the copy was deferred

Why it's asked / follow-up: it resolves the “isn't copying arrays everywhere slow?” objection to value types and shows performance awareness. Follow-up: “does COW mean value semantics are free?” — no; a mutation on shared storage still copies, and holding two references then mutating one pays the full copy. But the common read-only / pass-through cases are O(1).

Source: Swift standard library — isKnownUniquelyReferenced(_:).

8 · Swift Concurrency (extended)

What do async and await do, and what problem do they solve? Mid

async marks a function that can suspend — pause partway through, give the thread back, and resume later — and await marks each point where that suspension can happen. Together they let you write asynchronous code in a straight-line, top-to-bottom style instead of nested completion handlers (“callback hell”), while the runtime frees the thread during the wait so it isn't blocked. Crucially, a suspended await does not block the thread: other work runs, and your function picks up where it left off when the awaited value is ready. This is the foundation the whole concurrency model is built on.

func loadUser() async throws -> User {
    let (data, _) = try await URLSession.shared.data(from: url)  // suspends here
    return try JSONDecoder().decode(User.self, from: data)
}
// callers: let user = try await loadUser()

Why it's asked / follow-up: it's the entry point to the whole topic, and “await suspends but doesn't block” is the key insight. Follow-up: “can you only call an async function from another async function?” — from an async context; to bridge from synchronous code you start a Task { }. Async/await shipped in Swift 5.5.

Source: The Swift Programming Language — Concurrency.

What is structured concurrency — async let and task groups? Senior

Structured concurrency means child tasks have a bounded lifetime tied to a scope: the scope can't return until its children finish (or are cancelled), so there are no orphaned tasks and cancellation and errors propagate down the tree automatically. Two tools create child tasks. async let starts a fixed set of concurrent bindings you await later — ideal when you know the tasks up front. A task group (withTaskGroup / withThrowingTaskGroup) runs a dynamic number of child tasks and collects their results. Both guarantee every child completes before the enclosing function returns.

// async let — a fixed set, run concurrently:
async let first  = loadImage(1)
async let second = loadImage(2)
let images = try await [first, second]     // both fetched in parallel

// task group — a dynamic number:
let total = try await withThrowingTaskGroup(of: Int.self) { group in
    for id in ids { group.addTask { try await size(of: id) } }
    return try await group.reduce(0, +)
}

Why it's asked / follow-up: it's the difference between modern Swift concurrency and just “spawn a task and hope”; the lifetime guarantee is the whole point. Follow-up: “async let vs a task group?” — async let for a small fixed count known at compile time; a group for a variable count you build in a loop.

Source: The Swift Programming Language — Concurrency (Tasks and Task Groups).

What is a Task, and how does cooperative cancellation work? Senior

A Task is a unit of asynchronous work. Task { } creates an unstructured task — the bridge from a synchronous context into async code — and it inherits the current actor and priority. Cancellation in Swift is cooperative: calling task.cancel() doesn't forcibly stop anything; it just sets a flag. Your code is responsible for checking that flag and winding down — via Task.isCancelled or try Task.checkCancellation() (which throws CancellationError). Well-behaved async APIs (like URLSession) already check, so cancellation propagates; long CPU loops you write must check themselves or they'll run to completion regardless.

let task = Task {
    for chunk in chunks {
        try Task.checkCancellation()   // throws if cancelled — cooperative
        process(chunk)
    }
}
task.cancel()                          // just sets the flag; the loop must check it

Why it's asked / follow-up: “cancellation is cooperative, not preemptive” is the fact people miss, and it explains why a task can keep running after cancel(). Follow-up: “structured vs unstructured task?” — async let/task groups are structured (scoped lifetime, auto-propagated cancellation); a bare Task { } is unstructured and you own its lifetime.

Source: Swift standard library — Task.

What is an actor, and what does @MainActor do? Senior

An actor is a reference type that protects its own mutable state from data races by serializing access: only one task touches an actor's isolated state at a time, enforced by the compiler. From outside, every access to that state is async and must be awaited (the call may have to wait its turn); from inside, the actor's own methods reach its state synchronously. This is “actor isolation.” @MainActor is a special global actor that pins work to the main thread — annotate a type, method, or property with it to guarantee UI updates run on the main thread, and the compiler checks it for you instead of relying on you to remember DispatchQueue.main.

actor BankAccount {
    private var balance = 0
    func deposit(_ n: Int) { balance += n }   // isolated: one task at a time
}
let acct = BankAccount()
await acct.deposit(100)                     // await — crossing into the actor

@MainActor
func updateLabel(_ s: String) { label.text = s }  // guaranteed on the main thread

Why it's asked / follow-up: actors are Swift's answer to shared-mutable-state data races, and @MainActor is the everyday tool for UI safety. Follow-up: “is an actor's state ever accessed concurrently?” — no; the actor serializes it. But reentrancy is possible: an actor method that awaits can be suspended and another call let in, so don't assume invariants hold across an await.

Source: The Swift Programming Language — Concurrency (Actors).

What is Sendable, and what is Swift 6 data-race safety? Senior

Sendable is the marker protocol for types whose values can be safely shared across concurrency domains (passed between tasks/actors) without introducing a data race. Value types made of Sendable parts are Sendable (the compiler can synthesize the conformance); actors are implicitly Sendable because they serialize their own state; a mutable class generally is not, unless you make it safe yourself and declare @unchecked Sendable (taking responsibility for the locking). Swift 6's data-race safety turns these from optional warnings into compile-time errors by default: the compiler now checks, across the whole program, that non-Sendable values never cross an isolation boundary — a category of concurrency bug caught before you run the app.

struct Message: Sendable { let id: Int; let body: String }  // value of Sendable parts ⇒ ok

final class Cache: @unchecked Sendable {   // I promise this is locked correctly
    private let lock = NSLock()
    private var store: [String: Int] = [:]
}

Why it's asked / follow-up: it's the most movement-prone part of Swift — strict-concurrency enforcement has tightened release over release — so it's a live topic in senior interviews. Follow-up: “what changed in Swift 6?” — strict concurrency became the default language mode (Swift 6.0); Swift 6.2's “approachable concurrency” then re-tuned the defaults so single-threaded code isn't drowned in warnings.

Source: Swift standard library — Sendable.

How do you bridge an old completion-handler API into async/await? Senior

You wrap the callback with a continuation: withCheckedContinuation (or withCheckedThrowingContinuation for APIs that can fail) suspends the current task and hands you a continuation object; you call the legacy API, and in its completion handler you resume the continuation with the result, which un-suspends your async function. The contract is strict: you must resume exactly once — resuming twice traps, and never resuming leaks the task forever. The checked variants add runtime diagnostics that catch those mistakes (the unsafe variants skip the checks for a small perf win once you've proven correctness).

func loadImage() async throws -> UIImage {
    try await withCheckedThrowingContinuation { cont in
        legacyLoad { image, error in
            if let image { cont.resume(returning: image) }     // resume exactly once…
            else { cont.resume(throwing: error ?? SomeError.unknown) }  // …on every path
        }
    }
}

Why it's asked / follow-up: most real codebases still have callback APIs, and the resume-exactly-once rule is a genuine correctness trap. Follow-up: “what if the handler can fire multiple times?” — a continuation is single-shot; for a stream of values use an AsyncStream instead.

Source: The Swift Programming Language — Concurrency.

How does Swift Concurrency compare to GCD (DispatchQueue)? Mid

Grand Central Dispatch was the pre-2021 model: you dispatch closures onto serial or concurrent queues and hop back to DispatchQueue.main for UI. It works, but it's easy to get wrong — unbalanced queues, forgotten main-thread hops, callback nesting, and data races the compiler can't see. Swift Concurrency replaces it with async/await, actors, and structured tasks that the compiler checks for data-race safety, plus a cooperative thread pool that avoids the thread-explosion GCD could cause. GCD isn't deprecated and still fits fire-and-forget or simple timers, but new async code should use the language model. Swift 6.2's approachable concurrency made the common single-threaded, main-actor case the default, cutting the ceremony that early adopters hit.

// GCD (historical):
DispatchQueue.global().async {
    let r = compute()
    DispatchQueue.main.async { label.text = r }   // manual hop back to main
}

// Swift Concurrency:
Task { let r = await compute(); await MainActor.run { label.text = r } }

Why it's asked / follow-up: most Swift codebases straddle both, so interviewers want to know you can reason across the boundary and know why the new model exists. Follow-up: “is GCD gone?” — no; it's still there and fine for simple cases, but it can't give you the compile-time data-race checking that Swift 6 does.

Source: Apple — Dispatch (GCD), contrasted with Swift Concurrency.

9 · Error handling

How does throws / try / do-catch work? Mid

Swift models recoverable failure with throwing functions. A function marked throws can throw a value conforming to the Error protocol (usually an enum of cases); a caller must acknowledge that with try at the call site and handle the possibility — either by being throws itself (propagating up) or by wrapping the call in do { … } catch { … }. The visible try keyword is deliberate: unlike exceptions in some languages, you can see exactly which calls can fail. catch clauses can pattern-match specific error cases, with a final catch-all binding error.

enum NetError: Error { case offline, badStatus(Int) }

func fetch() throws -> Data { throw NetError.offline }

do {
    let data = try fetch()
} catch NetError.badStatus(let code) {
    print("HTTP \(code)")
} catch {
    print("failed: \(error)")          // error bound implicitly
}

Why it's asked / follow-up: it's the core mechanism and the “try is visible” design point distinguishes it from unchecked exceptions. Follow-up: “are Swift errors like Java checked exceptions?” — similar in that the compiler forces acknowledgement, but any Error value can be thrown and there's no per-type throws list (until typed throws, below).

Source: The Swift Programming Language — Error Handling.

What's the difference between try, try?, and try!? Mid

All three call a throwing function; they differ in how they treat the error. Plain try propagates it (you must be in a do-catch or a throwing function). try? turns the call into an optional: success gives .some(value), a thrown error is swallowed and you get nil — handy when you don't care why it failed. try! asserts the call won't throw and traps (crashes) if it does — the error analog of force-unwrap, justified only when failure is genuinely impossible.

let a = try  parse(s)      // propagates the error to the caller
let b = try? parse(s)      // Result? → nil on any error (reason discarded)
let c = try! parse(s)      // 💥 traps if it throws — use only when impossible

Why it's asked / follow-up: misusing try? (silently hiding real errors) and try! (crashing in production) are common mistakes, so the answer shows judgment. Follow-up: “downside of try??” — it discards the error, so a bug that should surface becomes a silent nil; reserve it for cases where any failure legitimately means “no value.”

Source: The Swift Programming Language — Error Handling (Converting Errors to Optional Values).

What are rethrows and typed throws? Senior

rethrows is for higher-order functions: it says “this function throws only if the closure you pass it throws.” So map is rethrows — called with a non-throwing transform it's non-throwing (no try needed), called with a throwing one it propagates. Typed throws (stable in Swift 6.0) let a function declare the exact error type it can throw, throws(MyError), instead of the type-erased any Error. That gives callers a concrete, exhaustively-switchable error and matters for constrained environments (embedded Swift) where any Error's existential box is unwanted. Plain throws is sugar for throws(any Error); rethrows is roughly the closure-polymorphic version.

func transform<T>(_ xs: [T], _ f: (T) throws -> T) rethrows -> [T] {
    try xs.map(f)          // throws only if f throws
}

func load() throws(NetError) -> Data {   // typed throws — concrete error type
    throw NetError.offline
}

Why it's asked / follow-up: rethrows explains why map/filter don't force a try, and typed throws is a recent feature interviewers probe for currency. Follow-up: “should every function use typed throws now?” — no; the guidance is that throws(any Error) stays the default for app code — typed throws is for libraries and constrained contexts where the exact error set is part of the contract. It stabilized in Swift 6.0.

Source: The Swift Programming Language — Error Handling.

What is Result, and when do you use it over throws or an optional? Mid

Result<Success, Failure> is an enum with .success(Success) and .failure(Failure) that packages an outcome as a value you can store and pass around. Three ways to signal failure, three fits: an optional when the caller doesn't need to know why (nil is enough); throws for the straight-line happy path where errors propagate up the call stack; and Result when you need to hold the outcome as a value — the classic case being an old completion handler ((Result<Data, Error>) -> Void) before async/await. You can convert with Result { try … } and unwrap with get().

func load(completion: (Result<Data, Error>) -> Void) { /* … */ }

load { result in
    switch result {
    case .success(let data): use(data)
    case .failure(let err):  report(err)
    }
}

Why it's asked / follow-up: knowing which failure mechanism fits which situation is the maturity signal, not the syntax. Follow-up: “is Result still needed with async/await?” — less often; async throws covers most of what completion-handler Result did, but Result still shines when you need to store an outcome or defer handling it.

Source: Swift standard library — Result.

What does defer do, and how do assert / precondition / fatalError differ? Mid

defer schedules a block to run when the current scope exits by any path — normal return, thrown error, or break — which makes it the clean way to guarantee cleanup (close a file, release a lock) next to where you acquired the resource. Multiple defers run in reverse order. The three failure calls are for unrecoverable programmer errors, distinguished by when they fire: assert checks only in debug builds (compiled out of release); precondition checks in debug and release; fatalError always stops the program and returns Never. Use them for “this must be true or the program is broken” — not for recoverable errors, which is what throws is for.

func process() throws {
    let handle = open()
    defer { handle.close() }        // runs on EVERY exit path, even a throw
    precondition(handle.isValid)     // checked in debug + release
    try work(handle)
}

Why it's asked / follow-up: mixing up recoverable (throws) and unrecoverable (fatalError/precondition) failure is a design mistake, and defer ordering is a common quiz. Follow-up: “assert vs precondition?” — use assert for checks you're happy to lose in release; use precondition for invariants that must hold in production too.

Source: The Swift Programming Language — Error Handling (Specifying Cleanup Actions).

10 · Idiomatic Swift & the standard library

How does pattern matching work — switch, if case, for case, where? Mid

Swift's switch is a full pattern-matching construct, not a C-style integer jump. It matches on enum cases (binding associated values), tuples, ranges, and types, and it must be exhaustive — cover every case or add default, so adding an enum case breaks the switch until you handle it. The same patterns work outside switch: if case tests a single pattern, for case iterates only the elements that match (great for filtering enums or unwrapping optionals in a loop), and a where clause adds a boolean guard to any case.

enum Event { case tap(x: Int), scroll(dy: Int) }

switch event {
case .tap(let x) where x > 100: print("far tap")   // where guard
case .tap:               print("tap")
case .scroll(let dy):    print(dy)
}

for case let .tap(x) in events { print(x) }        // only .tap elements

Why it's asked / follow-up: exhaustive matching plus associated-value binding is a Swift superpower, and if case/for case show fluency beyond basic switch. Follow-up: “why is exhaustiveness good?” — it turns “forgot to handle a new state” from a runtime bug into a compile error.

Source: The Swift Programming Language — Control Flow.

What is Codable, and how does encode/decode work? Mid

Codable is the standard-library protocol (a typealias for Encodable & Decodable) that gives a type automatic, type-safe serialization. Conform to it and, as long as every stored property is itself Codable, the compiler synthesizes the encoding/decoding for you — no manual key-mapping boilerplate. A format-specific coder (JSONEncoder, PropertyListEncoder) does the actual work. When JSON keys don't match your property names, a nested CodingKeys enum remaps them; for anything irregular you implement init(from:) / encode(to:) by hand.

struct User: Codable {
    let id: Int
    let fullName: String
    enum CodingKeys: String, CodingKey {
        case id, fullName = "full_name"      // remap snake_case JSON
    }
}
let user = try JSONDecoder().decode(User.self, from: json)

Why it's asked / follow-up: almost every app decodes JSON, and knowing the synthesis happens only when all properties are Codable (and how to remap keys) is practical. Follow-up: “how do you handle snake_case globally?” — set decoder.keyDecodingStrategy = .convertFromSnakeCase instead of per-property CodingKeys.

Source: Swift standard library — Codable.

What is a property wrapper (the @propertyWrapper mechanism)? Senior

A property wrapper factors out reusable get/set behavior into a type you attach with @. You define a @propertyWrapper struct with a wrappedValue computed (or stored) property; applying it to a stored property routes all reads and writes through your wrapper's logic. It's the language mechanism behind SwiftUI's @State/@Binding and SwiftData/Combine wrappers — but the mechanism itself is framework-agnostic: validation, clamping, thread-safety, and user-defaults backing are all classic uses. An optional projectedValue exposes a secondary interface via the $ prefix.

@propertyWrapper
struct Clamped {
    private var value: Int; let range: ClosedRange<Int>
    init(wrappedValue: Int, _ range: ClosedRange<Int>) {
        self.range = range; value = min(max(wrappedValue, range.lowerBound), range.upperBound)
    }
    var wrappedValue: Int {
        get { value }
        set { value = min(max(newValue, range.lowerBound), range.upperBound) }
    }
}
struct Volume { @Clamped(0...100) var level = 50 }   // always clamped to 0…100

Why it's asked / follow-up: candidates who only know @State from SwiftUI often can't explain the underlying mechanism — this separates users from understanders. Follow-up: “what's projectedValue?” — the value exposed via $property (SwiftUI uses it to hand out a Binding). Property wrappers shipped in Swift 5.1.

Source: The Swift Programming Language — Properties (Property Wrappers).

Why can't you subscript a String with an Int? Mid

Because a Swift String is a value-type collection of Characters, and a Character is an extended grapheme cluster — a user-perceived character that may be built from several Unicode scalars (an emoji with a skin-tone modifier, an e + combining accent). Those clusters have variable width in memory, so there's no O(1) mapping from an integer position to a character; indexing by Int would hide an O(n) walk and encourage buggy, encoding-unaware code. Swift instead uses an opaque String.Index that you obtain by walking from startIndex. The same reason makes count O(n): it counts grapheme clusters, not bytes.

let s = "Cafe\u{301}"            // "Café" — é is e + combining accent
s.count                          // 4 grapheme clusters (not 5 scalars)
// s[0]                          // error: no Int subscript
let i = s.index(s.startIndex, offsetBy: 1)
s[i]                             // "a" — via String.Index

Why it's asked / follow-up: the “why is String so annoying to index” complaint is really a Unicode-correctness feature, and explaining it shows depth. Follow-up: “how do you get the Nth character quickly then?” — if you need random access, convert to Array(s) once (O(n)) and index that array; don't repeatedly walk indices in a loop.

Source: The Swift Programming Language — Strings and Characters.

11 · Classic Swift gotchas

What happens on integer overflow, and what are the &+ operators? Mid

By default, Swift integer arithmetic that overflows the type's range traps — a deliberate safety choice over C's silent wraparound, which is a notorious bug and security-vulnerability source. When you genuinely want modular wraparound (hashing, checksums, bit manipulation), you opt in explicitly with the overflow operators &+, &-, &*, which wrap instead of trapping. For the “did it overflow?” case, the …ReportingOverflow family returns a tuple of the result and a flag.

let max = Int8.max          // 127
// let bad = max + 1        // 💥 runtime trap: overflow
let wrapped = max &+ 1        // -128 — explicit wraparound

let (v, overflowed) = max.addingReportingOverflow(1)  // (-128, true)

Why it's asked / follow-up: the trap-by-default behavior surprises people coming from C/Java, and knowing the & operators shows you've hit it. Follow-up: “is a trap a catchable error?” — no; an overflow trap is a fatal runtime error like force-unwrap, not something do-catch can recover from — validate ranges beforehand if the input is untrusted.

Source: The Swift Programming Language — Advanced Operators (Overflow Operators).

Why doesn't mutating a struct I got out of an array change the array? Senior

Because a struct is a value type, pulling one out of a collection gives you a copy. Binding it to a variable (or a for-loop element) and mutating that variable changes the copy, not the element still sitting in the array. This bites people who expect reference semantics. To actually change the stored element you must write back through the collection — mutate in place by index (or via a mutating method / subscript, which mutates the storage), or reassign the element. It's the same reason for item in array { item.x = 1 } doesn't even compile: the loop constant is immutable, and even a var copy wouldn't write back.

struct P { var x = 0 }
var arr = [P(), P()]

var first = arr[0]      // COPY
first.x = 99            // mutates the copy…
print(arr[0].x)        // 0 — array unchanged

arr[0].x = 99          // in place, by index — THIS writes back
print(arr[0].x)        // 99

Why it's asked / follow-up: it's the most concrete demonstration that value semantics mean copies, and the fix (mutate by index) is what people miss. Follow-up: “how would a class behave here?” — differently: arr[0] would be a shared reference, so mutating it would be visible in the array — which is exactly the aliasing value types avoid.

Source: The Swift Programming Language — Structures and Classes (Value Types).

Why isn't my view controller / object being deallocated? Senior

Almost always a retain cycle through an escaping closure that captured self strongly. If the object stores the closure (a completion handler, a timer callback, a Combine/observer subscription) and the closure captures self, then self → closure → self is a strong loop ARC can't break, so deinit never fires and the object leaks. The fix is a [weak self] capture list, unwrapping with guard let self else { return }. Swift's requirement that you write self. explicitly inside an escaping closure is the compiler nudging you to notice this. (This is the §7 closure cycle, in its most common real-world disguise.)

final class Controller {
    var onDone: (() -> Void)?
    func start() {
        // onDone = { self.finish() }        // ❌ strong cycle: never deallocates
        onDone = { [weak self] in self?.finish() }  // ✅ weak breaks it
    }
    func finish() {}
    deinit { print("gone") }              // prints only if the cycle is broken
}

Why it's asked / follow-up: it's the single most common real Swift memory bug and the reason [weak self] is muscle memory. Follow-up: “how do you confirm the leak?” — put a print in deinit (it won't fire), or use Xcode's Memory Graph Debugger to see the retain cycle. Follow-up: “does a non-escaping closure need [weak self]?” — no; it can't outlive the call, so it can't form a lasting cycle.

Source: The Swift Programming Language — ARC (Strong Reference Cycles for Closures).

Why am I suddenly getting Sendable / data-race warnings under strict concurrency? Senior

Because Swift 6's data-race checking now flags any non-Sendable value crossing an isolation boundary — most commonly a mutable class captured by a Task or passed to an actor. The warning is real: that value could be touched from two concurrency domains at once. The fix depends on the type: make it a value type (structs of Sendable parts are automatically Sendable), confine its mutable state to an actor, isolate it to @MainActor, or — if you've made it thread-safe yourself with a lock — declare @unchecked Sendable and own the correctness. Reaching for @unchecked to silence the warning without actually adding synchronization just hides the race.

final class Counter { var n = 0 }        // not Sendable (mutable class)
let c = Counter()
Task { c.n += 1 }                        // ⚠️ capture of non-Sendable 'c' across boundary

// Fix: make it an actor
actor SafeCounter { var n = 0; func bump() { n += 1 } }

Why it's asked / follow-up: this is the wall teams hit migrating to Swift 6, and the right fixes vs. the @unchecked escape hatch is a judgment signal. Follow-up: “how do you migrate incrementally?” — enable strict-concurrency checking as warnings in the Swift 5 language mode first, fix them module by module, then flip to the Swift 6 language mode; Swift 6.2's approachable-concurrency defaults reduce the noise for single-threaded code.

Source: The Swift Programming Language — Concurrency.

Every answer links its primary source inline — The Swift Programming Language and the official Swift documentation, the Swift standard library reference, and the Swift Evolution proposals where an answer needs spec-level provenance (especially the concurrency and Sendable answers). The questions are a curated set of the topics a Swift interviewer commonly covers, not a copy of any question bank. This page covers the Swift language; the Apple frameworks (SwiftUI, UIKit, Foundation) layered on top are out of scope — see the iOS version reference for the platform and the Xcode version reference for the toolchain. Feature-arrival details cross-link to the Swift version reference. Last updated July 2026.

Mungomash LLC · More on Swift