Skip to content
Mobile & design

12 iOS Swift and Types Interview Questions and Answers

This focused guide turns RecallDeck’s curated iOS Swift and Types material into 12 interview-ready questions. Answer each one before opening the explanation, then use the examples and edge cases to repair anything vague or incomplete.

13 min read12 detailed answersReviewed Aug 24, 2026
What to remember

Tie implementation or design choices to platform behavior, user feedback, lifecycle, accessibility, and the trade-off the team must maintain.

Question set

12 detailed answers

01

What are the real differences between a struct and a class in Swift?

Short answer: A struct is a value type: it is copied on assignment and when passed to a function. A class is a reference type: variables share one instance. Inheritance, deinit and identity (===) exist only for classes; mutating a struct requires var and mutating methods.

In depth:

struct class
Semantics copy of the value shared reference
Inheritance ✗ (protocols only)
deinit / ===
Mutation mutating + var always, even via let
ARC no retain/release (unless it holds references) yes
Initializer memberwise for free you write it
  1. Default to struct — predictable copying, no races on shared state, cheaper for ARC.
  2. Class — when you need shared identity (a cache, a service), inheritance from the NSObject world, or deinit to release a resource.

⚠️ Common mistake: "structs always live on the stack." No: a struct stored in a class property, captured by an escaping closure, or boxed in an existential with a large payload lives on the heap. Value type defines copy semantics, not memory placement.

02

What does this code print: an array of structs vs an array of class instances?

Short answer: Assigning an array of structs yields an independent copy — mutating the copy doesn't touch the original. Assigning an array of class instances copies only the references — the objects are shared, so the mutation is visible through both variables.

In depth:

struct Point { var x = 0 }
final class Box { var x = 0 }

var a = [Point(), Point()]
var b = a
b[0].x = 99
print(a[0].x)      // 0  — b has its own copy of the struct array

let c = [Box(), Box()]
let d = c
d[0].x = 99
print(c[0].x)      // 99 — only references were copied, Box is shared
  1. Array is itself a structb = a is semantically a copy; physically the buffer is shared until the first mutation (CoW), but the observable behavior is a full copy.
  2. Class elements — pointers get copied; d[0] and c[0] are the same heap object.
  3. let d doesn't protect youlet pins the reference, not the object's state: class fields can still be mutated.

⚠️ Common mistake: answering "99 and 99", forgetting that the array's value semantics extends to its elements: b[0].x = 99 mutates an element inside the copy, not a shared object.

03

How does Copy-on-Write work in Swift collections, and does your own struct get it automatically?

Short answer: CoW is an optimization of value semantics: assignment copies only the reference to a shared buffer, and the real copy happens on mutation, and only if the buffer is shared (isKnownUniquelyReferenced). Array/String/Dictionary implement it by hand. Your own struct does not get CoW automatically.

In depth:

  1. Assignment — O(1): both variables point at one heap buffer, reference count = 2.
  2. Mutation — a uniqueness check; if the buffer is shared, copy first, then write.
  3. Your own struct — if it holds a reference to class-backed storage, only the pointer is copied, and a mutation "leaks" into every copy. You write the check yourself:
final class Storage { var values: [Int] = [] }

struct Buffer {
    private var storage = Storage()
    mutating func set(_ v: Int, at i: Int) {
        if !isKnownUniquelyReferenced(&storage) {
            storage = storage.copy()   // copy only when the buffer is shared
        }
        storage.values[i] = v
    }
}

⚠️ Common mistake: "all value types in Swift copy via CoW." No: a plain struct is copied bitwise immediately; CoW is a manual pattern — out of the box only the standard collections have it.

04

What is an Optional under the hood, and when is force unwrap acceptable?

Short answer: An Optional is a plain generic enum with two cases: .some(Wrapped) and .none. T? is syntactic sugar for Optional<T>, and nil is a literal for .none. Force unwrap (!) is tolerable for invariants the programmer guarantees, but in business-logic flow it's a code smell.

In depth:

// Literally in the standard library:
enum Optional<Wrapped> {
    case none
    case some(Wrapped)
}

guard let user = findUser(id) else { return }  // early exit
if let name = user.nickname { greet(name) }    // local unwrap
let count = user.nickname?.count ?? 0          // chaining + nil-coalescing

The unwrapping ladder — from preferred to last resort:

  1. guard let — an invariant for the rest of the function, early exit.
  2. if let / ?. / ?? — local work with the value and defaults.
  3. ! — only for programmer-guaranteed invariants: an IBOutlet after the view loads, a bundled resource, a fixture in tests. A crash here is a build-time signal, not a user scenario.

⚠️ Common mistake: "never use !." The mature answer is about invariants: URL(string: "https://api.example.com")! with a constant literal is more honest than a silent ?? with a meaningless default that masks a bug.

05

Why can't you just put values of a protocol with an associatedtype into an array, and what are the alternatives?

Short answer: A protocol with an associatedtype is not a concrete type but a template of requirements: the compiler doesn't know what T is. Before Swift 5.7 such a protocol couldn't be used as a type at all; now you can write any P — an existential box with dynamic dispatch, but the associated type comes out erased. The alternatives are generics or type erasure.

In depth:

protocol Repository {
    associatedtype Entity
    func all() -> [Entity]
}

// let repos: [Repository] = []   // ✗ before 5.7: 'Repository' can only be used
//                                //   as a generic constraint
let repos: [any Repository] = []  // ✓ boxed, but Entity is erased outside

func sync<R: Repository>(_ repo: R) -> [R.Entity] {
    repo.all()                    // ✓ generic: Entity is known to the compiler
}
  1. any P — a heterogeneous collection becomes possible, but the result of all() loses its concrete Entity and goes through witness-table indirection.
  2. Generic <R: Repository> — full access to R.Entity, static dispatch; but one call means one concrete type.
  3. Type erasure — wrappers like AnyPublisher, AnySequence: they hide the concrete type while keeping the associated one (AnyPublisher<Int, Never>).

⚠️ Common mistake: fixing the compiler error by slapping on any without thinking. If the collection is homogeneous, a generic is both faster and type-preserving.

06

some P, any P, and a generic <T: P>: what does the compiler do differently?

Short answer: some P is an opaque type: behind it stands exactly one concrete type known to the compiler ("reverse generics") — static dispatch, no boxing. any P is an existential box: the value is stored with witness-table indirection, and a large payload moves to the heap. <T: P> is a generic, monomorphized per call site.

In depth:

some P any P <T: P>
Concrete type one, hidden from the caller any, can change at runtime one per instantiation
Dispatch static witness table static
Memory no box 3-word inline buffer, bigger — heap no box
When returning "some" type: some View heterogeneous collections, plugin-like fields hot code, reusable algorithms
  1. some — the implementation picks the type, not the caller; the type is stable across calls.
  2. any — honest runtime polymorphism: [any Shape] with circles and squares mixed together.
  3. Generic — the caller picks the type; the compiler clones and optimizes the code for each T.

⚠️ Common mistake: writing any everywhere "for convenience." That's a silent cost: boxing, indirect calls, loss of associated types. Rule of thumb: start with a generic or some; reach for any when you genuinely need heterogeneity.

07

Why does @escaping exist, and what does it change for a closure?

Short answer: @escaping marks a closure that outlives the function call — it gets stored in a property or handed to asynchronous work. This changes two things: captures move to the heap (the closure lives longer than the stack frame), and the compiler requires explicit self, making capture semantics visible.

In depth:

final class Loader {
    var completion: (() -> Void)?

    func load(_ done: @escaping () -> Void) {
        completion = done                        // stored → escaping is mandatory
        DispatchQueue.global().async { done() }  // goes async → escaping too
    }

    func forEachItem(_ body: () -> Void) {
        body()                                   // non-escaping: dies before return
    }
}
  1. Non-escaping (the default) — the closure is guaranteed to run before the function returns: captures can stay on the stack, self is implicit.
  2. Escaping — lifetime is unknown: the capture context is heap-allocated, and the explicit self. is a signal saying "a retain cycle is possible here, consider [weak self]".
  3. Where you meet it — network completion handlers, stored callbacks, DispatchQueue.async.

⚠️ Common mistake: saying @escaping "makes the closure asynchronous." No: it's only about lifetime. An escaping closure may run synchronously — or never at all.

08

How does an enum with associated values help model a screen's state?

Short answer: Each state is a case and its data are associated values: .loaded([Item]) physically cannot exist without the array, .error(Error) — without an error. Impossible states become unrepresentable, and switch forces you to handle every case.

In depth:

enum ScreenState {
    case loading
    case loaded([Item])
    case error(Error)
}

switch state {
case .loading:              showSpinner()
case .loaded(let items):    render(items)
case .error(let e):         showRetry(e)
}   // add a case — the compiler flags every site
  1. Versus bool flagsisLoading + items: [Item]? + error: Error? gives 8 combinations of which 3 are valid: "loading and error at the same time" has to be banned by discipline. The enum bans them with types.
  2. Data is bound to the case — you can't reach items until a switch proves the state is .loaded.
  3. indirect — for recursive enums (a tree, JSON): indirect case node(Tree, Tree) — Swift boxes the recursion behind a pointer.

⚠️ Common mistake: putting default: in a switch over your own enum. It turns off the main benefit — exhaustiveness checking: a new case silently falls into default and the compiler stays quiet.

09

What kinds of method dispatch does Swift have, and what does the classic protocol-extension example print?

Short answer: Three mechanisms: direct (static) — the address is known at compile time; table-based — a vtable for classes and a witness table for protocols; message dispatch — objc_msgSend for @objc dynamic. The famous trap: a method from a protocol extension that is NOT declared as a protocol requirement dispatches statically — by the variable's type.

In depth:

  1. Direct — struct methods, final methods, methods in extensions. Fastest, can be inlined.
  2. Vtable / witness table — regular class methods; protocol requirements when called through any P.
  3. Message@objc dynamic: objc_msgSend, needed for KVO and swizzling.
protocol Greeter { func hello() }
extension Greeter {
    func hello() { print("protocol hello") }
    func bye()   { print("protocol bye") }   // NOT a protocol requirement
}
struct Person: Greeter {
    func hello() { print("Person hello") }
    func bye()   { print("Person bye") }
}

let g: any Greeter = Person()
g.hello()   // "Person hello"  — requirement → witness table → the type's impl
g.bye()     // "protocol bye"  — not a requirement → static, by the any Greeter type

let p = Person()
p.bye()     // "Person bye"    — concrete type is known

⚠️ Common mistake: expecting "Person bye" from g.bye(). The method isn't in the requirements list — the witness table doesn't know about it, so the compiler hardwires the extension version. Second trap: a class method defined in an extension can't be overridden in a subclass without @objc dynamic — extension methods dispatch directly.

10

What's the difference between static and class type members?

Short answer: Both are members of the type, not of an instance. static is available everywhere (struct, enum, class) and forbids overriding — it is literally final class. class exists only in classes and allows override in subclasses. Stored class properties don't exist — computed only.

In depth:

class Animal {
    static func kind() -> String { "animal" }   // = final class func
    class  func sound() -> String { "..." }     // can be overridden

    // class var name = "x"      // ✗ class stored properties not supported
    class var legs: Int { 4 }    // ✓ computed — allowed
    static let planet = "Earth"  // ✓ stored — static only
}

class Dog: Animal {
    override class func sound() -> String { "woof" }
    // override static func kind() ...  // ✗ cannot override static method
}

Dog.sound()   // "woof" — dispatched on the metatype via the vtable
  1. static — static dispatch, works in every type; stored type properties come only this way (and they initialize lazily and thread-safely).
  2. class — participates in dynamic dispatch on the metatype: type(of: obj).sound() respects overrides.
  3. Choosing — default to static; use class when type-level polymorphism is genuinely needed (factories, reuseIdentifier).

⚠️ Common mistake: "static is for structs, class is for classes." No: static lives happily in classes and means final class there; the difference is overridability, not where you can use it.

11

When is a lazy property initialized, and is it thread-safe?

Short answer: On first access: it computes once, gets stored, and every later read returns the stored value. And no, it is not atomic: two threads reading an uninitialized lazy var simultaneously can run the initializer twice. Unlike global let and static let, which have dispatch_once semantics.

In depth:

final class ViewModel {
    lazy var formatter: DateFormatter = {
        let f = DateFormatter()      // runs on first access
        f.dateFormat = "dd.MM.yyyy"
        return f
    }()
}

// static let shared = ViewModel()  // this one IS lazy AND thread-safe
  1. Why — defer expensive initialization (formatters, values computed from other properties); self is already available inside the closure.
  2. Always var — the value is written after init, so lazy let doesn't exist.
  3. Threads — the compiler adds no lock: a race between two threads can double-initialize. Need laziness plus thread safety — use static let or explicit synchronization.
  4. In a struct — the first read mutates storage: you need a var instance, and reading it on a let struct is a compile error.

⚠️ Common mistake: assuming lazy var is thread-safe "like a singleton." The dispatch_once semantics belongs to globals and static constants — not to instance lazy properties.

12

Property wrappers: what does the compiler generate, and how does wrappedValue differ from projectedValue?

Short answer: For @Wrapper var x the compiler synthesizes hidden storage private var _x: Wrapper and rewrites accesses to x as _x.wrappedValue. projectedValue is the wrapper's second, optional value, reachable via $x: in SwiftUI, $text on a @State is a Binding.

In depth:

@propertyWrapper
struct Clamped {
    private var value: Int
    let range: ClosedRange<Int>

    var wrappedValue: Int {
        get { value }
        set { value = min(max(newValue, range.lowerBound), range.upperBound) }
    }
    var projectedValue: Bool { value == range.upperBound }  // this is what $x is

    init(wrappedValue: Int, _ range: ClosedRange<Int>) {
        self.range = range
        self.value = min(max(wrappedValue, range.lowerBound), range.upperBound)
    }
}

struct Player {
    @Clamped(0...100) var health = 100
    // the compiler generated: private var _health: Clamped
}
var p = Player()
p.health = 250
print(p.health, p.$health)   // 100 true
  1. Why — reusable access logic: validation, UserDefaults, thread safety — without copy-pasting it into every property.
  2. Well-known wrappers@Published ($ is a Publisher), @State/@Binding ($ is a Binding), @AppStorage.

⚠️ Common mistake: confusing $x with _x. _x is the wrapper storage itself (visible inside the type), $x is its projectedValue; if projectedValue isn't declared, the $x syntax simply doesn't exist.

Source notes

References and review policy

RecallDeck’s interview answers are editorial material, reviewed against maintained official documentation where a primary reference is available. Tool selections use direct provider links and contain no affiliate placements. Features can change after the review date.

From reading to recall

Practice the full interview loop.

RecallDeck schedules the concepts you miss and keeps coding, design, and behavioral fundamentals available when the interviewer changes direction.

Start studying

Keep going

RecallDeck Interview Library

Detailed answers from the same curated interview deck, organized for search, study, and durable recall.

RSS