Swift Interview Questions and Answers
Last updated:
Check out 45 of the most common Swift interview questions, then take an AI-powered practice interview
Q1Why does Swift default to structs over classes, and how do value semantics actually behave?
BasicValue Types
Answer
Structs are value types: assignment and function calls copy the value, so two variables never share mutable state by accident. Classes are reference types: assignment copies a pointer, and every holder of that pointer sees mutations made through any other holder. Swift's standard library is built almost entirely from structs (Array, String, Dictionary, Set are all structs), and Apple's guidance since the 'Protocol-Oriented Programming' era has been to start with a struct and reach for a class only when you need identity, inheritance from an Objective-C framework class like UIViewController, or a deinit.
Copying sounds expensive but usually is not, because the collection types implement copy-on-write: the copy shares storage until one side mutates. The classic interview probe is a struct that contains a class reference. Copying the struct copies the reference, not the object behind it, so both copies now mutate the same underlying class instance and your 'value type' silently gained reference semantics.
Candidates who mention this hybrid case, and who know that structs get a free memberwise initializer while classes do not, come across as having actually shipped Swift. Also worth stating: structs stored in local variables are typically stack-allocated with no reference counting, which is why hot-path model types in performance-sensitive code (feed cells, parser tokens) are almost always structs.
struct Point { var x: Int; var y: Int }
var a = Point(x: 1, y: 2)
var b = a // independent copy
b.x = 99
print(a.x) // 1, unaffected
final class Counter { var value = 0 }
let c1 = Counter()
let c2 = c1 // same instance
c2.value = 99
print(c1.value) // 99, shared
// The hybrid trap: a struct holding a class reference
struct Wrapper { var counter: Counter }
let w1 = Wrapper(counter: Counter())
var w2 = w1 // copies the reference, not the Counter
w2.counter.value = 42
print(w1.counter.value) // 42, reference semantics leaked in
Key Points
- Structs copy on assignment; classes share a reference
- Array, String, Dictionary are structs with copy-on-write storage
- A struct containing a class reference copies the pointer, not the object
- Choose classes for identity, Obj-C subclassing, or deinit
Q2What happens when you force unwrap a nil optional, and which unwrapping patterns should you use instead?
BasicOptionals
Answer
Force unwrapping nil with ! terminates the process immediately with 'Fatal error: Unexpectedly found nil while unwrapping an Optional value'. There is no catch, no recovery: it is a deliberate trap, the same category as an out-of-bounds array access. In a shipped app it surfaces as a crash report with an EXC_BREAKPOINT (SIGTRAP) on the offending line, and force-unwrap crashes are consistently among the top crash groups in Crashlytics dashboards at Indian consumer apps.
The alternatives, in order of preference: optional binding with if let or guard let (since Swift 5.7 you can write the shorthand 'if let user {' without repeating the name); nil-coalescing with ?? to supply a default; optional chaining with ?. when you only need to call through; and switch or the map/flatMap combinators on Optional for more functional pipelines. Implicitly unwrapped optionals (String!) exist mainly for two legacy reasons: @IBOutlet properties that are nil only before the storyboard connects them, and Objective-C APIs without nullability annotations. In greenfield SwiftUI code you should essentially never declare one.
The honest senior answer about ! is that it has one legitimate use: asserting an invariant you control, like a URL built from a hard-coded literal, where a nil genuinely means programmer error and crashing in development is the correct behaviour. Everywhere data crosses a boundary (network, disk, user input), unwrap conditionally.
func greet(_ name: String?) -> String {
// Preferred: guard with early exit
guard let name else { return "Hello, guest" }
return "Hello, \(name)"
}
let input: String? = nil
let display = input ?? "placeholder" // nil-coalescing
let count = input?.count // optional chaining -> Int?
// Swift 5.7+ shorthand binding
if let input {
print(input.uppercased())
}
// The crash you must avoid on boundary data:
// let value = input! // Fatal error: Unexpectedly found nil
Key Points
- Force unwrap of nil is an unconditional trap, not a catchable error
- Prefer guard let / if let, ??, and optional chaining
- Swift 5.7 shorthand: if let name { } without repetition
- IUOs (Type!) are for outlets and un-annotated Obj-C, not new code
Q3When should you use guard let instead of if let in Swift?
BasicOptionals
Answer
Use guard let when the happy path should continue at the same indentation level and the nil case means the function cannot proceed. guard requires you to exit the scope in the else branch (return, throw, continue, break, or a call to a Never-returning function like fatalError), and the compiler enforces that exit, so you cannot accidentally fall through with an unbound value. The unwrapped constant then remains in scope for the rest of the function, which is the practical difference from if let, where the binding is only visible inside the braces. This is what kills the 'pyramid of doom': four nested if lets become four flat guard statements, each documenting a precondition at the top of the function.
Interviewers also like to check that you know guard is not limited to optionals, it takes any Boolean condition, and you can comma-separate multiple bindings and conditions in one statement ('guard let user, user.isActive, let token = user.token else'). A subtle point worth raising: because the else branch must exit, guard pushes error handling to the top of functions and makes the remaining body provably non-nil, which pairs naturally with throwing functions, guard let x else { throw ParseError.missingField } is one of the most common lines in production Swift. The style consensus at most companies: guard for preconditions, if let for genuinely branching logic where both paths continue.
enum ParseError: Error { case missingField(String) }
func makeUser(from json: [String: Any]) throws -> User {
guard let id = json["id"] as? Int else {
throw ParseError.missingField("id")
}
guard let name = json["name"] as? String, !name.isEmpty else {
throw ParseError.missingField("name")
}
// id and name stay in scope here, flat and non-optional
return User(id: id, name: name)
}
Key Points
- guard's else branch must exit scope; the compiler enforces it
- Bindings survive past the guard, unlike if let
- Flattens nested unwrapping into readable preconditions
- Works with any Boolean condition, not just optionals
Q4What does the mutating keyword do on a struct method?
BasicValue Types
Answer
Methods on value types are non-mutating by default: self is an immutable copy inside the method body. Marking a method mutating tells the compiler the method will modify self's stored properties, and under the hood the method receives self inout, writing the modified value back to the caller's storage when it returns. Two compiler-enforced consequences follow.
First, you cannot call a mutating method on a let constant; you get 'Cannot use mutating member on immutable value' at compile time, which is value semantics doing its job. Second, a mutating method can assign a completely new value to self, which is a legitimate and occasionally elegant pattern, for example resetting a struct to its initial state with 'self = Self()' or implementing enum state transitions by assigning a different case to self. Protocols interact with this too: if a protocol requirement might be implemented by a struct that mutates, the protocol must declare the method mutating; classes conforming to that protocol just ignore the keyword because reference types can always mutate their properties through a constant reference. Interviewers use this question to check whether you actually understand that a struct method call is conceptually 'copy in, copy out' rather than an operation on shared memory, and the inout mental model is the cleanest way to explain it.
struct BankAccount {
private(set) var balance: Int = 0
mutating func deposit(_ amount: Int) {
balance += amount
}
mutating func reset() {
self = BankAccount() // assigning a whole new value to self
}
}
var acct = BankAccount()
acct.deposit(500)
let frozen = BankAccount()
// frozen.deposit(100)
// error: Cannot use mutating member on immutable value: 'frozen' is a 'let' constant
Key Points
- mutating passes self as inout and writes it back on return
- Calling a mutating method on a let constant is a compile error
- A mutating method may assign a brand-new value to self
- Protocols must mark requirements mutating for struct conformers
Q5What is the difference between escaping and non-escaping closures?
BasicClosures
Answer
A non-escaping closure (the default since Swift 3) is guaranteed to run before the function it is passed to returns, so the compiler can allocate its context cheaply and callers may reference self inside it without writing self explicitly. An escaping closure, marked @escaping in the parameter type, outlives the call: it is stored in a property, dispatched to another queue, or held as a completion handler to be invoked later. Because it may run after the caller's stack frame is gone, the closure must capture its context on the heap, and inside a class the compiler forces you to write self. explicitly, a deliberate speed bump making you think about the retain cycle you might be creating.
That is the real production stake: an escaping closure stored on an object that captures self strongly, while self also stores the closure, is the textbook retain cycle, fixed with a [weak self] capture list. Classic escaping examples: URLSession dataTask completion handlers, DispatchQueue.main.async blocks, and any handler you store for later. map and filter take non-escaping closures, which is why you never need weak self inside them. One more corner worth naming: withoutActuallyEscaping(_:do:) exists for the rare case where an API demands @escaping but you can prove the closure will not actually escape, and misusing it (letting it truly escape) is undefined behaviour that the runtime traps in debug builds.
final class Downloader {
var completionHandlers: [() -> Void] = []
// Stored for later, so it must be @escaping
func enqueue(_ handler: @escaping () -> Void) {
completionHandlers.append(handler)
}
// Runs before return, non-escaping by default
func transform(_ values: [Int], using f: (Int) -> Int) -> [Int] {
values.map(f) // no weak self dance needed
}
}
final class ViewModel {
let downloader = Downloader()
var title = ""
func load() {
downloader.enqueue { [weak self] in
self?.title = "Done" // weak self breaks the cycle
}
}
}
Key Points
- Non-escaping runs before the function returns; it is the default
- @escaping closures may outlive the call and capture on the heap
- Explicit self inside escaping closures flags potential cycles
- map/filter are non-escaping, so [weak self] there is noise
Q6How do Swift enums with associated values model state, and how does exhaustive switching help?
BasicEnums
Answer
Swift enums are full algebraic sum types: each case can carry its own typed payload, which lets you make illegal states unrepresentable. The canonical example is a network request state: .idle, .loading, .loaded([Item]), .failed(Error). With that one enum, it is impossible to simultaneously have data and an error, a bug class that a struct with 'var items: [Item]?; var error: Error?' invites.
Pattern matching extracts payloads in switch with 'case .loaded(let items)', and the compiler enforces exhaustiveness: add a new case and every switch in the codebase becomes a compile error until handled, which is exactly what you want during refactors. Related machinery interviewers expect you to know: raw-value enums (enum Plan: String) are a different feature, a single primitive per case for serialization, and cannot coexist with associated values; CaseIterable synthesizes allCases for enums without associated values; indirect enables recursive enums like expression trees by boxing the payload; and @unknown default handles non-frozen enums from Apple SDKs, where a future OS may deliver a case your app was not compiled against, so the compiler warns you to keep a default while still checking the cases you did write. Equatable and Hashable are synthesized automatically when all payloads conform. In interviews, reaching for an enum where a candidate might have written two Bools and an optional is a strong signal of Swift fluency.
enum LoadState {
case idle
case loading(progress: Double)
case loaded([String])
case failed(Error)
}
func render(_ state: LoadState) -> String {
switch state {
case .idle:
return "Pull to refresh"
case .loading(let progress):
return "Loading \(Int(progress * 100))%"
case .loaded(let items) where items.isEmpty:
return "No results"
case .loaded(let items):
return "\(items.count) items"
case .failed(let error):
return "Error: \(error.localizedDescription)"
}
}
// Adding a new case breaks this switch at compile time. That is a feature.
Key Points
- Associated values make invalid state combinations unrepresentable
- Exhaustive switch turns new cases into compile errors, not runtime bugs
- Raw values and associated values are mutually exclusive features
- @unknown default is for non-frozen SDK enums that may grow cases
Q7How does error handling with throws, try?, try!, and Result work, and how is it different from exceptions?
BasicError Handling
Answer
Swift errors are values conforming to the empty Error protocol, usually enums with associated values. A function that can fail is marked throws, callers must acknowledge it with try inside a do/catch, and the compiler refuses to let an error pass silently, there is no invisible propagation like unchecked exceptions in Java or JavaScript. Mechanically it is closer to returning a tagged union than to stack unwinding: throwing is cheap, does not capture a stack trace, and is intended for expected, recoverable failures (bad input, missing file, HTTP 422), while programmer errors (index out of range, force unwrap of nil) trap and crash instead. try? converts a thrown error into nil, useful when the failure reason genuinely does not matter, but it silently discards the error detail, so lint rules at many companies flag it on network and database calls. try! asserts the call cannot fail and traps if it does; it belongs in the same narrow box as force unwrap.
Result<Success, Failure> is the reified form, valuable when an error must be stored or passed through a completion handler, and .get() bridges it back into throwing code. Two newer points worth volunteering: rethrows lets functions like map propagate a closure's throwing behaviour without being throwing themselves, and Swift 6 added typed throws, 'throws(ValidationError)', which pins the concrete error type in the signature, mostly valuable in libraries and embedded contexts rather than app code. Also mention that async functions compose as 'try await', errors flow through tasks the same way.
enum PaymentError: Error {
case insufficientBalance(needed: Int)
case gatewayTimeout
}
func charge(amount: Int, balance: Int) throws -> Int {
guard balance >= amount else {
throw PaymentError.insufficientBalance(needed: amount - balance)
}
return balance - amount
}
do {
let remaining = try charge(amount: 500, balance: 200)
print(remaining)
} catch PaymentError.insufficientBalance(let needed) {
print("Top up \(needed) first")
} catch {
print("Unexpected: \(error)")
}
let maybe = try? charge(amount: 500, balance: 200) // nil, error discarded
let result = Result { try charge(amount: 100, balance: 200) }
Key Points
- Errors are values; propagation is explicit via try and throws
- Throwing is for recoverable failures; traps are for programmer errors
- try? discards error detail; try! traps on failure
- Swift 6 typed throws: throws(ConcreteError) for precise signatures
Q8Explain computed properties, willSet/didSet observers, and lazy stored properties.
BasicProperties
Answer
A computed property stores nothing; its get block derives a value on every access, and an optional set block writes through to other storage (newValue is the implicit parameter). Use them for derived data like fullName or isEmpty so it can never drift out of sync with the underlying state. Property observers attach to stored properties: willSet fires just before the write with newValue available, didSet fires after with oldValue available, and they are the idiomatic hook for invalidating layout or persisting a change when a property mutates.
Two behaviours interviewers probe: observers do not fire during initialization (assignments inside init bypass them by design, so 'didSet will sync this to disk' silently does nothing for the initial value), and mutating a value-type property's members, like appending to an observed array, does fire didSet because the whole struct value is rewritten. lazy stored properties defer their initializer expression until first access, required when the initial value needs self or is genuinely expensive, like an NSDateFormatter or a heavy image processing context. Three lazy gotchas: lazy requires var, because first access mutates storage; lazy is not thread-safe, two threads racing the first access can run the initializer twice, unlike static let and globals which are guaranteed once via dispatch_once semantics under the hood; and a lazy property is never reset, assigning to it just stores a new value. If you need thread-safe one-time init on an instance, use a static, an actor, or explicit locking rather than lazy.
struct Temperature {
var celsius: Double {
didSet {
print("changed from \(oldValue) to \(celsius)")
}
}
var fahrenheit: Double { // computed, always in sync
get { celsius * 9 / 5 + 32 }
set { celsius = (newValue - 32) * 5 / 9 }
}
}
final class ReportGenerator {
// Built on first use; needs var, not thread-safe
lazy var formatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "dd MMM yyyy"
return f
}()
}
Key Points
- Computed properties derive values; they own no storage
- willSet/didSet skip init assignments by design
- lazy defers work to first access but is not thread-safe
- static let is initialized once, thread-safely, unlike lazy
Q9Why can't you subscript a Swift String with an integer?
BasicStrings
Answer
Because Swift's Character is an extended grapheme cluster, a user-perceived character that may occupy a variable number of bytes. An emoji with skin tone and ZWJ sequences, or a Devanagari syllable with combining marks, is one Character built from several Unicode scalars, each encoded as one or more UTF-8 bytes. There is therefore no O(1) mapping from 'the 5th character' to a byte offset; finding it requires walking the string from the start, and Swift refuses to hide an O(n) operation behind the [] syntax that programmers assume is O(1).
Instead, String is indexed by the opaque String.Index type: you get positions from startIndex/endIndex and move with index(_:offsetBy:), which makes the linear cost visible at the call site. Practical consequences you should be able to state: string.count is O(n), so cache it in loops; indices from one string must never be used on another string, or on the same string after mutation, doing so traps at runtime; and for algorithmic work over ASCII protocols or parsing, drop down to string.utf8 view where offsets are honest byte positions, which is dramatically faster. Also know the views: .unicodeScalars, .utf8, .utf16 (the .utf16 count is what NSString/UIKit APIs like NSRange expect, a classic source of emoji-related range crashes when bridging to NSAttributedString). Interviewers use this question to separate people who fought a real text bug, usually an emoji cutting a string in the wrong place, from people who only ever sliced ASCII.
let s = "nam\u{0300}aste🇮🇳" // combining grave + flag cluster
print(s.count) // counts grapheme clusters, O(n)
// let c = s[2] // error: 'subscript' is unavailable
let i = s.index(s.startIndex, offsetBy: 2)
print(s[i])
let flag = "🇮🇳"
print(flag.count) // 1 Character
print(flag.unicodeScalars.count) // 2 regional indicator scalars
print(flag.utf8.count) // 8 bytes
// Fast path for ASCII-heavy parsing:
let colon = UInt8(ascii: ":")
let headerLength = s.utf8.firstIndex(of: colon)
Key Points
- Character = grapheme cluster, variable width in memory
- String.Index makes the O(n) traversal cost explicit
- Indices are invalid across different or mutated strings
- Use the utf8/utf16 views for byte offsets and NSRange bridging
Q10How do protocol extensions provide default implementations, and what happens when a conforming type redeclares the method?
BasicProtocols
Answer
A protocol extension can supply a body for any member, giving every conformer a default. The subtlety that shows up in almost every Swift interview is dispatch. If the member is declared in the protocol itself, it goes into the witness table and is dynamically dispatched: the conformer's own implementation wins no matter what static type you call through.
If the member exists only in the extension and was never listed as a protocol requirement, it is statically dispatched, resolved at compile time from the declared type of the variable. So calling through a variable typed as the protocol runs the extension version, while calling the same method on the concrete type runs the conformer's version. Two call sites, same object, different output.
This bites in real codebases when someone adds a convenience helper to a protocol extension, another team overrides it in their struct, and the behaviour changes depending on whether the value was passed as a concrete type or boxed into an existential. The fix is to add the member as a protocol requirement so it lands in the witness table. Other things worth stating: protocol extensions cannot add stored properties, they can be constrained with 'extension Collection where Element: Numeric' or 'where Self: UIViewController' to add behaviour only where it makes sense, and this constrained-extension pattern is the backbone of protocol-oriented design in the standard library. Interviewers use the static dispatch trap to separate people who read the language guide from people who have debugged it.
protocol Greeter {
func hello() -> String // requirement: witness table, dynamic
}
extension Greeter {
func hello() -> String { "hello from extension" }
func wave() -> String { "wave from extension" } // NOT a requirement
}
struct Host: Greeter {
func hello() -> String { "namaste" }
func wave() -> String { "namaste wave" }
}
let concrete = Host()
let boxed: Greeter = Host()
concrete.hello() // "namaste"
boxed.hello() // "namaste" dynamic dispatch
concrete.wave() // "namaste wave"
boxed.wave() // "wave from extension" static dispatch surprise
Key Points
- Protocol requirements dispatch through the witness table
- Extension-only members dispatch statically on the declared type
- Promote the member to a requirement to get overriding behaviour
- Constrained extensions (where Element: Numeric) scope defaults precisely
Q11Walk through Swift's access control levels, including package and private(set).
BasicAccess Control
Answer
Swift has six levels, from most to least restrictive: private, fileprivate, internal, package, public, open. private limits a member to the enclosing declaration plus extensions of that declaration in the same file, a relaxation added in Swift 4 that many candidates still get wrong. fileprivate widens it to the whole file. internal is the default and means the whole module, which is why a type you forgot to mark public is invisible the moment you split code into a Swift package. package, introduced in Swift 5.9, sits between internal and public: visible to every module in the same Swift package, invisible outside it, which is exactly what modularised apps needed to stop marking things public just to cross a target boundary. public means visible outside the module but not subclassable or overridable outside it. open, which applies only to classes and their members, additionally allows subclassing and overriding from another module. private(set) is an access modifier on the setter alone: 'public private(set) var token' gives everyone read access while keeping writes inside the type, and it is the cleanest way to expose immutable-looking state without hand-writing a computed property over a private stored one. Two practical notes: @testable import raises internal declarations to be visible in a test target compiled against a debug build, but it does not expose private members, so if your tests need it, it should probably be internal; and access control is a compile-time and API-surface tool, not a security boundary, since anything shipped in the binary is reachable by a determined attacker.
public struct Session {
public private(set) var token: String // read anywhere, write inside
private var refreshToken: String // this file only
package var traceID: String // whole Swift package (5.9+)
public init(token: String, refreshToken: String, traceID: String) {
self.token = token
self.refreshToken = refreshToken
self.traceID = traceID
}
public mutating func rotate(to newToken: String) { token = newToken }
}
open class Analytics {} // subclassable from another module
public class Sealed {} // visible outside, not subclassable outside
Key Points
- Order: private, fileprivate, internal (default), package, public, open
- open adds external subclassing and overriding on top of public
- package (Swift 5.9) avoids making things public across package targets
- @testable import exposes internal, never private
Q12What is the difference between strong, weak, and unowned references under ARC?
BasicMemory Management
Answer
ARC keeps a retain count per class instance and frees it when the count hits zero. A strong reference (the default) increments that count. A weak reference does not: it registers in the object's side table so that when the object deallocates, the runtime zeroes every weak reference pointing at it.
That is why weak variables must be optional and must be var, and why reading one is slightly more expensive than reading a strong reference. unowned also avoids retaining, but it does not zero: the reference is non-optional and reading it after the target deallocates traps with 'Fatal error: Attempted to read an unowned reference but object 0x... was already deallocated'. So the choice is a lifetime claim. Use unowned only when the referenced object is guaranteed to outlive the referencing one, the textbook case being a child that can never exist without its parent.
Use weak whenever the target might legitimately disappear first, which covers delegates, cached observers, and almost every [weak self] in a completion handler. In practice, most teams standardise on weak because a nil check is cheaper to reason about than a crash report. Two details worth adding: a weak reference requires a class, so delegate protocols must be declared 'protocol FooDelegate: AnyObject' or you get 'weak' must not be applied to non-class-bound protocol type; and unowned(unsafe) exists as the unchecked variant, equivalent to Objective-C assign, which will give you a use-after-free rather than a clean trap. Structs and enums are not reference counted at all, though a struct holding a class property still participates in ARC through that property.
protocol UploadDelegate: AnyObject { func didFinish() }
final class Uploader {
weak var delegate: UploadDelegate? // no retain, auto-nils
}
final class Parent {
var child: Child?
}
final class Child {
unowned let parent: Parent // parent outlives child by construction
init(parent: Parent) { self.parent = parent }
}
// Escaping closure inside a class: weak is the safe default
service.fetch { [weak self] result in
guard let self else { return }
self.apply(result)
}
Key Points
- weak zeroes on dealloc, must be optional var, uses the side table
- unowned is non-optional and traps if read after dealloc
- unowned only when the target provably outlives the reference
- weak needs a class-bound protocol: protocol D: AnyObject
Q13How does defer behave, and what are the rules about ordering and return values?
BasicControl Flow
Answer
defer schedules a block to run when the current scope exits, whatever the exit path: a normal return, an early return from a guard, a thrown error, or a break out of a loop. Multiple defer blocks in the same scope run in reverse order of declaration, which mirrors how you would unwind acquisitions taken in sequence. The idiomatic uses are all resource pairing: lock then defer unlock, open a file handle then defer close, begin a background task then defer endBackgroundTask, start an os_signpost interval then defer end it.
Because the compiler guarantees execution, defer removes the classic bug where a new early return added six months later skips the cleanup. Rules interviewers probe. First, scope is lexical, not function-wide: a defer inside a for loop body runs at the end of every iteration, not once at the end of the function, which surprises people who expect Go-style function scoping.
Second, a defer cannot transfer control out of itself, so return, break, continue and throw inside a defer block are compile errors ('return' cannot transfer control out of a defer statement. Third, and the favourite trick question, mutating a local variable inside defer does not change an already-evaluated return value, because the value was copied out before the defer ran. Fourth, defer does not run if the process traps or calls exit, so it is not a substitute for a crash-safe cleanup path. Finally, defer captures by reference and executes at scope exit, so it observes the final state of any variable it reads.
func upload(_ data: Data, to url: URL) throws {
lock.lock()
defer { lock.unlock() } // runs even if write throws
let handle = try FileHandle(forWritingTo: url)
defer { try? handle.close() } // runs first (reverse order)
try handle.write(contentsOf: data)
}
func trap() -> Int {
var x = 1
defer { x = 99 }
return x // 1, the value was already copied out
}
for i in 0..<3 {
defer { print("end \(i)") } // fires three times, once per iteration
}
Key Points
- Runs on every scope exit: return, throw, break
- Multiple defers run in reverse declaration order
- Scope is the enclosing block, so loop bodies defer per iteration
- Cannot return or throw from inside a defer block
Q14When would you reach for compactMap, flatMap, reduce(into:) and lazy on a Swift collection?
BasicCollections
Answer
map transforms every element one to one. compactMap transforms and drops nils in one pass, which is the correct tool for parsing arrays of strings into numbers or filtering optional model conversions; it was literally renamed from an overload of flatMap in Swift 4.1 because the old name confused everyone. flatMap on a Sequence concatenates nested collections into one level, so [[1,2],[3]] becomes [1,2,3]; flatMap on Optional is a separate thing entirely, chaining an optional-returning transform without double-wrapping. reduce folds a sequence into a single value, but plain reduce with an array or dictionary accumulator is a performance trap: each step conceptually produces a new accumulator, and with copy-on-write types you can end up with quadratic copying. reduce(into:) passes the accumulator inout, so appends and dictionary writes mutate in place, and it is the version you want for building collections. lazy turns eager chains into a view that computes elements on demand, so 'items.lazy.filter(...).map(...).prefix(3)' never materialises the intermediate arrays and stops after three matches; on a large feed that is the difference between one allocation and three. The caveats to mention: lazy sequences do not conform to everything an Array does, you often need Array(...) at the end, and capturing self in a lazy chain that outlives the scope keeps the base collection alive. Also know the key-path shorthand 'map(\.id)', which reads better than a closure and is what most Indian product teams' style guides prefer in review.
let raw = ["12", "abc", "7"]
let ints = raw.compactMap(Int.init) // [12, 7]
let pages = [[1, 2], [3]]
let flat = pages.flatMap { $0 } // [1, 2, 3]
// reduce(into:) mutates the accumulator in place
let counts = words.reduce(into: [String: Int]()) { acc, w in
acc[w, default: 0] += 1
}
// lazy: no intermediate arrays, stops after three matches
let topThree = Array(feed.lazy.filter(\.isActive).map(\.id).prefix(3))
Key Points
- compactMap = map + drop nils; flatMap = flatten one level
- reduce(into:) avoids copy-on-write churn when accumulating collections
- lazy skips intermediate arrays and short-circuits with prefix/first
- Key-path shorthand map(\.id) is idiomatic modern Swift
Q15What can and cannot be added in a Swift extension, and what is @retroactive in Swift 6?
BasicExtensions
Answer
Extensions can add computed properties, instance and type methods, subscripts, nested types, new initializers, and protocol conformances to a type you do not own. They cannot add stored properties, because that would change the type's memory layout after the fact, and the compiler says exactly that: extensions must not contain stored properties. They cannot add designated initializers to a class (only convenience ones), and they cannot override an existing method of a class unless the member is @objc dynamic, since there is no vtable slot to replace.
Property observers cannot be attached in an extension either. The common workaround for 'I need stored state on a type I do not own' is Objective-C associated objects via objc_setAssociatedObject, which works only for classes and is generally a smell; the better answer in an interview is to wrap the type instead. Conformance-in-extension is the pattern most codebases lean on: keeping 'extension MyViewController: UITableViewDataSource' as its own block keeps the file navigable and lets you constrain conformance with where clauses.
Swift 6 tightened one thing here. If your module extends a type it did not define to conform to a protocol it did not define, for example making Foundation's URL conform to Identifiable, two modules could both do it and the runtime would have to pick one. That now warns, and the fix is to annotate the conformance @retroactive to say you accept the risk, or to wrap the type in your own struct. Expect this to come up if the interviewer is doing a Swift 6 migration.
extension String {
var isTenDigitMobile: Bool {
count == 10 && allSatisfy(\.isNumber)
}
// var cache: Int = 0
// error: extensions must not contain stored properties
}
extension Collection {
subscript(safe index: Index) -> Element? {
indices.contains(index) ? self[index] : nil
}
}
// Swift 6: imported type + imported protocol needs an explicit opt-in
extension URL: @retroactive Identifiable {
public var id: String { absoluteString }
}
Key Points
- No stored properties, no designated inits on classes, no overriding
- Conformances in extensions can be constrained with where clauses
- Swift 6 warns on retroactive conformances; annotate @retroactive
- Associated objects are the escape hatch, and usually the wrong answer
Q16How do inout parameters work, and what triggers an exclusive access violation?
BasicFunctions
Answer
inout is copy-in copy-out, not pass by reference in the C sense. The caller's value is copied into the parameter, the function mutates it, and on return the value is copied back over the caller's storage. The compiler is allowed to optimise this into direct addressing when it can prove it is safe, but the semantics you should describe are copy in, copy out, and the & at the call site exists to make that mutation visible in the code.
You can only pass a var; passing a let, a literal, or a get-only computed property is a compile error. An inout parameter is also not allowed to escape, so you cannot capture it in an @escaping closure, which is why 'Escaping closure captures mutating self parameter' shows up when you try to spawn async work from a mutating method on a struct. The interesting half of this question is exclusivity.
Swift enforces that a variable undergoing a write access cannot be simultaneously accessed, statically for local variables and at runtime for class properties and globals. The classic reproduction is passing the same property twice as inout, which the compiler rejects with 'overlapping accesses to ... but modification requires exclusive access'. The runtime version surfaces as 'Simultaneous accesses to 0x..., but modification requires exclusive access' and typically comes from a mutating method that calls back into something that touches the same object. If you genuinely need shared mutable memory, use a class, an actor, or the withUnsafeMutablePointer family rather than fighting inout.
func clamp(_ value: inout Double, to range: ClosedRange<Double>) {
value = min(max(value, range.lowerBound), range.upperBound)
}
var score = 1.8
clamp(&score, to: 0...1) // score == 1.0
struct Pair { var a = 0; var b = 0 }
var pair = Pair()
swap(&pair.a, &pair.b) // fine, different stored properties
// swap(&pair.a, &pair.a)
// error: overlapping accesses to 'pair.a', but modification requires
// exclusive access; consider copying to a local variable
Key Points
- Semantics are copy-in copy-out, with & marking mutation at the call site
- inout arguments must be var and cannot escape the call
- Overlapping inout access to the same storage is a compile error
- Runtime exclusivity checks catch the class-property and global cases
Q17How is a Swift package structured, and what do Package.swift and Package.resolved actually control?
BasicTooling
Answer
A Swift package is a directory with a Package.swift manifest at the root. The first line is a compiler directive, 'swift-tools-version:', and it decides which manifest APIs and language defaults are available, so bumping it can change behaviour independently of your Xcode version. The manifest declares platforms (minimum deployment targets), products (libraries or executables other packages can depend on), dependencies (other packages by URL or local path), and targets (the actual compilation units, each mapping to a folder under Sources/ by convention).
Test targets live under Tests/. Non-code assets are declared with resources: [.process(...)] or [.copy(...)] and are read at runtime through Bundle.module, which is a synthesized accessor and a frequent source of 'unable to find bundle named ...' when a resource was not declared. Package.resolved is the lockfile: it pins the exact revision of each dependency so every machine and CI runner builds the same code.
Commit it for apps, and do not fight Xcode when it rewrites it after 'File > Packages > Update to Latest Package Versions'. Useful commands: swift build, swift test, swift package resolve, swift package update, swift package show-dependencies, and swift package purge-cache plus deleting DerivedData when resolution wedges. Two things Indian teams hit often: mixing SPM with an existing CocoaPods workspace is fine but each has its own lockfile and its own resolution failures, and .binaryTarget with an XCFramework is how closed-source SDKs (several payment and analytics vendors) ship for SPM.
// Package.swift
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "Networking",
platforms: [.iOS(.v16)],
products: [
.library(name: "Networking", targets: ["Networking"])
],
dependencies: [
.package(url: "https://github.com/apple/swift-log.git", from: "1.5.0")
],
targets: [
.target(
name: "Networking",
dependencies: [.product(name: "Logging", package: "swift-log")],
resources: [.process("Certificates")]
),
.testTarget(name: "NetworkingTests", dependencies: ["Networking"])
]
)
Key Points
- swift-tools-version gates manifest APIs and language defaults
- Package.resolved pins revisions; commit it for app repos
- Resources need .process/.copy and are loaded via Bundle.module
- .binaryTarget wraps vendor XCFrameworks for SPM
Q18When does Swift synthesize Equatable and Hashable, and why should you never persist a hashValue?
BasicProtocols
Answer
For a struct, the compiler synthesizes == and hash(into:) when you declare conformance and every stored property already conforms. For an enum, it synthesizes them when every associated value conforms; enums with no associated values get it unconditionally. Classes get nothing: you must write == yourself and decide what identity means, and you must implement hash(into:) combining the same fields you compared, because the contract is that equal values must produce equal hashes.
Getting that backwards, comparing three fields but hashing one, is legal but degrades every Set and Dictionary into linear probing; hashing more fields than you compare is an outright bug that makes lookups miss. Use hasher.combine(field) rather than XOR-ing hash values by hand, because Hasher implements a proper finalizing algorithm and hand-rolled mixes cluster badly. The hashValue point is the one interviewers like.
Swift seeds its hasher randomly on every process launch, so the integer you get for the same value differs between runs. That is a deliberate defence against hash-flooding attacks, and it means hashValue must never be written to UserDefaults, a database, a cache key on disk, or a network payload. If you need a stable fingerprint, compute one explicitly, for example SHA256 over a canonical string with CryptoKit.
The environment variable SWIFT_DETERMINISTIC_HASHING exists but is a debugging aid, not a production switch. One more trap: mutating a value after inserting it into a Set or using it as a dictionary key leaves the container with a stale bucket, so the element becomes unfindable.
struct Candidate: Hashable { // == and hash(into:) synthesized
let id: UUID
var name: String
}
final class Job: Hashable { // classes get nothing for free
let id: Int
init(id: Int) { self.id = id }
static func == (lhs: Job, rhs: Job) -> Bool { lhs.id == rhs.id }
func hash(into hasher: inout Hasher) { hasher.combine(id) }
}
// Wrong: the seed changes every launch
// UserDefaults.standard.set(candidate.hashValue, forKey: "lastSeen")
Key Points
- Synthesis works for structs and enums whose members conform, never classes
- Fields you compare in == must be the fields you feed to hash(into:)
- Hashing is randomly seeded per launch, so hashValue is not stable
- Mutating a key after insertion strands it in the wrong bucket
Q19Where do retain cycles come from in a typical UIKit or SwiftUI app, and how do you break each shape?
IntermediateMemory Management
Answer
Four shapes cover almost every real cycle. First, a closure stored on an object that captures that object strongly: a view model holds 'var onUpdate: ([Item]) -> Void' and the controller assigns a closure referring to self. Break it with [weak self] and the standard 'guard let self else { return }' opening.
Second, a strong delegate: 'var delegate: FooDelegate?' where the delegate is also the owner. Declare the protocol AnyObject-bound and the property weak. Third, block-based NotificationCenter observation: addObserver(forName:object:queue:using:) retains the block, so a captured self never dies; keep the returned token and removeObserver, or use [weak self].
Fourth, Timer: both scheduledTimer(timeInterval:target:...) and the closure variant retain until invalidate() is called, and because the run loop holds the timer, deinit will never fire, so 'I will invalidate in deinit' is a cycle that can never break itself. Invalidate in viewDidDisappear or on an explicit stop path. SwiftUI has its own variant: a @StateObject view model that captures a Task or a Combine sink referencing itself.
Also watch parent-child object graphs where both directions are strong, and CADisplayLink, which behaves like Timer. The senior nuance is knowing that [weak self] is not free of bugs either: if the closure is a long-running task and self dies mid-flight, you silently skip the completion, which can leave a spinner on screen forever. Decide explicitly whether the work should be cancelled or should complete against a weak reference.
final class FeedViewController: UIViewController {
private var token: NSObjectProtocol?
private var timer: Timer?
func start() {
// 1. closure stored elsewhere, capturing self
service.onUpdate = { [weak self] items in self?.render(items) }
// 2. block observer retains the block: keep the token
token = NotificationCenter.default.addObserver(
forName: .didLogout, object: nil, queue: .main
) { [weak self] _ in self?.reset() }
// 3. Timer retains until invalidate(); deinit will never run
timer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] t in
guard let self else { t.invalidate(); return }
self.poll()
}
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
timer?.invalidate()
token.map(NotificationCenter.default.removeObserver)
}
}
Key Points
- Closure on self capturing self is the most common cycle
- Delegates must be weak and the protocol AnyObject-bound
- Timer and block observers retain until you explicitly stop them
- [weak self] can silently drop completion work; decide, do not default
Q20A screen's memory keeps growing after repeated push and pop. How do you find out why?
IntermediateDebugging
Answer
Start with the cheap confirmation: add a debug-only deinit that logs the type, push and pop the screen five times, and see whether five deallocations appear. If they do not, you have a retained object, not a caching effect. Next, run in Xcode and hit the Debug Memory Graph button.
It snapshots the heap and draws the object graph; leaked cycles get a purple exclamation badge, and selecting any node shows every inbound reference, which usually identifies the offender in seconds. Turn on Malloc Stack Logging (Product > Scheme > Edit Scheme > Diagnostics, 'Live Allocations Only') beforehand so each node also carries the allocation backtrace, otherwise you see the object but not who created it. For growth without cycles, use Instruments.
The Allocations template with generation marks is the right tool: mark a generation, push and pop the screen, mark again, and inspect what survived in that generation. The Leaks instrument only catches unreachable memory, so it will miss a cache that grows forever, which is exactly why the generation workflow matters. In production, MetricKit gives you MXMemoryMetric and jetsam-style termination data, and out-of-memory kills appear as EXC_RESOURCE or missing sessions rather than crash reports, so you cannot rely on Crashlytics alone. The most common real culprits on Indian consumer apps are full-resolution UIImage decoding for thumbnail-sized views (fix with CGImageSourceCreateThumbnailAtIndex downsampling), unbounded NSCache substitutes built from plain dictionaries, and WKWebView instances kept alive by their navigation delegate.
#if DEBUG
deinit { print("dealloc \(type(of: self))") }
#endif
// Scheme > Diagnostics > Malloc Stack Logging (Live Allocations Only)
// Then: Debug > Debug Workflow > View Memory Graph Hierarchy
// Downsample instead of decoding full-resolution images:
func thumbnail(from data: Data, maxPixel: Int) -> CGImage? {
guard let src = CGImageSourceCreateWithData(data as CFData, nil) else { return nil }
let options: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceThumbnailMaxPixelSize: maxPixel,
kCGImageSourceShouldCacheImmediately: true
]
return CGImageSourceCreateThumbnailAtIndex(src, 0, options as CFDictionary)
}
Key Points
- Debug deinit logging first: confirm the object is actually retained
- Memory Graph Debugger plus Malloc Stack Logging names the owner
- Instruments Allocations with generation marks catches growth without cycles
- Image decoding, not language-level leaks, is the usual production cause
Q21How do associated types and where clauses shape a generic Swift API, and what changed with primary associated types?
IntermediateGenerics
Answer
An associated type is a placeholder the conforming type fills in, which turns the protocol into a generic recipe rather than a concrete interface. Collection is the standard-library example: Element and Index are associated types. Historically this had a hard consequence: a protocol with Self or associated type requirements could not be used as a type, only as a generic constraint, and everyone met the error 'Protocol can only be used as a generic constraint because it has Self or associated type requirements'.
The workaround was type erasure, either Apple's AnySequence and AnyView or a hand-rolled AnyRepository wrapping closures. Swift 5.7 changed the landscape. Existentials of protocols with associated types became legal as 'any Repository', and primary associated types let you constrain them in angle brackets: declare 'protocol Repository<Item>' and you can then write 'any Repository<Candidate>' or 'some Collection<String>'.
That removed most hand-written type erasers from modern codebases. Where clauses are how you express relationships the constraint list cannot: 'where R.Item.ID: Comparable', 'where Element == String', or the same-type constraints that make two generic parameters agree. Two extras that mark experience: constrained extensions ('extension Array where Element: Numeric') add behaviour without new types, and 'extension Foo where Self == Bar' powers the dot-shorthand you see in SwiftUI style and button APIs. Also mention specialisation: the optimiser generates concrete versions of generic functions within a module, and @inlinable or @_specialize is what gets that across module boundaries when profiling shows generic overhead.
protocol Repository<Item> { // primary associated type
associatedtype Item: Identifiable
func fetch(id: Item.ID) async throws -> Item
func all() async throws -> [Item]
}
func smallestID<R: Repository>(_ repo: R) async throws -> R.Item.ID?
where R.Item.ID: Comparable {
try await repo.all().map(\.id).min()
}
// Legal since Swift 5.7, no hand-written type eraser needed
let sources: [any Repository<Candidate>] = [remote, cached]
extension Array where Element: Numeric {
var total: Element { reduce(.zero, +) }
}
Key Points
- Associated types make a protocol generic over its conformer's types
- Swift 5.7 allows any Protocol and primary associated types any P<T>
- where clauses express same-type and nested constraints
- Constrained extensions replace most bespoke type-erasure wrappers
Q22What is the practical difference between 'some Protocol' and 'any Protocol'?
IntermediateGenerics
Answer
'some P' is an opaque type: exactly one concrete type satisfies it, the compiler knows which one, and the caller does not. There is no box, no dynamic dispatch, and type identity is preserved, so two values of 'some Equatable' returned from the same function can be compared. That preservation is why SwiftUI's body is 'some View': the framework needs a stable, statically known view tree to diff efficiently.
As a parameter position, 'func render(_ s: some Shape)' is pure sugar for a generic parameter, introduced in Swift 5.7. 'any P' is an existential: a box that can hold any conforming type, decided at runtime. It allows heterogeneous storage, '[any Shape]', which 'some' cannot do. The cost is real.
Small values fit in the existential's three-word inline buffer; anything larger is heap-allocated with the associated retain and release traffic, and every call goes through the witness table so the optimiser cannot inline it. In hot paths (a table view rendering a thousand rows, a parser loop) that shows up in Time Profiler as protocol witness thunks. The decision rule to state in an interview: reach for 'some' or a generic parameter by default; use 'any' only when you genuinely need to store mixed types together or break a compile-time dependency. Version detail worth naming: 'any' was introduced in Swift 5.6 as optional and Swift 6 makes it mandatory under the ExistentialAny feature, so migrating code sees a wave of 'use of protocol as a type must be written any Protocol' errors.
protocol Shape { func area() -> Double }
func makeCircle() -> some Shape { Circle(radius: 2) } // one type, no box
func pick(_ flag: Bool) -> any Shape { // may vary, boxed
flag ? Circle(radius: 2) : Square(side: 3)
}
let mixed: [any Shape] = [Circle(radius: 1), Square(side: 2)]
let total = mixed.reduce(0) { $0 + $1.area() } // witness table calls
// Parameter position: sugar for a generic
func render(_ shape: some Shape) { }
// identical to: func render<S: Shape>(_ shape: S) { }
Key Points
- some = one hidden concrete type, static dispatch, identity preserved
- any = runtime box, allows heterogeneous collections, dynamic dispatch
- Existential boxing plus witness thunks cost measurable time in hot loops
- Swift 6 makes the any keyword mandatory on protocol types
Q23How do you decode messy backend JSON with Codable when fields change type or arrive in snake_case?
IntermediateCodable
Answer
Start with the cheap tools. A CodingKeys enum maps Swift names to wire names field by field. JSONDecoder's keyDecodingStrategy = .convertFromSnakeCase converts globally, but it is a trap in mixed schemas: it applies to every key, it mangles acronyms ('user_id' becomes 'userId' but 'id_URL' style keys surprise people), and it conflicts confusingly with explicit CodingKeys because the strategy runs first.
Most teams pick explicit CodingKeys for anything they will maintain. Dates need dateDecodingStrategy; use .iso8601 when the backend really is ISO 8601, and if you supply a custom DateFormatter, always set locale to Locale(identifier: "en_US_POSIX"), otherwise a device set to a non-Gregorian calendar or a Hindi locale silently fails to parse, a bug Indian teams hit constantly because it never reproduces on the developer's machine. For fields that arrive as 12 sometimes and "12" other times, write init(from:) and try both decodes.
For optional-versus-absent, decodeIfPresent returns nil for a missing key while decode throws keyNotFound. Nested payloads use nestedContainer(keyedBy:forKey:) or an intermediate private struct. For arrays where one bad element must not kill the whole response, decode into a wrapper that decodes each element with try? inside an unkeyedContainer, the 'lossy array' pattern.
Finally, read the errors properly: DecodingError carries a codingPath, so log 'error as? DecodingError' with its context rather than the useless localizedDescription 'The data couldn't be read because it isn't in the correct format.'
struct Job: Decodable {
let id: Int
let title: String
let postedAt: Date
let salaryMax: Int?
enum CodingKeys: String, CodingKey {
case id, title
case postedAt = "posted_at"
case salaryMax = "salary_max"
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
// backend sends 12 or "12" depending on the endpoint
if let intID = try? c.decode(Int.self, forKey: .id) {
id = intID
} else {
id = Int(try c.decode(String.self, forKey: .id)) ?? 0
}
title = try c.decode(String.self, forKey: .title)
postedAt = try c.decode(Date.self, forKey: .postedAt)
salaryMax = try c.decodeIfPresent(Int.self, forKey: .salaryMax)
}
}
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
// Custom formatter? pin the locale or non-Gregorian devices fail:
// formatter.locale = Locale(identifier: "en_US_POSIX")
Key Points
- convertFromSnakeCase is global and interacts badly with explicit CodingKeys
- Always set en_US_POSIX on custom DateFormatters
- decodeIfPresent for absent keys; custom init(from:) for shifting types
- Log DecodingError.codingPath, not localizedDescription
Q24Explain structured concurrency in Swift: Task, async let, task groups, and where unstructured tasks fit.
IntermediateConcurrency
Answer
Structured concurrency means every child task has a parent scope that cannot return until its children finish, so lifetimes, cancellation and error propagation follow the shape of the code. Two constructs create children. 'async let' starts a task immediately and implicitly awaits it at the end of the scope; it is the right tool for a fixed, small number of parallel calls. Task groups, withTaskGroup and withThrowingTaskGroup, handle dynamic fan-out where the number of children depends on data, and they let you consume results as they complete with 'for try await'.
Both guarantee that cancelling the parent cancels the children and that the scope will not exit while a child is running. A very common interview trap: writing two sequential awaits and calling it concurrent. 'let a = await f(); let b = await g()' runs strictly one after the other; only async let or a group overlaps them. Task { } creates unstructured work: it escapes the current scope, inherits the enclosing actor isolation and priority, and you are responsible for storing the handle and cancelling it.
Task.detached inherits nothing, not actor, not priority, not task-local values, and should be rare. Also worth saying: async does not mean background thread. An async function called from the main actor runs on the main actor until it hits an await on something isolated elsewhere; the concurrency runtime uses a cooperative thread pool sized to the core count, so blocking one of those threads with a semaphore or a synchronous file read is far more damaging than blocking a GCD queue.
// Sequential: total time is the sum
let profile = try await api.profile()
let jobs = try await api.jobs()
// Concurrent children, awaited at scope exit
async let profileTask = api.profile()
async let jobsTask = api.jobs()
let (p, j) = try await (profileTask, jobsTask)
// Dynamic fan-out
let images = try await withThrowingTaskGroup(of: (Int, UIImage).self) { group in
for (index, url) in urls.enumerated() {
group.addTask { (index, try await loader.image(url)) }
}
var out: [Int: UIImage] = [:]
for try await (index, image) in group { out[index] = image }
return out
}
// Unstructured: escapes the scope, you own cancellation
loadTask = Task { await viewModel.refresh() }
Key Points
- async let and task groups create children the parent must outlive
- Two sequential awaits are not concurrent; people fake parallelism this way
- Task inherits actor and priority; Task.detached inherits nothing
- The cooperative pool is core-count sized, so never block it
Q25What problem do actors solve, and what is actor reentrancy?
IntermediateConcurrency
Answer
An actor protects its mutable state by serialising access: only one task executes actor-isolated code at a time, and callers from outside must await, which the compiler enforces. That removes an entire class of data races without you writing locks, and unlike a serial DispatchQueue it is checked at compile time rather than by convention. nonisolated marks members that touch no mutable state so they can be called synchronously. Reentrancy is the sharp edge.
Actors are reentrant: when an isolated method hits an await and suspends, the actor is free to start other queued work, and when your method resumes the state it read earlier may have changed. So actors guarantee no data races, not atomicity across suspension points. The canonical bug is a cache: check the dictionary, see a miss, await a network fetch, then store the result.
Ten concurrent callers all check before anyone stores, and you fire ten identical requests. The fix is to publish an in-flight Task handle into the dictionary before the first await, so later callers await the same task. Any invariant you rely on must be re-verified after every await.
Two more points that read as senior: actors do not guarantee a thread, work can resume on any cooperative pool thread, so thread-local state and thread-affine APIs are unsafe inside them; and @MainActor is a global actor whose executor is the main thread, which is why it is the exception to that rule. Actor hops also cost, so an actor called per row in a scroll loop can become the bottleneck.
actor ImageCache {
private var cache: [URL: UIImage] = [:]
private var inFlight: [URL: Task<UIImage, Error>] = [:]
func image(for url: URL) async throws -> UIImage {
if let cached = cache[url] { return cached }
if let running = inFlight[url] { return try await running.value }
let task = Task { try await Downloader.fetch(url) }
inFlight[url] = task // published BEFORE any suspension
defer { inFlight[url] = nil }
let image = try await task.value
cache[url] = image
return image
}
nonisolated var label: String { "ImageCache" } // touches no state
}
Key Points
- Actors serialise access to isolated state, enforced by the compiler
- Reentrancy: state can change across every await inside an actor
- Publish an in-flight Task before awaiting to deduplicate work
- Actors give no thread guarantee; only @MainActor pins the main thread
Q26What makes a type Sendable, and when is @unchecked Sendable defensible?
IntermediateConcurrency
Answer
Sendable marks a type as safe to pass across isolation boundaries, meaning between actors, into a Task, or through an @Sendable closure. The compiler infers it for structs and enums whose stored properties are all Sendable, as long as the type is not public (public types must declare conformance explicitly so the promise is part of the API contract). A final class can be Sendable if every stored property is an immutable let of a Sendable type; a non-final class cannot, because a subclass could add mutable state.
Actors are Sendable by definition since their state is isolated. Closures marked @Sendable must only capture Sendable values, which is why capturing a mutable local or a plain view model inside a Task produces 'Capture of ... with non-Sendable type in a @Sendable closure'. @unchecked Sendable is you telling the compiler you have proven safety it cannot see. That is defensible in exactly two situations: a class whose mutable state is fully guarded by a lock (NSLock, os_unfair_lock, or a dispatch queue) with no way to hand out unprotected references, and a wrapper around a legacy framework type that is documented thread-safe but not yet annotated.
Both deserve a comment stating the invariant, because the annotation silences all future checking, including on properties added next year. What is not defensible is sprinkling it to clear migration errors, which is the fastest way to reintroduce exactly the races Swift 6 was built to catch. For third-party SDKs that have not adopted concurrency, the right tool is '@preconcurrency import', which downgrades their Sendable diagnostics without lying about your own types.
struct Money: Sendable { let paise: Int }
final class Config: Sendable { // all lets, all Sendable
let baseURL: URL
init(baseURL: URL) { self.baseURL = baseURL }
}
// Defensible: state is entirely lock-protected and never escapes
final class AtomicCounter: @unchecked Sendable {
private let lock = NSLock()
private var value = 0
func increment() {
lock.lock(); defer { lock.unlock() }
value += 1
}
}
@preconcurrency import LegacyAnalyticsSDK
// Swift 6 rejects mutable global state outright:
// var sharedCounter = AtomicCounter()
// error: var 'sharedCounter' is not concurrency-safe because it is
// nonisolated global shared mutable state
Key Points
- Inferred for non-public structs/enums of Sendable members
- final class + all immutable Sendable lets can conform
- @unchecked Sendable only with a documented locking invariant
- @preconcurrency import is the right tool for un-migrated SDKs
Q27How does @MainActor work, and what causes 'Publishing changes from background threads is not allowed'?
IntermediateConcurrency
Answer
@MainActor is a global actor whose serial executor is the main thread. Applying it to a type isolates every member; applying it to a single function isolates that function; applying it to a property isolates access to that property. Calls from outside the main actor must await, and the compiler inserts the hop.
In recent SDKs, UIKit and SwiftUI view types are themselves annotated @MainActor, which is why calling a UILabel setter from a background context is now a compile error rather than a random UI glitch. The purple runtime warning 'Publishing changes from background threads is not allowed; make sure to publish values from the main thread' comes from ObservableObject: an @Published property was written off the main thread, so SwiftUI's objectWillChange fired on a background thread and the view update is racing the render loop. It is a warning, not a crash, and the symptoms in production are corrupted layout and intermittent crashes deep in SwiftUI internals rather than at the offending line.
The fix is to annotate the view model @MainActor, which makes the whole class safe by construction, instead of wrapping individual assignments in DispatchQueue.main.async. Related tools: 'await MainActor.run { }' for a one-off hop from non-isolated code, 'Task { @MainActor in }' to start work already isolated, and MainActor.assumeIsolated when a legacy delegate documents main-thread delivery but is not annotated, which asserts rather than hops. Also useful to know that an async function does not leave the main actor by itself: awaiting a URLSession call from a @MainActor method suspends and resumes on the main actor, so no manual dispatch back is needed.
@MainActor
final class JobListViewModel: ObservableObject {
@Published private(set) var jobs: [Job] = []
@Published private(set) var isLoading = false
func load() async {
isLoading = true
defer { isLoading = false }
// suspends here, resumes back on the main actor automatically
jobs = (try? await api.jobs()) ?? []
}
}
// Legacy callback documented as main-thread delivery:
nonisolated func legacyDidLoad(_ jobs: [Job]) {
MainActor.assumeIsolated { self.apply(jobs) } // asserts, no hop
}
// One-off hop from truly non-isolated code:
await MainActor.run { spinner.stopAnimating() }
Key Points
- @MainActor is a global actor pinned to the main thread's executor
- Annotate the whole view model instead of scattering DispatchQueue.main
- The purple warning means @Published was written off the main thread
- Awaiting inside a @MainActor method resumes on the main actor
Q28In SwiftUI, when do you use @State, @Binding, @StateObject and @ObservedObject, and what does the @Observable macro change?
IntermediateSwiftUI State
Answer
@State holds value-type state owned by this view; SwiftUI stores it outside the struct and keeps it alive across the many times your view struct is recreated. @Binding is a two-way reference to state owned somewhere else, produced with the $ projection, and it is how a child edits a parent's value without owning it. @StateObject and @ObservedObject both watch a reference-type ObservableObject, and the difference between them is the bug interviewers look for: @StateObject initializes its object exactly once per view identity, while @ObservedObject does not own the object at all. If you write '@ObservedObject var vm = ViewModel()' inline, that initializer runs on every re-evaluation of the parent's body, so the view model is silently recreated, in-flight loads restart, and typed text disappears. Rule: create with @StateObject, receive from a parent with @ObservedObject. @EnvironmentObject and @Environment cover dependency passing down the tree.
The Observation framework, available from iOS 17, changes the model layer. Marking a class @Observable removes ObservableObject, @Published, and the objectWillChange plumbing; you hold the instance in @State and SwiftUI tracks reads at the property level rather than the object level. That is the real win: a view that reads only 'model.title' no longer re-renders when 'model.items' changes, which fixes the whole-screen invalidation that plagued large ObservableObject view models.
Bindings to an @Observable come from @Bindable. If you still support iOS 16, you keep ObservableObject, and most Indian consumer apps only dropped iOS 16 recently, so knowing both models is genuinely expected.
@Observable // iOS 17+: no ObservableObject, no @Published
final class JobListModel {
var query = ""
var jobs: [Job] = []
}
struct JobListView: View {
@State private var model = JobListModel() // created once, owned here
var body: some View {
VStack {
SearchField(text: $model.query) // Binding via $
List(model.jobs) { JobRow(job: $0) }
}
}
}
// The classic bug:
struct Broken: View {
@ObservedObject var vm = LegacyViewModel() // recreated on every re-render
var body: some View { Text(vm.title) }
}
Key Points
- @StateObject owns and creates once; @ObservedObject only observes
- Inline @ObservedObject initialization resets state on every re-render
- @Observable tracks reads per property, cutting needless re-renders
- Hold an @Observable in @State, bind to it with @Bindable
Q29How does a property wrapper desugar, and what would you watch out for when writing one?
IntermediateLanguage Features
Answer
A property wrapper is a type with a wrappedValue property, annotated @propertyWrapper. Applying it rewrites your declaration into a stored property of the wrapper type plus a computed property that forwards get and set through wrappedValue. If the wrapper also declares projectedValue, the compiler exposes it under the dollar prefix, which is exactly how SwiftUI's $binding and Combine's $published work.
Writing one is the cleanest way to remove repeated boilerplate: UserDefaults access, clamping numeric ranges, trimming strings, or tagging values for logging redaction. Things to watch. The initializer story is fiddly: a wrapper used on a struct property changes the memberwise initializer's parameter type unless you provide init(wrappedValue:), and forgetting that breaks callers in confusing ways.
Wrappers cannot be applied to computed properties, lazy properties, or protocol requirements, and applying one to a local variable has restrictions. If wrappedValue's getter is mutating, every read makes the containing struct mutating, which quietly infects the whole call chain. Under Swift 6 concurrency, a wrapper used on a static var, the common UserDefaults flag pattern, now trips 'Static property is not concurrency-safe'; the honest fixes are to isolate the enum to an actor, make the wrapper Sendable with real locking, or mark it nonisolated(unsafe) with a comment. Also remember that a UserDefaults-backed wrapper reads on every access, so putting one in a SwiftUI body or a table view cell configure method is a per-frame disk-backed lookup; SwiftUI's own @AppStorage exists precisely because it also publishes changes, which a hand-rolled wrapper does not.
@propertyWrapper
struct UserDefault<Value> {
let key: String
let defaultValue: Value
var container: UserDefaults = .standard
var wrappedValue: Value {
get { container.object(forKey: key) as? Value ?? defaultValue }
set { container.set(newValue, forKey: key) }
}
var projectedValue: String { key } // available as $onboardingDone
}
enum Flags {
@UserDefault(key: "onboarding_done", defaultValue: false)
static var onboardingDone: Bool
}
// Desugars roughly to:
// private static var _onboardingDone = UserDefault(key: ..., defaultValue: false)
// static var onboardingDone: Bool {
// get { _onboardingDone.wrappedValue }
// set { _onboardingDone.wrappedValue = newValue }
// }
Key Points
- wrappedValue forwards access; projectedValue becomes the $ form
- Provide init(wrappedValue:) or you change the memberwise init
- A mutating getter makes every reader of the struct mutating
- Static wrappers now hit Swift 6 concurrency-safety diagnostics
Q30With async/await and Observation available, where does Combine still earn its place?
IntermediateReactive
Answer
Combine is still the shortest path for time-based operators over UI event streams. debounce, throttle, removeDuplicates, combineLatest, merge and retry with backoff are one line each, and the classic search-as-you-type pipeline (debounce 300ms, drop duplicates, switchToLatest onto a network call) remains cleaner in Combine than hand-rolled Task cancellation. It also has deep integration where Apple already exposes publishers: NotificationCenter.default.publisher, URLSession.dataTaskPublisher, Timer.publish, and @Published on ObservableObject. Where it loses is everywhere structured concurrency is a better fit: sequential dependent requests, cancellation tied to a view's lifetime, and error handling, because Combine's Failure type forces eraseToAnyPublisher, mapError and setFailureType gymnastics that async throws just does not need.
Debuggability matters too: an async stack trace reads like a stack trace, whereas a Combine failure is a wall of generic operator types. The direction of travel is clear. Apple is investing in Observation and async sequences rather than Combine, AsyncStream covers most Subject use cases, and the swift-async-algorithms package supplies debounce, throttle, combineLatest and chunked for AsyncSequence, which removes the last strong reason to reach for Combine in new code.
Practical advice for an interview: say you would not rewrite a working Combine layer, you would stop adding to it, bridge with 'for await value in publisher.values' where the two worlds meet, and be explicit that AnyCancellable stored in a Set is the memory-management analogue of holding a Task handle. Legacy Combine code in Indian product teams is usually large enough that fluency in both is a hiring requirement, not a nice-to-have.
// Combine: search-as-you-type in one pipeline
searchText
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
.removeDuplicates()
.sink { [weak self] query in self?.search(query) }
.store(in: &cancellables)
// Bridging Combine into async code
for await value in searchText.values {
await search(value)
}
// Native async sequence for notifications
for await _ in NotificationCenter.default.notifications(named: .didLogout) {
await session.clear()
}
Key Points
- Time operators (debounce, throttle) are still Combine's strongest suit
- async/await wins for sequential work, cancellation and readable errors
- publisher.values bridges an existing Combine layer into async code
- swift-async-algorithms brings debounce and combineLatest to AsyncSequence
Q31What are Swift's four dispatch mechanisms, and which declarations force which one?
IntermediatePerformance
Answer
Static (direct) dispatch resolves the call at compile time and can be inlined; it applies to structs and enums, global functions, final class members, private members, and anything in an extension of a class. Table dispatch through a vtable applies to overridable class methods: one indirection, cheap, but it blocks inlining. Witness table dispatch applies to protocol requirements called through a generic parameter or an existential, and it has the same shape as a vtable plus, for existentials, the cost of boxing the value.
Message dispatch through objc_msgSend applies to @objc dynamic members and anything inherited from Objective-C; it is the slowest, but it is also the only one that supports swizzling and KVO, which is why frameworks rely on it. The declarations that move a call down the cost ladder are the ones worth naming: 'final' removes the vtable slot, 'private' lets the compiler prove there are no overrides in the file, and building with whole-module optimisation (SWIFT_COMPILATION_MODE = wholemodule, the Release default) lets it prove the same across the module and devirtualize internal classes that are never subclassed. Two consequences to volunteer.
First, a method declared in a class extension cannot be overridden by a subclass because there is no vtable slot, and the resulting behaviour looks like a broken override. Second, Debug builds compile with -Onone and incremental mode, so nothing is devirtualized or inlined; profiling a Debug build and concluding that Swift is slow is a mistake interviewers see often. If Time Profiler shows protocol witness thunks or objc_msgSend dominating a scroll loop, the fixes are concrete: use generics instead of existentials, mark classes final, and keep hot types as structs.
struct Row { func height() -> CGFloat { 44 } } // static
class Cell { func configure() {} } // vtable
final class SealedCell { func configure() {} } // static
protocol Configurable { func configure() } // witness table
func setUp(_ item: some Configurable) { item.configure() } // witness, specializable
func setUp(_ item: any Configurable) { item.configure() } // witness + box
class Legacy: NSObject {
@objc dynamic func tapped() {} // objc_msgSend: swizzlable, KVO-able
}
extension Cell {
func decorate() {} // static: a subclass cannot override this
}
Key Points
- final, private and whole-module optimisation enable devirtualization
- Class extension methods are statically dispatched and not overridable
- @objc dynamic is required for KVO and swizzling, at msgSend cost
- Debug uses -Onone, so never draw performance conclusions from it
Q32What breaks when you expose Swift code to Objective-C, and what does @objc actually require?
IntermediateInterop
Answer
Direction matters. Objective-C into Swift goes through the bridging header, and the quality of the result depends entirely on nullability annotations: without NS_ASSUME_NONNULL_BEGIN or explicit nullable and nonnull, every object comes across as an implicitly unwrapped optional, which is how a legacy SDK plants a crash in otherwise safe Swift. Swift into Objective-C goes through the generated ProductName-Swift.h, and only @objc members of NSObject subclasses appear in it.
That is the constraint people trip over: Swift structs, enums with associated values, generics, tuples, protocols with associated types, and default parameter values simply cannot be represented, and the compiler says so with 'Method cannot be marked @objc because the type of the parameter cannot be represented in Objective-C'. The workaround is a thin NSObject-based adapter class exposing plain types. @objc alone makes a member visible to the runtime; 'dynamic' additionally forces message dispatch, and both are required for KVO and for swizzling. #selector requires an @objc method, which is why target-action code in a plain Swift class fails until you add it, and it is why 'unrecognized selector sent to instance' still appears in crash logs when a selector string drifts from the method name. Other practical items: @objcMembers on a class exposes everything (convenient, bad for binary size), NS_SWIFT_NAME renames an Objective-C API for Swift callers, NS_REFINED_FOR_SWIFT hides an API so you can wrap it with a nicer Swift signature, and bridging an NSArray to an Array is lazy but bridging its elements is not free, so tight loops across the boundary are measurably slower.
final class PaymentHandler: NSObject {
@objc func handleTap(_ sender: UIButton) { }
// Not representable in Objective-C:
// @objc func apply(_ result: Result<Int, Error>) { }
// error: method cannot be marked @objc because the type of the
// parameter cannot be represented in Objective-C
}
button.addTarget(handler,
action: #selector(PaymentHandler.handleTap(_:)),
for: .touchUpInside)
final class Player: NSObject {
@objc dynamic var isPlaying = false // dynamic is required for KVO
}
let observation = player.observe(\.isPlaying) { _, _ in }
Key Points
- Missing nullability annotations import as implicitly unwrapped optionals
- Only @objc members of NSObject subclasses reach the generated header
- Structs, generics and associated-value enums cannot cross the boundary
- KVO and swizzling need @objc dynamic, not just @objc
Q33How does Swift Testing differ from XCTest, and what still requires XCTest?
IntermediateTesting
Answer
Swift Testing, shipped with Xcode 16, replaces the XCTestCase subclass model with free functions and types annotated @Test, grouped by @Suite. Assertions collapse into two macros: #expect(condition) records a failure and continues, and try #require(value) throws to stop the test, which is what you use to unwrap an optional before touching it. Because they are macros, the failure output shows the evaluated subexpressions, so '#expect(job.salary == 1200000)' prints both actual values instead of XCTAssertEqual's bare 'XCTAssertEqual failed'.
Parameterised tests are first class: '@Test(arguments: [...])' runs one case per argument and reports each separately, which replaces the hand-rolled for-loop over fixtures that made XCTest failures hard to read. Async support is native, just mark the test async and await, no XCTestExpectation and no waitForExpectations timeouts. Traits cover the rest: .disabled("reason"), .tags, .timeLimit, .bug("JIRA-123"), and .serialized.
The default execution model is the big behavioural difference: Swift Testing runs tests in parallel and in the same process, and a suite is instantiated fresh per test, so shared mutable singletons that XCTest tolerated will now flake. Apply .serialized to a suite while you clean that up. XCTest is not going away.
UI tests with XCUIApplication, performance measurement with measure and XCTMetric, and existing suites all remain XCTest, and the two frameworks coexist in the same test target, so migration is incremental. Being able to say that clearly, including the parallel-by-default trap, is what separates a candidate who has actually migrated a suite from one who read the release notes.
import Testing
@Suite("Salary formatting")
struct SalaryFormatterTests {
@Test("formats in lakhs with Indian grouping")
func lakhs() {
#expect(format(1_250_000) == "12.5 LPA")
}
@Test(arguments: [0, -1, Int.max])
func handlesEdgeInputs(_ input: Int) {
#expect(!format(input).isEmpty)
}
@Test func decodesJob() throws {
let job = try #require(try JSONDecoder().decode(Job.self, from: fixture))
#expect(job.id == 12)
}
@Test(.timeLimit(.minutes(1)))
func loadsFeed() async throws {
let jobs = try await api.jobs()
#expect(jobs.count > 0)
}
}
Key Points
- #expect continues on failure; try #require stops and unwraps
- @Test(arguments:) gives per-case reporting for table-driven tests
- Parallel, in-process execution by default; .serialized is the escape hatch
- XCUIApplication UI tests and measure blocks stay on XCTest
Q34How would you make networking code testable without hitting the network?
IntermediateTesting
Answer
Two techniques, used together. The first is protocol-based injection at the seam you own: define 'protocol JobAPI { func jobs() async throws -> [Job] }', have the view model depend on that rather than on a concrete client or a singleton, and inject a stub in tests that returns fixtures or throws a specific error. This is the level at which you test view model logic, loading states, retry behaviour, and error mapping, and it needs no framework, just an initialiser parameter with a default.
The second is URLProtocol subclassing for when you want to test the real client, including the URLRequest it builds, the headers it attaches, and its decoding. Register a MockURLProtocol on a URLSessionConfiguration, set protocolClasses, and hand back canned responses per request. That exercises the genuine URLSession path, so a wrong HTTP method, a missing Authorization header, or a broken query item shows up.
Points that mark experience: use URLSessionConfiguration.ephemeral so the shared URL cache and cookie storage do not leak state between tests; assert on the request in the handler, not only on the decoded result; and cover the non-200 path, because most production bugs are in how you interpret a 401 or a 500 body, not in the happy path. For time-dependent code, inject a Clock rather than calling Task.sleep, so retry-with-backoff tests run instantly. And avoid asserting against a live staging server in CI: Indian teams running GitHub Actions runners routinely see those tests fail on network flakiness and then get disabled, which is worse than not having them.
final class MockURLProtocol: URLProtocol {
nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
guard let handler = Self.handler else { return }
do {
let (response, data) = try handler(request)
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
} catch {
client?.urlProtocol(self, didFailWithError: error)
}
}
override func stopLoading() {}
}
let config = URLSessionConfiguration.ephemeral
config.protocolClasses = [MockURLProtocol.self]
let session = URLSession(configuration: config)
Key Points
- Protocol injection for logic tests; URLProtocol stubs for client tests
- Use .ephemeral configuration so cache and cookies do not leak
- Assert on the outgoing URLRequest, not just the decoded response
- Inject a Clock instead of Task.sleep so backoff tests run instantly
Q35How does copy-on-write work in the standard library, and how do you implement it for your own type?
IntermediatePerformance
Answer
Array, String, Dictionary, Set and Data are structs holding a reference to a heap buffer. Copying the struct copies only that reference, so 'var b = a' is O(1). Before any mutation, the implementation checks whether it is the sole owner of the buffer; if not, it allocates a fresh buffer and copies the elements, then mutates.
That is why value semantics stay cheap until you actually diverge. You get the same behaviour in your own type with isKnownUniquelyReferenced(&storage), which returns true only when the passed reference is the single strong reference; it takes the storage inout precisely so it can inspect the caller's reference. The production failure mode is an accidental extra reference defeating the check.
Capture the array in a closure, hand it to another object, or keep a debug copy around, and every subsequent mutation in your loop allocates and copies the whole buffer, turning an O(n) loop into O(n squared). This shows up in Instruments as heavy time in swift_retain and specialized _copyToNewBuffer. The remedies to name: reserveCapacity before a known-size append loop so growth does not repeatedly reallocate; withUnsafeMutableBufferPointer for genuinely hot numeric loops; avoid returning array slices you then mutate; and remember that passing an array as a normal function parameter does not copy the buffer, it is a borrow, so 'copying is expensive' arguments about parameter passing are usually wrong. One caveat for interviews: isKnownUniquelyReferenced returns false for Objective-C classes, so your storage class must be a pure Swift final class.
final class Storage {
var values: [Int]
init(_ values: [Int]) { self.values = values }
func copy() -> Storage { Storage(values) }
}
struct Buffer {
private var storage: Storage
init(_ values: [Int]) { storage = Storage(values) }
var values: [Int] { storage.values }
mutating func append(_ value: Int) {
if !isKnownUniquelyReferenced(&storage) {
storage = storage.copy() // diverge only on write
}
storage.values.append(value)
}
}
var rows = [Int]()
rows.reserveCapacity(10_000) // one allocation instead of repeated growth
Key Points
- Standard collections share a buffer until the first mutation
- isKnownUniquelyReferenced(&storage) drives your own COW check
- A stray extra reference makes every mutation copy the whole buffer
- reserveCapacity and unsafe buffer pointers are the hot-loop fixes
Q36Where do KeyPaths pay off in Swift, and what are the different KeyPath types?
IntermediateLanguage Features
Answer
A key path is a first-class reference to a property, written with a backslash: '\Candidate.name'. The hierarchy matters. KeyPath gives read access.
WritableKeyPath additionally writes, but only through a mutable value-type root. ReferenceWritableKeyPath writes through a class instance even when the reference itself is a let, which is what makes SwiftUI bindings and NSObject's observe(\.property) work. PartialKeyPath erases the value type and AnyKeyPath erases both, useful for building generic table-driven code such as sort descriptors chosen at runtime.
Since Swift 5.2 a key path can be used where a function is expected, which is why 'candidates.map(\.name)' compiles and reads better than a closure. The real payoff is generic APIs parameterised by a property instead of a closure: a sum(by:) helper, a diffing engine told which field is the identity, a form builder that binds fields to model properties, or KeyPathComparator for multi-level sorting with sorted(using:). Combined with @dynamicMemberLookup, key paths let you build transparent wrapper types that forward property access to a wrapped value, which is how several state containers expose their contents without boilerplate.
The caveat worth raising unprompted is cost. Accessing through a key path is not free: it walks a runtime-encoded component list rather than emitting a direct offset load, so it is measurably slower than 'candidate.name' and does not inline the way a closure can. Fine for configuration and sorting, wrong for a per-frame loop over ten thousand elements. Interviewers like the question because it separates people who use \.id in SwiftUI from people who understand what it compiles into.
struct Candidate: Identifiable {
let id: UUID
var name: String
var experience: Int
}
let names = candidates.map(\.name) // key path as a function
let ranked = candidates.sorted(using: [
KeyPathComparator(\.experience, order: .reverse),
KeyPathComparator(\.name)
])
func total<T>(_ items: [T], by keyPath: KeyPath<T, Int>) -> Int {
items.reduce(0) { $0 + $1[keyPath: keyPath] }
}
let years = total(candidates, by: \.experience)
var first = candidates[0]
let writable: WritableKeyPath<Candidate, Int> = \.experience
first[keyPath: writable] = 7
Key Points
- KeyPath reads; WritableKeyPath needs a mutable value root
- ReferenceWritableKeyPath powers SwiftUI bindings and KVO observe(\.x)
- map(\.id) works because key paths convert to functions since Swift 5.2
- Key-path access walks runtime components; avoid it in per-frame loops
Q37How would you migrate a large UIKit-era codebase to the Swift 6 language mode without freezing feature work?
AdvancedSwift 6 Migration
Answer
The key structural fact is that the language mode is per module, so a mixed codebase compiles fine and you can migrate leaf modules first while the app target stays on Swift 5. The sequence that works: stay in Swift 5 mode and turn SWIFT_STRICT_CONCURRENCY from minimal to targeted, then to complete, on one module at a time. In Swift 5 mode these are warnings, so you get the full inventory of violations without blocking merges.
Only when a module is warning-clean do you flip SWIFT_VERSION to 6 for that module, which locks in the win and prevents regressions. Fix categories in order of volume. Global mutable state comes first and is usually the largest bucket: every 'static var shared' now errors as nonisolated global shared mutable state.
Convert singletons to let where possible, isolate the rest to an actor or @MainActor, and use nonisolated(unsafe) only for values genuinely protected by a lock, with a comment explaining why. Second, annotate the UI layer @MainActor rather than sprinkling MainActor.run, which usually deletes a lot of DispatchQueue.main.async at the same time. Third, make model types Sendable, which is mostly free for structs of value types and painful for Core Data managed objects and vendor SDK types.
For third-party frameworks that have not adopted concurrency, @preconcurrency import is the correct tool. What to avoid: mass @unchecked Sendable to clear the build. It compiles and it reintroduces exactly the races the mode was designed to catch, and reviewers at senior interviews will ask whether you did that.
// Xcode build settings, ratcheted per target:
// SWIFT_STRICT_CONCURRENCY = minimal -> targeted -> complete
// SWIFT_VERSION = 5 -> 6 (only once warning-clean)
// Swift package equivalent
.target(
name: "Networking",
swiftSettings: [.swiftLanguageMode(.v6)]
)
// The dominant error in a legacy app:
// static var shared = Session()
// error: static property 'shared' is not concurrency-safe because it is
// nonisolated global shared mutable state
@MainActor final class AppRouter { static let shared = AppRouter() }
// Only where a lock genuinely protects the value:
nonisolated(unsafe) private static var legacyCache = NSCache<NSString, UIImage>()
@preconcurrency import VendorAnalyticsSDK
Key Points
- Language mode is per module: migrate leaves first, app target last
- Use complete checking in Swift 5 mode to collect warnings without blocking
- Global mutable state is the biggest bucket; isolate or make it let
- @unchecked Sendable as a build fixer defeats the entire migration
Q38Explain cooperative cancellation in Swift concurrency, including why a cancelled URLSession request does not throw CancellationError.
AdvancedConcurrency
Answer
Cancellation in Swift is a flag, not a preemption. Calling cancel() on a Task, or cancelling a parent so the flag propagates to every child, sets isCancelled; nothing stops running by itself. Code must cooperate, either by polling Task.isCancelled in a loop, or by calling try Task.checkCancellation() at a natural boundary so it throws CancellationError.
Library primitives cooperate for you: Task.sleep throws when cancelled, unlike Thread.sleep, and async sequences generally finish. A CPU-bound loop that never checks will run to completion after cancellation, wasting battery, and that is the single most common cancellation bug. The URLSession detail is worth knowing cold.
When you cancel a Task that is awaiting URLSession.shared.data(for:), URLSession cancels the underlying request and the call throws URLError with code .cancelled, which is NSURLErrorCancelled, value -999. It does not throw CancellationError. So a generic catch that maps every error to an alert will show 'request failed' to a user who simply swiped back, and in SwiftUI this is extremely common because the .task modifier cancels automatically when the view disappears.
Filter both CancellationError and URLError.cancelled out of your error-reporting path, including out of Crashlytics non-fatals, or your dashboards fill with noise. For bridging older callback APIs, withTaskCancellationHandler runs its onCancel closure immediately on whatever context calls cancel, so that closure must be Sendable and must not touch actor-isolated state directly; the usual body is a single call into the legacy object's own cancel method.
func enrich(_ jobs: [Job]) async throws -> [Job] {
var out: [Job] = []
for job in jobs {
try Task.checkCancellation() // throws CancellationError
out.append(await score(job))
}
return out
}
// Bridge a legacy cancellable API
func download(_ url: URL) async throws -> Data {
let task = LegacyDownloader(url: url)
return try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { cont in
task.start { cont.resume(with: $0) }
}
} onCancel: {
task.cancel() // runs on the cancelling context, must be Sendable
}
}
// Do not report user-initiated cancellation as a failure
catch is CancellationError {
return
} catch let error as URLError where error.code == .cancelled {
return // NSURLErrorCancelled, -999
}
Key Points
- Cancellation sets a flag; loops must poll or call checkCancellation()
- Task.sleep throws on cancellation, Thread.sleep does not
- Cancelled URLSession work throws URLError.cancelled (-999), not CancellationError
- SwiftUI's .task cancels on disappear, so filter these from error reporting
Q39How do you wrap a delegate or callback API in an AsyncStream, and what goes wrong in production?
AdvancedConcurrency
Answer
AsyncStream turns a push-based API into a pull-based sequence. You construct it with a closure that receives a continuation, yield values from the callback, call finish() when the source ends, and set onTermination to unregister. Since Swift 5.9, AsyncStream.makeStream(of:) returns the stream and continuation as a tuple, which avoids the older pattern of smuggling the continuation out of the initializer closure.
Three things go wrong in real apps. First, buffering. The default policy is .unbounded, so if the producer (a location manager at 10Hz, a WebSocket firing on every tick) outruns the consumer, the buffer grows until memory pressure kills the app.
For UI state you almost always want .bufferingNewest(1), because stale intermediate values have no value; for a work queue you want .bufferingOldest(n) so you drop the newest rather than losing head-of-line items. Second, termination. onTermination fires both when the stream finishes normally and when the consuming task is cancelled, and it is the only reliable place to stop the underlying source; forget it and the location manager keeps running with the screen off. The closure is @Sendable and runs on an arbitrary context, so it must not touch isolated state directly.
Third, multiplicity. An AsyncStream is single-consumer, not a broadcast: iterate it from two places and the values split between them non-deterministically. If you need fan-out, keep an actor holding an array of continuations and yield to each, or stay with a Combine subject. Also mention that leaking a continuation without finishing it hangs the consumer forever with no error, which is much harder to spot than the CheckedContinuation misuse message.
func locationUpdates() -> AsyncStream<CLLocation> {
AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in
let delegate = LocationDelegate { location in
continuation.yield(location)
}
manager.delegate = delegate
manager.startUpdatingLocation()
continuation.onTermination = { @Sendable _ in
manager.stopUpdatingLocation() // fires on finish AND on cancel
}
}
}
// Swift 5.9 tuple form, easier to store and finish later
let (stream, continuation) = AsyncStream.makeStream(of: Event.self)
continuation.yield(.started)
continuation.finish()
for await location in locationUpdates() {
await viewModel.update(location)
}
Key Points
- Default .unbounded buffering grows without limit under a fast producer
- .bufferingNewest(1) is right for UI state; .bufferingOldest for work queues
- onTermination fires on cancellation too and is where you unregister
- AsyncStream is single-consumer; fan-out needs an actor holding continuations
Q40When is writing a Swift macro the right call, and what does it cost the build?
AdvancedMacros
Answer
Macros come in two families. Freestanding macros are invoked with a hash, like #expect in Swift Testing or #Predicate in SwiftData, and expand into an expression or declaration. Attached macros are written as attributes and add members, extensions, accessors or peers to the declaration they decorate; @Observable and SwiftData's @Model are the ones every candidate has used.
They are implemented as separate executables built against swift-syntax: your macro target parses the attached syntax tree and returns generated syntax, which the compiler splices in. Debugging is done with Xcode's Expand Macro action, and macros can emit their own diagnostics, which is a real advantage over a code-generation script that fails with a stack trace. The costs are concrete and interviewers expect you to name them.
The macro plugin compiles swift-syntax, which on a clean checkout or a cold CI runner adds a meaningful chunk of build time, and it must build for the host architecture, which complicates cross-compilation setups. Xcode also requires you to trust the macro plugin the first time, which trips up new joiners and CI images. Macros run at compile time with visibility only into the syntax handed to them: they cannot see other files, resolve types, or know your module's type graph, so anything requiring semantic information about the wider program is out of scope. The decision rule: reach for protocol extensions and generics first, then a build-phase code generator if the pattern is repetitive but not syntax-sensitive, and only write a macro when the generated code must vary with the exact shape of a declaration, as @Observable does when it rewrites every stored property into a tracked accessor.
// Declaration in the library target
@attached(member, names: named(id))
@attached(extension, conformances: Identifiable)
public macro AutoIdentifiable() =
#externalMacro(module: "AppMacros", type: "AutoIdentifiableMacro")
// Usage
@AutoIdentifiable
struct Job {
var title: String
}
// Package.swift wires the plugin target
.macro(
name: "AppMacros",
dependencies: [
.product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
.product(name: "SwiftCompilerPlugin", package: "swift-syntax")
]
)
Key Points
- Freestanding (#expect) versus attached (@Observable, @Model) macros
- Implemented with SwiftSyntax as a host-built compiler plugin
- Cold builds pay for compiling swift-syntax; CI images need plugin trust
- Macros see syntax only, never the module's resolved type graph
Q41What do noncopyable types and the borrowing/consuming parameter modifiers give you?
AdvancedOwnership
Answer
Declaring 'struct FileDescriptor: ~Copyable' suppresses the implicit Copyable conformance, so the value has exactly one owner at any moment. The compiler then tracks its lifetime: assigning it moves ownership, and using it afterwards is the error 'used after consume'. This is what makes a struct able to have a deinit at all, which is otherwise a class-only feature, and it is the point of the whole feature.
Resources that must be released exactly once, file descriptors, mmap regions, locks, pooled buffers, C handles, can now be modelled as values with automatic, compiler-verified cleanup and no reference counting. The parameter modifiers make the ownership convention explicit. 'borrowing' means the callee reads the value without taking ownership and without a retain, which is the default for normal parameters but becomes meaningful for noncopyable types. 'consuming' means the callee takes ownership, so the caller cannot use the value afterwards; a 'consuming func close()' is the idiomatic way to express an operation that ends the resource's life. The 'consume' operator forces an end of lifetime at a specific point.
For copyable types these modifiers are a performance tool: marking a parameter consuming can eliminate retain and release traffic in hot paths, which shows up in Time Profiler as swift_retain and swift_release. Generics need explicit opt-in with '<T: ~Copyable>' because the default constraint everywhere in Swift is Copyable. The honest framing for an interview is that this is a systems-level feature. It matters for Swift on the server, embedded Swift, and library authors wrapping OS resources, and most application code should recognise it rather than adopt it.
struct FileDescriptor: ~Copyable {
private let fd: Int32
init(path: String) throws {
fd = open(path, O_RDONLY)
guard fd >= 0 else { throw POSIXError(.ENOENT) }
}
consuming func close() {
let handle = fd
discard self // skip deinit, we are closing explicitly
Darwin.close(handle)
}
deinit { Darwin.close(fd) }
}
func size(of file: borrowing FileDescriptor) -> Int { 0 }
let file = try FileDescriptor(path: "/tmp/log")
_ = size(of: file) // borrow: file still usable
file.close() // consume
// size(of: file) // error: 'file' used after consume
Key Points
- ~Copyable gives single ownership and allows deinit on a struct
- borrowing reads without taking ownership; consuming ends the caller's use
- Ideal for OS resources that must be released exactly once
- Generics over noncopyable types need an explicit <T: ~Copyable>
Q42A production crash spike appears in Crashlytics with no reproducible steps. Walk through your triage.
AdvancedProduction Debugging
Answer
First establish whether the reports are trustworthy: check that the dSYM for that exact build was uploaded, matching UUIDs with dwarfdump, because a half-symbolicated stack sends people chasing the wrong frame. Cross-check Xcode Organizer's Crashes tab against Crashlytics, since Organizer reports come from devices with diagnostics sharing on and are the closest thing to ground truth. Then read the exception type, which narrows the cause enormously.
EXC_BREAKPOINT with SIGTRAP is a Swift runtime trap: force unwrap of nil, array index out of range, integer overflow, a failed precondition, or an unexpectedly nil implicitly unwrapped optional; the crashing line is usually exactly right. EXC_BAD_ACCESS with KERN_INVALID_ADDRESS is a memory error: an over-released Objective-C object, a dangling unowned(unsafe) reference, or a use-after-free from a C library, and the stack is often misleading, so reproduce it locally with Zombie Objects or Address Sanitizer. EXC_CRASH with SIGABRT typically means an uncaught NSException, and the reason string is in the log.
Termination codes matter too: 0x8badf00d is the watchdog killing you for blocking the main thread, usually during launch or scene connection; 0xdead10cc means you held a file or database lock while being suspended; and out-of-memory kills produce no crash report at all, which is why MetricKit's MXCrashDiagnostic and MXHangDiagnostic payloads are worth wiring up. Then correlate: does the spike start with a specific app version, OS version, or device class, and does it follow a backend deploy? Many 'sudden crash spikes' in Indian consumer apps are a schema change producing an unexpected null that a force unwrap or a non-optional Decodable field turns into a trap.
# Confirm the dSYM matches the crashing build before believing the stack
dwarfdump --uuid MyApp.app.dSYM/Contents/Resources/DWARF/MyApp
# Symbolicate a single frame by load address
xcrun atos -o MyApp.app.dSYM/Contents/Resources/DWARF/MyApp \
-arch arm64 -l 0x104b18000 0x104b2f4a8
# Release builds must produce dSYMs
# DEBUG_INFORMATION_FORMAT = dwarf-with-dsym
# Field data Crashlytics cannot give you (hangs, OOM signals)
# MXMetricManager.shared.add(self) -> didReceive(_ payloads: [MXDiagnosticPayload])
Key Points
- Verify dSYM UUIDs first; a bad symbol map wastes days
- EXC_BREAKPOINT = Swift trap, EXC_BAD_ACCESS = memory, SIGABRT = NSException
- 0x8badf00d is watchdog, 0xdead10cc is a lock held while suspended
- Out-of-memory kills leave no crash report; use MetricKit for those
Q43The app takes too long to launch on mid-range Android-class hardware equivalents. How do you measure and reduce launch time?
AdvancedPerformance
Answer
Split launch into pre-main and post-main, because the fixes are completely different. Pre-main is dyld work: loading and linking dynamic libraries, rebasing, binding, running Objective-C +load methods and C++ static initializers. Measure it by setting DYLD_PRINT_STATISTICS in the scheme's environment, or use the App Launch template in Instruments, which attributes time across both phases on a real device.
The dominant pre-main cost in most apps is the number of dynamically linked frameworks, so the lever is to link fewer of them: prefer static libraries where the vendor allows, and consider mergeable libraries in recent toolchains. Also audit for +load and static initializers, which run before your code and are a common hidden cost in older analytics SDKs. Post-main is your own code: application(_:didFinishLaunchingWithOptions:) or the SwiftUI App initializer plus the first frame of the first screen.
The classic failure is a stack of third-party SDK initialisations run synchronously at launch because each integration guide says to do it there. Defer everything not required for the first frame, keep any keychain or disk read off the critical path, and never make a network call blocking. Field data is what counts: Xcode Organizer's Launch Time metric and MetricKit's MXAppLaunchMetric report percentiles from real users, and in India your p90 device is not the one on your desk. Two related constraints worth naming: the watchdog terminates a launch that takes too long with 0x8badf00d, and download size matters commercially, so asset catalog thinning, on-demand resources, and -Osize keep you under the cellular download threshold that drives a lot of install drop-off.
# Scheme > Run > Arguments > Environment Variables
DYLD_PRINT_STATISTICS = 1
# Console then prints: total pre-main time, dylib loading, rebase/binding,
# ObjC setup, and initializer time
# Instruments > App Launch template, on a real mid-range device
xcrun xctrace record --template 'App Launch' --device <udid> --launch -- MyApp
// Move non-critical SDK setup off the launch path
func application(_ app: UIApplication,
didFinishLaunchingWithOptions opts: LaunchOptions?) -> Bool {
CrashReporter.start() // must be first, keep it
Task.detached(priority: .utility) {
await AnalyticsSDK.configure() // not needed for the first frame
await ExperimentSDK.refresh()
}
return true
}
Key Points
- DYLD_PRINT_STATISTICS separates pre-main from your own launch code
- Dynamic framework count dominates pre-main; static linking cuts it
- Defer SDK initialisation that the first frame does not need
- Trust MetricKit and Organizer percentiles, not your flagship test device
Q44A list scrolls with visible hitches on mid-range iPhones. How do you find the cause and fix it?
AdvancedPerformance
Answer
Start from the frame budget: 16.6ms per frame at 60Hz and 8.3ms on a 120Hz ProMotion display. A hitch means the main thread missed that window, so the question is only ever what ran on the main thread. Use the Animation Hitches instrument to get hitch time per scroll, then Time Profiler with the main thread isolated to see where it went.
Do not profile a Debug build; -Onone changes the shape of the trace completely. The recurring causes in production lists are predictable. Image work: decoding a 3000px JPEG for a 120pt thumbnail on the main thread during cell configuration, fixed by downsampling with CGImageSourceCreateThumbnailAtIndex on a background context and caching the result.
Layout: deeply nested stack views or repeated Auto Layout constraint churn per cell, fixed by simplifying hierarchy or using a compositional layout. Synchronous IO: reading UserDefaults, Keychain, or a Core Data fetch inside cellForRowAt. Date and number formatting: creating a DateFormatter per cell is famously expensive, so cache it.
In SwiftUI the equivalent questions are whether the body is doing real work, whether the view is being invalidated far more often than necessary, and whether identity is unstable so the diff recreates rows. Self._printChanges() in a body tells you which property triggered the update, and moving to @Observable narrows invalidation to the properties actually read. Wrap suspect sections in os_signpost intervals so Instruments shows your own labelled regions rather than raw symbols, which makes the before-and-after comparison defensible in a code review.
import os
let signposter = OSSignposter(subsystem: "ai.goodspace.feed", category: "scroll")
func configure(_ cell: JobCell, at index: Int) {
let state = signposter.beginInterval("configure")
defer { signposter.endInterval("configure", state) }
cell.apply(viewModels[index]) // already formatted, no work here
}
// Cache formatters, never build one per cell
private let salaryFormatter: NumberFormatter = {
let f = NumberFormatter()
f.numberStyle = .decimal
f.locale = Locale(identifier: "en_IN") // Indian digit grouping
return f
}()
// SwiftUI: find out what is actually invalidating the view
var body: some View {
let _ = Self._printChanges()
JobRow(job: job)
}
Key Points
- Budget is 16.6ms at 60Hz, 8.3ms at 120Hz; hitches are main-thread work
- Animation Hitches plus Time Profiler on a Release build, on device
- Image decoding, per-cell formatters and synchronous IO are the usual causes
- Self._printChanges() and @Observable narrow SwiftUI over-invalidation
Q45Design the concurrency for a screen that loads 500 remote images, cancels on exit, and never overwhelms the device.
AdvancedSystem Design
Answer
Say the constraints out loud first: bounded parallelism, deduplicated in-flight work, cancellation tied to view lifetime, and bounded memory. Then build it from three pieces. First, a bounded task group rather than a task per URL. withThrowingTaskGroup with a sliding window, add tasks until you have N in flight (six to eight is a reasonable start for network-bound work), then for every completed result add one more.
Firing 500 unstructured tasks is the wrong answer: even though Swift's cooperative pool will not spawn 500 threads the way GCD's overcommit queues would, you still create 500 sockets' worth of contention and blow through memory holding 500 decoded images. Second, an actor cache that stores the in-flight Task per URL so ten cells asking for the same avatar await one download, and that stores decoded thumbnails, not full-size images, with an NSCache or a size-capped dictionary behind it. Remember actor reentrancy here: insert the Task into the dictionary before the first await, or you race and duplicate.
Third, cancellation. In SwiftUI, the .task modifier cancels the whole tree when the view disappears, and in UIKit you store the Task handle on the cell and cancel it in prepareForReuse. Downstream, checkCancellation before decoding so a scrolled-past image does not burn CPU, and treat URLError.cancelled as a normal exit rather than an error. Two production notes: never block a cooperative pool thread with a semaphore to implement the concurrency limit, because there is no thread donation and you can deadlock the pool; and decode off the main actor, returning a prepared image, so the UI hop only assigns a ready object.
actor ImageLoader {
private let cache = NSCache<NSURL, UIImage>()
private var inFlight: [URL: Task<UIImage, Error>] = [:]
func image(for url: URL) async throws -> UIImage {
if let hit = cache.object(forKey: url as NSURL) { return hit }
if let running = inFlight[url] { return try await running.value }
let task = Task.detached(priority: .utility) {
let (data, _) = try await URLSession.shared.data(from: url)
try Task.checkCancellation()
return try downsample(data, maxPixel: 240)
}
inFlight[url] = task
defer { inFlight[url] = nil }
let image = try await task.value
cache.setObject(image, forKey: url as NSURL)
return image
}
}
// Bounded parallelism: sliding window of 6
func prefetch(_ urls: [URL], loader: ImageLoader) async {
await withTaskGroup(of: Void.self) { group in
var iterator = urls.makeIterator()
for _ in 0..<6 {
guard let url = iterator.next() else { break }
group.addTask { _ = try? await loader.image(for: url) }
}
while await group.next() != nil {
guard let url = iterator.next() else { continue }
group.addTask { _ = try? await loader.image(for: url) }
}
}
}
Key Points
- Bounded task group with a sliding window, not one Task per item
- Actor-held in-flight Task map deduplicates concurrent requests
- Cancel via .task or prepareForReuse, and checkCancellation before decoding
- Never gate concurrency with a semaphore inside the cooperative pool
Frequently Asked Questions
What salary can an iOS or Swift developer expect in India in 2026?
Typical bands look like this. Freshers with one shipped app land roughly ₹4-8 LPA, higher at product startups than at service firms. With two to four years of Swift you are usually in the ₹10-18 LPA range, and five to eight years of solid product work puts you around ₹20-35 LPA. Senior and lead roles at consumer product companies such as CRED, Swiggy, PhonePe, Zomato, Flipkart and Zerodha go well past that once ESOPs are counted, and Apple's own Bengaluru and Hyderabad teams pay at a different scale again. Two India-specific effects are worth knowing. First, iOS engineers usually clear a small premium over Android engineers at the same level because the local talent pool is thinner. Second, the premium is concentrated in companies whose revenue skews to iOS users, which in India means payments, fintech, travel, and premium commerce.
How long should I prepare for a Swift interview?
If you write Swift daily, two to three focused weeks is enough. Spend the first week on concurrency, because that is where most mid and senior rounds now concentrate: actors, Sendable, structured concurrency, cancellation, and the Swift 6 migration story. Week two on memory and performance: ARC and retain cycles, the Memory Graph Debugger, copy-on-write, dispatch, and Instruments. Week three on SwiftUI state, testing, and one end-to-end design rehearsal such as an image loading pipeline or an offline-first feed. If you are switching from Android or web, budget three to four months and make sure at least one real app of yours is on TestFlight or the App Store, because Indian hiring managers weight a shipped app more heavily than any certificate.
What is expected from a fresher versus an experienced Swift candidate?
Freshers are assessed on language fundamentals and evidence of shipping: optionals and safe unwrapping, value versus reference semantics, closures and capture lists, a working understanding of URLSession and JSON decoding, some layout skill in SwiftUI or Auto Layout, and one app you can talk about in detail. Nobody expects concurrency depth. From about three years, the questions change shape. You are expected to justify architecture decisions, find a retain cycle live, explain how you would migrate a codebase to strict concurrency, triage a crash from a symbolicated report, and describe your release process including TestFlight, phased rollout, and how you handled an App Store review rejection. Above five years, add ownership of a module, mentoring, and performance work you can quantify.
Is Swift worth learning in 2026?
Yes, if you want to work in the Apple ecosystem, and the reasoning is commercial rather than sentimental. Even though Android dominates Indian device share, iOS users disproportionately drive revenue for payments, fintech, travel and premium commerce apps, so those teams keep investing in native iOS. The supply of strong Swift engineers in India is smaller than for Android or web, which sustains the pay premium. The language itself has broadened too: Swift 6's data-race safety, server-side Swift, and embedded Swift all extend where the skill applies beyond phones. The honest caveats: you need a Mac, and roles cluster in Bengaluru, Hyderabad, Gurugram, Pune and Mumbai, with fewer remote-only listings than web engineering has.
How does Swift compare to Kotlin, Flutter and React Native as a career bet?
Kotlin is the closest mirror: null safety, coroutines instead of async/await, and a similar seniority ladder on Android. Engineers move between the two more easily than either camp expects, and Kotlin Multiplatform makes shared business logic with a native Swift UI layer a real option that several Indian product teams now run. Flutter and React Native reduce headcount for standard CRUD screens, which is why many startups adopt them, but those same teams still hire a native iOS specialist for performance-critical surfaces, platform integrations such as payments, widgets, App Clips and push, and for the parts of the app the cross-platform bridge handles badly. The defensible position is Swift plus SwiftUI plus concurrency depth, with enough React Native or Flutter familiarity to work in a hybrid codebase.
Do I still need UIKit and Objective-C in 2026?
UIKit, yes. Almost every Indian app older than about 2020 has a large UIKit surface, and interviews for those teams still ask about view controller lifecycle, Auto Layout, cell reuse, and how you interoperate between UIKit and SwiftUI with UIHostingController and UIViewRepresentable. New screens are usually SwiftUI, but maintenance is where most of the work is. Objective-C is different: reading fluency is enough. You should be able to open a legacy file, follow the logic, understand nullability annotations and the bridging header, and know why @objc dynamic is required for KVO and swizzling. Very few roles now ask you to write new Objective-C, and those that do will say so in the job description.
Introduction
Swift in 2026 is a very different interview subject than it was five years ago. The Swift 6 language mode made data-race safety a compiler guarantee, which means concurrency questions that used to be about DispatchQueue folklore are now about actors, Sendable, and isolation regions that the compiler itself enforces. SwiftUI is the default UI framework for new screens at most product companies, the @Observable macro has replaced much of the Combine boilerplate, and Swift Testing has begun displacing XCTest. At the same time, the fundamentals that Swift interviews have always leaned on, value semantics, ARC, optionals, and protocol-oriented design, still fill the first half of every technical round.
In India, iOS roles pay a premium precisely because the talent pool is thinner than Android or web. Swiggy, CRED, PhonePe, Zomato, Flipkart, Zerodha, and Paytm all run large consumer iOS apps, and Apple itself hires Swift engineers into its Bengaluru and Hyderabad offices. Interviewers at these companies rarely ask trivia. They give you a retain cycle and ask you to find it, show you a struct wrapping a class and ask what copying does, or ask how you would migrate a GCD-era codebase to Swift 6 strict concurrency without freezing feature work. Instruments, crash symbolication, and MetricKit come up in senior rounds because on-call reality demands them.
This guide contains 45 questions arranged from basic through advanced, matching how real interview loops escalate. The basic section locks down value types, optionals, closures, enums, error handling, and Swift Package Manager. The intermediate section covers the topics that decide mid-level offers: ARC edge cases, generics, some versus any, Codable in messy real-world JSON, actors, Sendable, and Swift Testing. The advanced section is where senior offers are won: Swift 6 migration strategy, task cancellation, AsyncStream bridging, macros, noncopyable types, and production crash triage. Most answers include runnable code, because Swift interviews are almost always conducted in front of a compiler.
Ready to practice Swift interviews?
Don't just read, practice these Swift questions live with an AI interviewer that asks follow-ups and scores your answers.