Two decisions shape nearly everything else in Swift. Most of what you define is a value: assigning it makes an independent copy, so nothing can change your data behind your back. And a thing that might be missing has a different type from a thing that is there, so the compiler makes you handle absence exactly where you wrote it. Anyone arriving from Java, Python or JavaScript mispredicts both. Learn them first; reference types, memory and concurrency are built on top.
Each chapter opens with the short version. Tap one to read the detail.
Structs copy, classes share
~2 min
A struct or enum is a value type: assigning one makes an independent copy. A class is a reference type: two names refer to one instance. That single split decides how mutation, let, equality and memory behave. Prefer structs; reach for a class only when you need one shared, identifiable object.
Assign a struct and you get an independent value; assign a class instance and you get a second name for one object.
struct Point { var x = 0 }
class Box { var x = 0 }
var a = Point(); var b = a; b.x = 99 // a.x is still 0
let p = Box(); let q = p; q.x = 99 // p.x is now 99, and p === q
Three consequences catch people out. let freezes a struct completely — every property, however it was declared — but on a class it only freezes which object you point at, so q.x = 99 above compiles even though q is a constant. A method that changes a struct's own properties must be marked mutating, and calling one on a constant gives error: cannot use mutating member on immutable value. And == asks "equal in value" while === asks whether two references are the same instance — a question that only exists for classes.
Inheritance is classes only. Structs and enums get shared behavior from protocols and extensions instead, and overriding is opt-in with override, which stops a subclass silently shadowing a method it never knew about.
Rule: default to struct; choose class when you genuinely need shared identity — and remember let on a class reference does not freeze the object.
Optionals, and the absence you cannot forget
~2 min
Int? is a different type from Int, so missing values cannot slip past unhandled. nil is not a null pointer — it is the empty case of a two-case value. Bind with if let or guard let, fall back with ??, and read ! as an assertion that stops the program rather than a conversion.
An optional either holds something or holds nothing, and a plain Int can never hold nothing. Everything else is how you get the value out.
let good = Int("123") // Optional(123)
let bad = Int("12x") // nil
if let n = good { print(n + 1) } // 124
print(bad ?? -1) // -1
Four moves cover almost all of it. if let x and guard let x bind the unwrapped value, reusing the name. ?? supplies a fallback. ?. chains and stops at the first nil, so the whole expression becomes optional. And ! force-unwraps, which is not a conversion at all: on nil it stops the program, so read it as a compact way of calling fatalError. A type written String! is still an optional; it just force-unwraps itself on every use, so it traps later rather than never.
Numbers surprise newcomers separately: nothing converts implicitly. let i = 3; i + 0.5 is a compile error, and you write Double(i). Integer arithmetic traps on overflow rather than wrapping — &+ is the opt-in wrapping version. One precedence trap is worth memorizing: ?? binds looser than arithmetic, so y ?? 0 + 1 means y ?? (0 + 1) and gives 5 for y = 5, not 6.
Rule: let the type carry the absence, and treat every ! as a crash you are personally guaranteeing will not happen.
Memory: deterministic, until two objects own each other
~2 min
Swift uses automatic reference counting (ARC), not a tracing garbage collector, so an object dies the instant its last strong reference goes — deterministically. Nothing sweeps up afterwards, which means two objects that hold each other strongly are never freed. weak and unowned are how you break that.
Reference counting applies to classes only; structs and enums are not counted at all. Every strong reference keeps the instance alive; the moment the count hits zero it is deallocated and deinit runs. That determinism is the feature — and the reason cycles are permanent: no collector comes along later to notice them.
class Node { let name: String; var peer: Node?
init(_ n: String) { name = n }
deinit { print("deinit \(name)") } }
var a: Node? = Node("A"); var b: Node? = Node("B")
a!.peer = b; b!.peer = a
a = nil; b = nil // prints nothing — both leaked
Marking one side weak var peer: Node? makes both deinit lines print. A weak reference is always an optional var, because ARC sets it to nil when the referent goes. unowned is the non-optional version for when the reference cannot outlive its target — cheaper, and a runtime trap if you are wrong.
A separate rule guards ordinary mutation: a write to a variable may not overlap another access to it, so passing one variable to two inout parameters is rejected outright.
Rule: pick weak when the other object may legitimately vanish first, unowned when it cannot — and if deinit never runs, look for a cycle before anything else.
Closures capture the variable, not its value
~2 min
A closure captures a reference to the surrounding variable, so it sees changes made after it was created. A capture list [x] snapshots instead. Closures are reference types, so a stored one that captures self is the most common way to build a retain cycle by accident.
Capture is by reference, which is what makes closures useful and what makes them surprising.
var total = 0
let show = { print(total) }
total = 42
show() // 42 — captured the variable
var snap = 0
let frozen = { [snap] in print(snap) } // capture list: snapshot now
snap = 42
frozen() // 0
A closure that outlives the call it was passed to must be marked @escaping, and inside one you have to write self explicitly. That is not bureaucracy: capturing self keeps the instance alive, so a class storing a closure that mentions self owns a closure that owns it back. [weak self] gives you an optional to check; [unowned self] gives you a non-optional that traps if you were wrong.
Functions carry two habits worth knowing early. Parameters have both an outward argument label and an inward name, so call sites read as sentences. And inout is copy-in copy-out, not a pointer — the value is written back when the function returns, which is why property observers fire on an inout argument.
Rule: when a closure is stored rather than called immediately, decide who owns whom before you write its body.
Protocols and extensions, and the dispatch trap
~2 min
Protocols describe capability, and extensions supply default implementations, which is how value types share behavior without inheritance. The trap: a member declared only in a protocol extension is dispatched statically, so calling it through the protocol runs the default even when the concrete type defines its own.
A protocol is a set of requirements; a protocol extension can implement them once for every conformer. You can also add conformance to a type you do not own, and conform conditionally — an array is Equatable exactly when its element type is, so [NotEq] == [NotEq] fails with requires that 'NotEq' conform to 'Equatable'.
Almost everyone assumes a conformer's own version always wins. It wins only for actual requirements — a member living only in the extension behaves differently.
The type of the variable, not the object, chose bye(). Fix it by declaring bye() in the protocol, at which point the conformer's version wins everywhere.
Access control is the neighboring tool: six levels from open down to private, with internal — module-wide — as the unwritten default. private is scope-and-file based, so extensions in the same file can still see it, and private(set) publishes a getter while keeping the setter to yourself.
Rule: if a conformer might want to override it, it belongs in the protocol, not only in the extension.
Generics, some and any
~2 min
A type parameter carries a relationship the caller picks. some P reverses that — the callee picks one fixed type and hides it. any P is a box holding different conforming types over time, at the cost of the identity that made the other two useful.
With <T> the caller chooses the type. With some P the callee chooses, and the caller only knows it conforms — but it is still one specific type, fixed at compile time. That is why every return in a some-returning function must produce the same type; returning a circle on one branch and a square on another gives the return statements in its body do not have matching underlying types.
any P is the existential: a box that can hold different conforming types, which is what lets a heterogeneous array exist at all.
func unit() -> some Shape { Circle(r: 1) } // one hidden type
let mixed: [any Shape] = [Circle(r: 1), Square(s: 2)] // several, boxed
if let c = mixed[0] as? Circle { print(c.r) } // 1.0
Boxing costs indirection and erases the type, so operations needing type identity — comparing two values with ==, feeding a result back into the same function — stop being available.
Generic types are invariant, and the standard library looks like a counter-example, which is what makes this confusing. [Dog] does convert to [any Animal] — arrays get a special element-wise conversion — so people assume theirs will too. Your own Box<Dog> will not become a Box<any Animal>: arguments to generic parameter 'T' are expected to be equal.
Rule: reach for some by default, any only when values of genuinely different types must live in one place.
Enums carry data, and switches must be exhaustive
~2 min
A Swift enum case can carry a payload, and each case can carry a different one — so an enum models "one of these shapes" rather than a numbered constant. switch must cover every case, which turns adding a case into a list of compile errors instead of a silent bug.
Cases with associated values make enums a modeling tool rather than a naming convention, and pattern matching is how you get the payload back out.
enum Outcome { case ok(Int), failed(reason: String), pending }
switch outcome {
case .ok(let n) where n > 100: print("big \(n)")
case .ok(let n): print("ok \(n)")
case .failed(let reason): print("failed: \(reason)")
case .pending: print("pending")
}
Omit .pending and the compiler says error: switch must be exhaustive. That is the payoff: add a case a year later and every switch that must change tells you so. The common mistake is reading that error as noise and adding default to clear it — which then silently absorbs every case you add later, including ones you wanted to hear about.
Cases are matched top to bottom and the first match wins, a where clause filters, and there is no implicit fallthrough — each case ends by itself. For a single case, if case .failed(let why) = outcome is lighter than a full switch. Raw values are the other flavor: enum Planet: Int numbers its cases, and Planet(rawValue:) is failable because not every number is a case.
defer belongs here too: deferred blocks run on every exit from a scope, including a thrown error, last-registered first.
Rule: model states as cases with payloads and let exhaustiveness do your review for you.
Errors are values, not exceptions
~2 min
A throwing function announces it in its signature, and every call to one is marked try. There is no stack unwinding, so propagating one costs about what returning costs. try? turns the error into nil and try! traps, and both discard information you usually wanted.
An error is an ordinary value conforming to Error, usually an enum, and throws is part of the function's type: a throwing function cannot go where a non-throwing one is required.
enum ParseError: Error { case empty, notANumber(String) }
func parse(_ s: String) throws -> Int {
defer { print("cleanup") } // runs on success AND on throw
guard !s.isEmpty else { throw ParseError.empty }
guard let n = Int(s) else { throw ParseError.notANumber(s) }
return n
}
let a = try? parse("x") // nil — the reason is gone
do { _ = try parse("") } catch ParseError.empty { print("empty") }
The distinction people get wrong is try? versus catch. try? is right when every failure means the same thing to you, and wrong when the caller needed to know which failure happened — the error value is discarded. try! claims the throw is impossible and stops the program if it is not.
Two refinements: rethrows marks a function that throws only when a closure you gave it throws, and throws(MyError) fixes the error type so a catch can switch over it exhaustively. Plain throws means any error, which is honest for code whose dependencies may add new failures.
Rule: throw a typed value rather than a string, and only reduce it to try? when you have decided the reason genuinely does not matter.
Initialization, and the memberwise one you can lose
~2 min
Every stored property must have a value before initialization ends — there are no implicit defaults. A struct gets a free memberwise initializer only while it declares no initializer of its own; writing one in an extension keeps both. Classes initialize in two phases and cannot touch self until the first finishes.
Structs get an initializer for free. Add your own and it disappears — the most common initialization surprise there is.
struct A { var x: Int; var y: Int }
A(x: 1, y: 2) // free memberwise init
struct C { var x: Int; var y: Int }
extension C { init(square s: Int) { x = s; y = s } }
C(x: 1, y: 2); C(square: 3) // both, because it is in an extension
Declaring init(square:) inside C instead gives error: extra arguments at positions #1, #2 in call on the memberwise form. Classes are stricter: a designated initializer must set every property it introduces before calling super.init, and cannot read self until that phase completes for the whole chain. init? returns nil on failure, which is how Int("12x") and Planet(rawValue: 9) work.
Properties come in matching flavors. Stored ones occupy memory; computed ones run a getter each access. lazy var defers its work to first use, must be a var, and is not thread-safe. willSet and didSet observe changes, but not while a type sets its own properties inside its initializer — an inherited property assigned after super.init() does fire, which is the distinction people miss.
Rule: put convenience initializers in an extension so the memberwise one survives.
Collections and strings are values too
~2 min
Arrays, dictionaries, sets and strings are all value types — copies are independent, with the physical copy deferred until a write. Their failure modes differ on purpose: an out-of-range array index traps, a missing dictionary key returns nil, and a string cannot be indexed by an integer at all.
Copying a collection gives an independent value; storage is shared behind the scenes until one side is written, so it always behaves as if the copy happened at once.
The subscripts diverge deliberately. An array index is a promise, so breaking it is a bug: xs[3] on three elements gives Fatal error: Index out of range. A dictionary key is a question, so a missing one is nil — and assigning nil removes the pair.
let xs = [10, 20, 30]
xs.first // Optional(10) — the safe ask
["a": 1]["zz"] // nil, no trap
var counts: [String: Int] = [:]
counts["hit", default: 0] += 1 // insert-or-update in one step
Strings take that further: a Character is an extended grapheme cluster, which may be several Unicode scalars, so positions are not integers. Append a combining accent to "cafe" and count stays 4 while the scalar count becomes 5 — and count is O(n) because it must walk the string, so use isEmpty to test emptiness. You index with values from index(_:offsetBy:), and slicing yields a Substring that keeps the whole original alive until you convert it back to a String.
Rule: use first or indices when an index might not exist, and convert a Substring to String the moment you intend to keep it.
async, await, and actors that take turns
~2 min
async lets a function suspend without blocking a thread; await marks every point where it might. Concurrency comes from creating tasks, not from the keyword. Actors serialize access to their own state — but they can interleave other work at each await, so an invariant must hold across every suspension.
Writing await does not make anything parallel. It marks where this code may pause and something else may run. Parallelism comes from starting tasks: async let for a fixed set, withTaskGroup for a dynamic one, and a child task can never outlive the scope that created it.
An actor protects mutable state by letting only one task run its isolated code at a time; reaching it from outside requires await, and forgetting gives error: actor-isolated property 'n' can not be referenced from a nonisolated context. A thousand concurrent increments therefore land at exactly a thousand.
The trap is reentrancy. An actor guarantees exclusivity between suspension points, not across them:
func withdraw(_ amount: Int) async -> Bool {
guard balance >= amount else { return false }
try? await Task.sleep(for: .milliseconds(50)) // others run here
balance -= amount // the guard is now stale
return true
}
Two concurrent calls both pass the guard and the balance goes negative. Keep the check and the mutation in one synchronous stretch with no await between them.
Sendable marks a type safe to hand across that boundary; value types of sendable parts usually qualify automatically. Cancellation is cooperative — a flag your task chooses where to honor. The rules tighten over time through numbered proposals reviewed in public, and you can adopt a future one early with an upcoming-feature flag.
Rule: never let an invariant span an await.
Property wrappers, result builders and macros
~2 min
Three ways to stop writing the same code by hand. A property wrapper moves storage and access rules into a reusable type. A result builder rewrites a block of expressions into constructor calls, which is what makes declarative blocks legal. A macro generates source at compile time and can only add, never delete.
A property wrapper is a type with a wrappedValue; applying it as an attribute routes every read and write through that type. A projectedValue gives you a second interface reachable with $.
@propertyWrapper
struct Clamped {
private var value = 0
let range: ClosedRange<Int>
init(wrappedValue: Int, _ range: ClosedRange<Int>) {
self.range = range
self.wrappedValue = wrappedValue // runs the setter below
}
var wrappedValue: Int {
get { value }
set { value = min(max(newValue, range.lowerBound), range.upperBound) }
}
var projectedValue: Bool { value == range.upperBound }
}
struct Volume { @Clamped(0...11) var level = 5 }
// v.level = 99 -> v.level is 11, and v.$level is true
A result builder turns a block of bare expressions into a data structure: the compiler rewrites the statements into buildBlock calls, and buildOptional, buildEither and buildArray are precisely what make if, else and for legal inside such a block — omit them and that control flow is a compile error, not a missing feature.
Macros run only during compilation, and the expansion becomes ordinary source, checked like anything else. The assumption to drop is that a macro rewrites your code: expansion is strictly additive, so it adds declarations but never deletes or alters what you wrote. A type carrying one still means everything it visibly says.
Rule: reach for a wrapper when the rule is about one property, a builder when it is about the shape of a block, and a macro only when neither can express it.
Written by Keentune. We are not affiliated with or endorsed by the organizations whose documentation informs this guide, and any linked sources belong to their respective owners.
All exam, test, and product names and trademarks are the property of their respective owners and are used here for identification and reference only. Keentune is independent study practice — not affiliated with, authorized, or endorsed by any of these organizations.