Learning Swift: A Comprehensive Guide from Fundamentals to Advanced

A pure-Swift tutorial. No iOS, no UIKit, no SwiftUI — just the language itself, from the ground up to modern concurrency, macros, and protocol-oriented programming.


Table of Contents

  1. Getting Started with Swift
  2. Variables, Constants, and Type Inference
  3. Basic Data Types
  4. Operators
  5. Strings and Characters
  6. Collections: Array, Dictionary, Set
  7. Control Flow
  8. Functions
  9. Closures
  10. Optionals — Swift's Most Important Feature
  11. Enumerations
  12. Structures and Classes
  13. Properties
  14. Methods
  15. Initialization
  16. Inheritance
  17. Protocols
  18. Extensions
  19. Protocol-Oriented Programming
  20. Generics
  21. Error Handling
  22. Type Casting
  23. Access Control
  24. Memory Management and ARC
  25. Property Wrappers
  26. Result Builders
  27. Opaque and Existential Types (some vs any)
  28. Key Paths
  29. Pattern Matching Deep Dive
  30. Concurrency: async/await Fundamentals
  31. Tasks, Task Groups, and Cancellation
  32. Actors and Data Isolation
  33. Sendable and Data-Race Safety
  34. AsyncSequence and AsyncStream
  35. Macros
  36. Custom Operators and Advanced Idioms
  37. Where to Go Next

1. Getting Started with Swift

Swift is a compiled, statically-typed, multi-paradigm language created by Apple and now developed in the open at swift.org. Although it was born for Apple's platforms, modern Swift runs on Linux, Windows, and embedded targets, and is used for servers, scripts, command-line tools, and systems programming. In this guide we will use only Swift's standard library — no iOS, no UIKit, no SwiftUI — so every example here can be run anywhere Swift compiles.

Setting up a playground for this guide

The fastest way to follow along is the Swift REPL. After installing Swift (via Xcode, swiftly, or your package manager), you can start it from a terminal:

swift

You'll get an interactive prompt where you can type expressions and see results immediately:

Welcome to Swift!
  1> let greeting = "Hello, Swift"
greeting: String = "Hello, Swift"
  2> print(greeting)
Hello, Swift

For longer programs, create a file main.swift and run it:

swift main.swift

For project-style work, use the Swift Package Manager:

mkdir LearnSwift && cd LearnSwift
swift package init --type executable
swift run

Your first Swift program

Create a file called hello.swift and add this single line:

print("Hello, Swift!")

Then run it:

swift hello.swift

There's no main function, no import statement required, no boilerplate. Top-level code in a Swift file simply runs in order. This makes Swift comfortable for quick scripts as well as large applications.

How to read this guide

Each section introduces a concept, shows minimal examples, then builds up to more realistic patterns. Every code block is self-contained — you can paste it into the REPL or a .swift file. Where a feature has rough edges, sharp corners, or "wait, why?" moments, I'll call them out explicitly rather than glossing over them, because those are the things that bite later.


2. Variables, Constants, and Type Inference

Swift has two ways to introduce a name that refers to a value: var and let. Choosing between them is the most frequent decision you'll make in Swift, and it's far from trivial.

let declares a constant

A let binding cannot be reassigned after its initial value is set. Reach for let first, every time:

let pi = 3.14159
let name = "Ada"
// name = "Grace"  // ❌ Compile error: Cannot assign to value: 'name' is a 'let' constant

var declares a variable

Use var only when you actually need to reassign:

var counter = 0
counter = 1
counter += 1
print(counter)   // 2

Why prefer let?

It isn't just style. let communicates intent to the compiler and to readers: this value will not change. The compiler can optimize around that knowledge, and readers don't have to scan the rest of the function asking "where else does this get mutated?" In practice, the vast majority of bindings in idiomatic Swift code are let. If you find yourself reaching for var reflexively, pause — there is often a clearer way to express the same logic without mutation.

Type inference

Swift figures out types from the right-hand side of an assignment:

let temperature = 72        // inferred as Int
let ratio = 0.75            // inferred as Double
let label = "Pressure"      // inferred as String
let isReady = true          // inferred as Bool

You can also write the type explicitly using a type annotation:

let temperature: Int = 72
let ratio: Double = 0.75
let label: String = "Pressure"

Annotations are useful when the inferred type isn't what you want:

let count = 5            // Int
let count2: Double = 5   // Double — without the annotation it would be Int

They're also useful when you want to declare a name without an initial value:

let result: String
if someCondition {
    result = "yes"
} else {
    result = "no"
}
// result is set exactly once, on every path. The compiler verifies this.

This pattern — let declared without a value, then assigned exactly once on every code path — is called definite initialization. It lets you keep let semantics even when the value depends on runtime control flow.

Naming rules

Swift identifiers are Unicode-aware. These are all valid:

let café = "espresso"
let π = 3.14159
let 🍕 = "pizza"  // technically legal, please don't

Stick to descriptive ASCII names. The Swift API Design Guidelines (which we'll touch on throughout this guide) prefer clarity over brevity: userIndex beats uIdx, and removeAll(where:) beats clear.

Multiple declarations on one line

Allowed, occasionally readable:

var x = 0, y = 0, z = 0

But splitting these out usually reads better. Aesthetic minimalism in Swift rarely improves clarity.


3. Basic Data Types

Swift's standard library provides a small set of fundamental types. Every one of them is a struct, not a primitive — there's no special "primitive vs object" distinction the way Java has int and Integer. This makes the type system much more uniform.

Integers

let a: Int = 42
let b: Int8 = 127           // 8-bit signed:    -128 ... 127
let c: Int16 = 32_000       // 16-bit signed
let d: Int32 = 2_000_000    // 32-bit signed
let e: Int64 = 9_000_000_000_000_000_000  // 64-bit signed

let u: UInt = 100           // unsigned, platform-sized
let u8: UInt8 = 255         // 0 ... 255

Use Int unless you have a specific reason to pick a sized integer. Int is 64-bit on modern hardware, matches the platform's word size, and interoperates cleanly with the standard library (Array.count is Int, for example).

The underscores are digit separators. They're ignored by the compiler and exist purely for readability — 1_000_000 is identical to 1000000.

Numeric literals

Swift supports several literal forms:

let decimal = 17
let binary = 0b10001       // 17
let octal = 0o21           // 17
let hex = 0x11             // 17

let oneMillion = 1_000_000.0
let exponent = 1.25e2      // 125.0
let hexFloat = 0xFp2       // 60.0  (15 * 2^2)

Floating-point types

let f: Float = 3.14         // 32-bit, ~6 decimal digits of precision
let d: Double = 3.14        // 64-bit, ~15 decimal digits of precision

Double is the default when you write a floating-point literal without an annotation. Prefer Double unless memory or interop forces Float.

Type conversion

Swift does no implicit numeric conversion. This trips up people coming from C, JavaScript, or Python:

let i: Int = 5
let d: Double = 2.0
// let result = i + d        // ❌ Cannot convert value of type 'Int' to expected argument type 'Double'
let result = Double(i) + d   // ✅ explicit

This rule is annoying for ten minutes and saves you from class-of-bug-X for the rest of your career. The conversion happens via an initializer — Double(5) constructs a new Double from the Int.

let pi = 3.14159
let asInt = Int(pi)          // 3, truncates toward zero
let asInt2 = Int(pi.rounded()) // 3, but rounded first
let asInt3 = Int(pi.rounded(.up)) // 4

Booleans

let isFinished = true
let isEmpty = false

if isFinished { print("done") }

Bool is not convertible to Int. if 1 { ... } does not compile. The condition of an if must be a Bool.

Type aliases

You can give an existing type a new name:

typealias Byte = UInt8
typealias Coordinate = (x: Double, y: Double)

let red: Byte = 255
let origin: Coordinate = (x: 0, y: 0)

Useful for documentation and for shortening verbose generic types later.

Tuples

A tuple groups multiple values into one compound value:

let point = (3, 4)
let labeledPoint = (x: 3, y: 4)
let mixed: (String, Int, Bool) = ("Ada", 36, true)

You can decompose tuples by destructuring:

let (x, y) = point
print(x, y)        // 3 4

let (name, age, _) = mixed   // _ ignores the third element
print(name, age)

Or access by name (if labeled) or index:

print(labeledPoint.x)   // 3
print(labeledPoint.y)   // 4
print(point.0)          // 3
print(point.1)          // 4

Tuples are excellent for returning multiple values from a function without creating a whole new type:

func minMax(of numbers: [Int]) -> (min: Int, max: Int)? {
    guard let first = numbers.first else { return nil }
    var minimum = first
    var maximum = first
    for n in numbers.dropFirst() {
        if n < minimum { minimum = n }
        if n > maximum { maximum = n }
    }
    return (minimum, maximum)
}

if let bounds = minMax(of: [3, 1, 7, 2, 9, 4]) {
    print("min: \(bounds.min), max: \(bounds.max)")
}

The named return type (min: Int, max: Int) lets callers access fields by name. We'll get to optionals (? and if let) shortly.


4. Operators

Swift's operators look familiar but have a few important differences worth knowing up front.

Arithmetic

let sum = 5 + 3       // 8
let diff = 5 - 3      // 2
let prod = 5 * 3      // 15
let quot = 5 / 3      // 1   (integer division — truncates)
let rem = 5 % 3       // 2

let fquot = 5.0 / 3.0 // 1.666...

Integer division truncates toward zero. To get a fractional result, at least one operand must be a floating-point type.

Overflow behavior

Standard arithmetic operators trap (crash) on overflow:

let x: Int8 = 120
// let y = x + 100   // 💥 runtime crash: overflow

When you actively want wrapping behavior, use the & operators:

let a: UInt8 = 255
let b = a &+ 1        // 0, wraps around
let c = a &* 2        // 254, wraps

This is one of Swift's safety-by-default principles: unexpected wraparound has caused legendary bugs in software history, so the language makes you ask for it explicitly.

Compound assignment

var n = 10
n += 5    // 15
n -= 3    // 12
n *= 2    // 24
n /= 4    // 6
n %= 4    // 2

There's no ++ or -- in Swift. They were removed in Swift 3 because they encouraged subtle bugs (pre/post increment confusion) and had no advantage over += 1.

Comparison

let isEqual = 5 == 5      // true
let isNotEqual = 5 != 6   // true
let isLess = 3 < 5        // true
let isLessEq = 5 <= 5     // true

These return Bool. == and != work for any type that conforms to the Equatable protocol; < and friends require Comparable. We'll cover protocols later.

Logical

let a = true && false    // false
let b = true || false    // true
let c = !true            // false

&& and || short-circuit — the right-hand side is only evaluated if needed:

func expensiveCheck() -> Bool {
    print("ran expensive check")
    return true
}

let result = false && expensiveCheck()   // expensive check is NOT called

Range operators

These produce Range values you'll use constantly with loops and slicing:

let closed = 1...5         // 1, 2, 3, 4, 5  (ClosedRange)
let halfOpen = 1..<5       // 1, 2, 3, 4     (Range)
let oneSided = 1...        // PartialRangeFrom — 1, 2, 3, ...
let upTo = ...5            // PartialRangeThrough
let upBefore = ..<5        // PartialRangeUpTo

for i in 1...5 { print(i) }   // 1 2 3 4 5

Ternary conditional

let age = 17
let category = age >= 18 ? "adult" : "minor"

Useful for quick choices, but resist the urge to nest them. Nested ternaries are unreadable; use if expressions (Swift 5.9+) or switch instead.

if and switch as expressions (Swift 5.9+)

Modern Swift lets if and switch produce values:

let temperature = 72
let comfort = if temperature < 60 {
    "cold"
} else if temperature > 80 {
    "hot"
} else {
    "comfortable"
}
let grade = 85
let letter = switch grade {
case 90...: "A"
case 80..<90: "B"
case 70..<80: "C"
default: "F"
}

This replaces a lot of the awkward "declare a var, then assign inside an if" pattern.

Nil-coalescing

let maybeName: String? = nil
let name = maybeName ?? "Anonymous"   // "Anonymous"

We'll cover this fully in the optionals section, but it's a daily-use operator.


5. Strings and Characters

Swift's String type is more sophisticated than the C-style "array of bytes" string most languages start with. It's Unicode-correct by default, which means you'll occasionally hit surprises if you assume one character equals one byte. Once you internalize the model, it's a joy to work with.

Creating strings

let single = "Hello"
let empty = ""
let alsoEmpty = String()

let multiline = """
    Roses are red,
    Violets are blue,
    Swift is great,
    And so are you.
    """

For multi-line strings, the indentation of the closing """ defines a baseline that's stripped from each line. Lines indented further than the closing delimiter keep their relative indentation.

Concatenation and interpolation

let first = "Ada"
let last = "Lovelace"
let full = first + " " + last       // concatenation

let age = 36
let intro = "\(full) is \(age) years old."   // interpolation

Inside \(...) you can put any expression, even function calls:

let nums = [1, 2, 3, 4]
print("Total: \(nums.reduce(0, +))")   // Total: 10

For repeated string building, prefer interpolation or += over many + operations — the compiler is generally smart enough, but interpolation expresses intent best.

Characters

Character represents a single user-perceived character — a grapheme cluster in Unicode terms. This is what your eye sees as one "letter," even if it's encoded as multiple Unicode scalars under the hood.

let letter: Character = "A"
let emoji: Character = "🇨🇦"        // one Character, even though it's two scalars
let accented: Character = "é"      // one Character

To iterate characters:

for ch in "Swift" {
    print(ch)
}

Length

There's a subtle but critical point here:

let flag = "🇨🇦"
print(flag.count)   // 1  — ONE user-visible character

But underneath:

print(flag.unicodeScalars.count)  // 2 — two Unicode scalars
print(flag.utf8.count)            // 8 — eight UTF-8 code units
print(flag.utf16.count)           // 4 — four UTF-16 code units

This means String.count is O(n) — Swift has to walk the string to count grapheme clusters. If you find yourself comparing count repeatedly in a loop, cache it.

String indices

You can't index a String with Int because grapheme clusters are variable-width. Instead, use String.Index:

let s = "Hello, world!"
let firstChar = s[s.startIndex]              // "H"
let secondChar = s[s.index(after: s.startIndex)] // "e"
let fifth = s[s.index(s.startIndex, offsetBy: 4)] // "o"
let lastChar = s[s.index(before: s.endIndex)]  // "!"

endIndex is past-the-end — using it directly to subscript will crash. Use index(before:) to get the last valid character.

Substrings

Slicing produces a Substring, which shares storage with the original String:

let greeting = "Hello, world!"
let comma = greeting.firstIndex(of: ",")!
let hello = greeting[..<comma]          // "Hello" — type Substring
print(hello)

Substrings are designed to be short-lived. To store them long-term, convert back to String:

let helloString = String(hello)

This avoids holding a reference to the larger original string longer than necessary.

Common operations

let s = "  Hello, World!  "

s.uppercased()           // "  HELLO, WORLD!  "
s.lowercased()           // "  hello, world!  "
s.trimmingCharacters(in: .whitespaces)   // "Hello, World!"

s.hasPrefix("  Hel")     // true
s.hasSuffix("!  ")       // true
s.contains("World")      // true

let parts = "a,b,c,d".split(separator: ",")  // [Substring]
let joined = ["a", "b", "c"].joined(separator: "-")  // "a-b-c"

"Hello".replacingOccurrences(of: "l", with: "L")     // "HeLLo"

String comparison

== performs canonical equivalence — two strings are equal if they look the same to a human reader, even if they're encoded differently:

let café1 = "café"           // é as single scalar U+00E9
let café2 = "cafe\u{0301}"   // e + combining acute accent U+0301
print(café1 == café2)        // true
print(café1.count == café2.count)  // true (1 grapheme each, plus "caf")

This is the Right Behavior for almost all user-facing comparisons.

Raw strings

Sometimes you want to write a string without processing backslashes — especially for regex patterns or Windows paths:

let path = #"C:\Users\Ada\file.txt"#
let withQuote = #"She said "hi"."#
let regex = #"^\d{3}-\d{4}$"#

The # delimiters disable backslash escapes inside the string. To interpolate, use \#(...):

let name = "Ada"
let raw = #"Hello, \#(name) — backslash \n stays literal."#
// "Hello, Ada — backslash \n stays literal."

You can use multiple # if your string contains a single #. The number of opening and closing pounds must match.

Character properties

let c: Character = "7"
c.isNumber         // true
c.isLetter         // false
c.isWhitespace     // false
c.isUppercase      // false
("A" as Character).isUppercase  // true

Useful for parsing without pulling in regex.


6. Collections: Array, Dictionary, Set

Swift gives you three primary collection types, all generic and all value types.

Arrays

Arrays are ordered, support duplicates, and are indexed by Int:

let primes = [2, 3, 5, 7, 11]
var fruits = ["apple", "banana"]
fruits.append("cherry")
fruits += ["date", "elderberry"]
print(fruits)   // ["apple", "banana", "cherry", "date", "elderberry"]

The full type name is Array<String>, which can also be written [String]. The bracket syntax is shorter and more common.

Creating arrays

let empty: [Int] = []
let alsoEmpty = [Int]()
let zeros = Array(repeating: 0, count: 5)   // [0, 0, 0, 0, 0]
let range = Array(1...5)                    // [1, 2, 3, 4, 5]

Reading

let nums = [10, 20, 30, 40]

nums.count          // 4
nums.isEmpty        // false
nums.first          // Optional(10)
nums.last           // Optional(40)
nums[0]             // 10  — crashes if out of bounds
nums.indices        // 0..<4

Subscripting with [i] traps on out-of-bounds. To safely access, use first(where:), range checks, or write a safe-subscript extension (we'll do that in the extensions section).

Modifying

var nums = [10, 20, 30, 40]
nums.append(50)
nums.insert(5, at: 0)                    // [5, 10, 20, 30, 40, 50]
nums.remove(at: 0)                       // [10, 20, 30, 40, 50]
nums.removeLast()                        // [10, 20, 30, 40]
nums.removeAll(where: { $0 > 20 })       // [10, 20]
nums[0] = 100                            // [100, 20]
nums.swapAt(0, 1)                        // [20, 100]
nums.reverse()                           // [100, 20]
nums.sort()                              // [20, 100]

Functional operations

These return new arrays without mutating the original:

let nums = [1, 2, 3, 4, 5]

let doubled = nums.map { $0 * 2 }              // [2, 4, 6, 8, 10]
let evens = nums.filter { $0.isMultiple(of: 2) } // [2, 4]
let total = nums.reduce(0, +)                  // 15
let firstEven = nums.first(where: { $0.isMultiple(of: 2) })  // Optional(2)
let allPositive = nums.allSatisfy { $0 > 0 }   // true
let containsThree = nums.contains(3)           // true

let mixed = [[1, 2], [3, 4], [5]]
let flat = mixed.flatMap { $0 }                // [1, 2, 3, 4, 5]

let words = ["hi", "", "world"]
let nonEmpty = words.compactMap { $0.isEmpty ? nil : $0 }  // ["hi", "world"]

The $0 syntax is shorthand for the first closure argument — we'll cover closures in detail soon.

Dictionaries

Dictionaries map keys to values, where keys must be Hashable (we'll cover that protocol later):

var ages: [String: Int] = ["Ada": 36, "Grace": 79]
ages["Linus"] = 54
ages["Ada"] = 37            // overwrite
print(ages["Grace"]!)       // 79
print(ages["Unknown"])      // nil

The full type name is Dictionary<String, Int>. Bracket syntax [String: Int] is preferred.

Looking up a key returns an Optional because the key might not exist. Always handle the nil case:

if let age = ages["Ada"] {
    print("Ada is \(age)")
}

let age = ages["Ada", default: 0]   // gives 0 if missing

Iterating

for (name, age) in ages {
    print("\(name): \(age)")
}

for name in ages.keys.sorted() {
    print(name)
}

for age in ages.values {
    print(age)
}

Dictionaries are unordered — iteration order is not guaranteed and may differ between runs.

Removing

ages["Linus"] = nil           // removes the key
ages.removeValue(forKey: "Ada")
ages.removeAll()

Useful operations

let counts = ["apples": 3, "pears": 5, "bananas": 2]

let total = counts.values.reduce(0, +)         // 10
let manyFruits = counts.filter { $0.value > 2 } // ["apples": 3, "pears": 5]
let doubled = counts.mapValues { $0 * 2 }      // ["apples": 6, ...]

// Group items by some key
let words = ["apple", "ant", "banana", "berry", "cherry"]
let byFirstLetter = Dictionary(grouping: words, by: { $0.first! })
// ["a": ["apple", "ant"], "b": ["banana", "berry"], "c": ["cherry"]]

Sets

Sets are unordered collections of unique, hashable values:

var primes: Set<Int> = [2, 3, 5, 7]
primes.insert(11)
primes.insert(2)               // ignored — already present
print(primes.count)            // 5
print(primes.contains(7))      // true

Sets are extremely fast for membership tests (O(1) average) — far faster than Array.contains for large collections.

Set algebra

This is where sets shine:

let a: Set = [1, 2, 3, 4]
let b: Set = [3, 4, 5, 6]

a.union(b)              // [1, 2, 3, 4, 5, 6]
a.intersection(b)       // [3, 4]
a.subtracting(b)        // [1, 2]
a.symmetricDifference(b) // [1, 2, 5, 6]

a.isSubset(of: [1, 2, 3, 4, 5])       // true
a.isDisjoint(with: [10, 20])          // true

Value semantics

This is critical: arrays, dictionaries, and sets in Swift are value types. Assigning or passing a collection makes a (logically) independent copy:

var a = [1, 2, 3]
var b = a
b.append(4)
print(a)   // [1, 2, 3]
print(b)   // [1, 2, 3, 4]

In languages where collections are reference types (Java, Python), this trips people up. In Swift, mutating b does not affect a.

Internally, the collections use copy-on-write — they share storage until one is modified, at which point the modified one gets its own buffer. So you don't pay full copy cost on every assignment, only when there's actual mutation across multiple references.


7. Control Flow

Swift's control flow constructs share DNA with C-family languages but with several important refinements: no fall-through in switch by default, exhaustiveness checking on switch, optional binding in if/while/guard, and powerful pattern matching that we'll explore in depth later.

if / else

let temp = 72
if temp < 60 {
    print("cold")
} else if temp > 80 {
    print("hot")
} else {
    print("comfortable")
}

The condition must be a Bool. Parentheses around the condition are optional and idiomatic Swift omits them. Braces are required, even for single statements — Swift learned from "the goto fail bug" and made this non-negotiable.

if let and if var

This is one of Swift's signature features — optional binding:

let possibleName: String? = "Ada"

if let name = possibleName {
    print("Hello, \(name)")  // name is non-optional inside the block
} else {
    print("No name")
}

You can chain multiple bindings and add boolean conditions:

func parsePoint(x: String?, y: String?) {
    if let x, let y, let xi = Int(x), let yi = Int(y), xi >= 0, yi >= 0 {
        print("Point(\(xi), \(yi))")
    } else {
        print("Invalid input")
    }
}

In Swift 5.7+ the shorthand if let x (without = x) unwraps an optional into a same-named non-optional inside the block. This eliminates a lot of if let foo = foo boilerplate.

guard

guard is the inverse of if let — it asserts a condition holds, and if it doesn't, leaves the current scope:

func process(_ input: String?) {
    guard let input, !input.isEmpty else {
        print("Need non-empty input")
        return
    }
    // input is non-optional and non-empty here, AND remains in scope
    // for the rest of the function — unlike if let
    print("Processing \(input)")
}

The else block of a guard must transfer control out — return, throw, break, continue, or call a function returning Never (like fatalError).

guard is the cornerstone of "early-exit" style. Use it to handle precondition failures up front, leaving the happy path un-indented:

// Without guard — pyramid of doom
func login(username: String?, password: String?) -> Bool {
    if let username {
        if !username.isEmpty {
            if let password {
                if password.count >= 8 {
                    // do the work
                    return true
                }
            }
        }
    }
    return false
}

// With guard — flat, readable
func login2(username: String?, password: String?) -> Bool {
    guard let username, !username.isEmpty else { return false }
    guard let password, password.count >= 8 else { return false }
    // do the work
    return true
}

switch

Swift's switch is far more powerful than C's. Cases can match values, ranges, tuples, types, and patterns. There's no implicit fall-through — each case ends after its body executes:

let n = 7
switch n {
case 0:
    print("zero")
case 1, 2, 3:
    print("small")
case 4...10:
    print("medium")
case _ where n.isMultiple(of: 2):
    print("big and even")
default:
    print("big and odd")
}

switch must be exhaustive. The compiler will reject this:

let n = 7
// switch n {           // ❌ 'switch' must be exhaustive
// case 0: print("zero")
// case 1: print("one")
// }

For an Int, you'll need a default case (because there are too many values to enumerate). For finite types like enums (next chapter), the compiler verifies you've covered every case.

Tuple matching

let point = (x: 3, y: 0)
switch point {
case (0, 0):
    print("origin")
case (_, 0):
    print("on the x-axis")
case (0, _):
    print("on the y-axis")
case let (x, y) where x == y:
    print("on the diagonal")
case let (x, y):
    print("at (\(x), \(y))")
}

let inside a case binds matched components for use in the body. _ matches anything without binding. We'll go much deeper into pattern matching later.

Multi-value cases

switch character {
case "a", "e", "i", "o", "u":
    print("vowel")
default:
    print("consonant")
}

fallthrough

If you actually want C-style fall-through, you have to ask:

let n = 5
switch n {
case 5:
    print("five")
    fallthrough
case 6:
    print("at least five")
default:
    print("done")
}
// prints: five, at least five, done

Rarely needed, but available.

Loops

for-in

for i in 1...5 {
    print(i)
}

for i in stride(from: 0, to: 100, by: 10) {
    print(i)   // 0, 10, 20, ..., 90
}

for ch in "hello" {
    print(ch)
}

let pairs = [("Ada", 36), ("Grace", 79)]
for (name, age) in pairs {
    print("\(name): \(age)")
}

Use _ if you don't care about the loop variable:

for _ in 0..<3 {
    print("hi")
}

Indexed iteration with enumerated()

let names = ["Ada", "Grace", "Linus"]
for (index, name) in names.enumerated() {
    print("\(index): \(name)")
}

while and repeat-while

var n = 10
while n > 0 {
    n -= 1
}

var m = 0
repeat {
    m += 1
} while m < 5

repeat-while runs the body at least once before checking the condition (it's Swift's equivalent of do-while in C).

break and continue

for i in 1...10 {
    if i == 5 { continue }   // skip 5
    if i == 8 { break }      // stop entirely
    print(i)
}
// prints: 1 2 3 4 6 7

Labeled statements

When you have nested loops, you can label the outer one and break from the inner all the way out:

outer: for row in 0..<10 {
    for col in 0..<10 {
        if grid[row][col] == target {
            print("found at \(row), \(col)")
            break outer
        }
    }
}

defer

defer schedules a block to run when the current scope exits, regardless of how it exits — normal return, early return, thrown error, anything except a crash:

func processFile(path: String) throws {
    let handle = openFile(path)
    defer {
        closeFile(handle)   // always runs, no matter how the function exits
    }
    try parse(handle)
    try validate(handle)
    // closeFile runs here automatically
}

Multiple defers in the same scope run in reverse order (LIFO):

func demo() {
    defer { print("first defer") }
    defer { print("second defer") }
    print("body")
}
demo()
// body
// second defer
// first defer

This makes resource cleanup symmetric: if you acquire A then B, you release B then A.


8. Functions

Functions in Swift are first-class values, support multiple return values via tuples, have argument labels distinct from parameter names, and can have default values, variadics, in-out parameters, and trailing closures.

Defining functions

func greet(name: String) -> String {
    return "Hello, \(name)"
}

let message = greet(name: "Ada")
print(message)

For single-expression functions, the return keyword is optional:

func square(_ x: Int) -> Int { x * x }

Functions that return nothing have return type Void (or ()), and you can omit the -> Void:

func log(_ message: String) {
    print("[LOG] \(message)")
}

Argument labels vs parameter names

Swift distinguishes between the argument label (what callers write) and the parameter name (what the function body uses):

func greet(to person: String, with greeting: String) -> String {
    return "\(greeting), \(person)"
}

print(greet(to: "Ada", with: "Hello"))

Inside the body, person and greeting are the names. At the call site, to: and with: are the labels. This lets the function signature read naturally as English while keeping the body code clean.

To omit the label entirely at the call site, use _:

func add(_ a: Int, _ b: Int) -> Int { a + b }
add(2, 3)

To make the label match the parameter name (the most common case), just write one identifier:

func multiply(by factor: Int, value: Int) -> Int { value * factor }
multiply(by: 3, value: 4)

Default values

func greet(_ name: String, greeting: String = "Hello") -> String {
    "\(greeting), \(name)"
}

greet("Ada")                       // "Hello, Ada"
greet("Ada", greeting: "Hi")       // "Hi, Ada"

Default values let you have one function instead of overloads.

Variadic parameters

A ... after a type lets a parameter accept zero or more values:

func sum(_ numbers: Int...) -> Int {
    numbers.reduce(0, +)
}

sum(1, 2, 3)         // 6
sum(1, 2, 3, 4, 5)   // 15
sum()                // 0

Inside the function, numbers is just an [Int].

In-out parameters

By default, function parameters are constants — you can't modify them inside the function. To mutate a value passed in, use inout:

func swapInts(_ a: inout Int, _ b: inout Int) {
    let temp = a
    a = b
    b = temp
}

var x = 1
var y = 2
swapInts(&x, &y)
print(x, y)   // 2 1

The & at the call site is mandatory — it makes the side effect visible to the reader. inout parameters can't have default values and can't be variadic.

(Swift's standard library has a swap function that you should use in real code; this is an example of the mechanism.)

Returning multiple values

Use a tuple:

func divmod(_ a: Int, _ b: Int) -> (quotient: Int, remainder: Int) {
    (a / b, a % b)
}

let r = divmod(17, 5)
print(r.quotient, r.remainder)   // 3 2

Functions as values

A function has a type. The type of func square(_ x: Int) -> Int is (Int) -> Int:

func square(_ x: Int) -> Int { x * x }

let op: (Int) -> Int = square
print(op(5))   // 25

You can pass functions as arguments and return them from other functions:

func apply(_ f: (Int) -> Int, to x: Int) -> Int {
    f(x)
}

print(apply(square, to: 4))   // 16
func makeMultiplier(by factor: Int) -> (Int) -> Int {
    func multiply(_ x: Int) -> Int { x * factor }
    return multiply
}

let triple = makeMultiplier(by: 3)
print(triple(5))   // 15

The inner function captures factor from its enclosing scope. This is a closure — and the next chapter is all about them.

Nested functions

A function defined inside another function is only visible inside its enclosing function:

func processList(_ values: [Int]) -> [Int] {
    func isValid(_ n: Int) -> Bool { n > 0 && n < 1000 }
    return values.filter(isValid)
}

This is a clean way to extract helpers without polluting the outer scope.

@discardableResult

If a function returns a value that callers might reasonably ignore, the compiler usually warns when they do. Annotate the function to suppress that warning:

@discardableResult
func saveAndReturnID(_ value: String) -> Int {
    // ... save logic ...
    return 42
}

saveAndReturnID("hello")   // no warning despite ignoring the return

Use this sparingly — usually the warning is right.

Never return type

A function that never returns (it always throws or terminates the program) returns Never:

func crash(_ message: String) -> Never {
    fatalError(message)
}

This is genuinely useful in switch because the compiler knows Never-returning calls don't continue:

enum Direction { case north, south, east, west }

func describe(_ d: Direction) -> String {
    switch d {
    case .north: return "N"
    case .south: return "S"
    case .east: return "E"
    case .west: return "W"
    }
}

9. Closures

A closure is a self-contained block of code that you can pass around. Functions are special-cased closures — they have a name. The unnamed kind (closure expressions) shows up everywhere in Swift, especially in collection operations and asynchronous APIs. Closures can capture variables from their surrounding scope, which makes them powerful and occasionally surprising.

Closure expression syntax

The full form:

{ (parameters) -> ReturnType in
    statements
}

Example — same logic as square but as a closure:

let square = { (x: Int) -> Int in
    return x * x
}
print(square(5))   // 25

Swift will let you progressively simplify this. Suppose we sort an array using a custom rule:

let names = ["Charlie", "Alice", "Bob"]

// Full form
let sorted1 = names.sorted(by: { (a: String, b: String) -> Bool in
    return a < b
})

// Type inference — Swift knows the parameter and return types from `sorted(by:)`
let sorted2 = names.sorted(by: { a, b in
    return a < b
})

// Implicit return for single-expression closures
let sorted3 = names.sorted(by: { a, b in a < b })

// Shorthand argument names $0, $1, ...
let sorted4 = names.sorted(by: { $0 < $1 })

// Operator method — < is itself a function (String, String) -> Bool
let sorted5 = names.sorted(by: <)

All five produce the same result. In real code you'll see the last few much more often than the first.

Trailing closure syntax

When a closure is the last argument to a function, you can write it after the parentheses:

let nums = [1, 2, 3, 4, 5]

// Without trailing closure
let doubled1 = nums.map({ $0 * 2 })

// With trailing closure
let doubled2 = nums.map { $0 * 2 }

If the closure is the only argument, you can drop the parentheses entirely (as above).

Multiple trailing closures (Swift 5.3+)

If a function takes multiple closures, you can use multiple trailing closures:

func fetch(
    onSuccess: (String) -> Void,
    onFailure: (Error) -> Void
) {
    // ... pretend logic ...
    onSuccess("data")
}

fetch { data in
    print("Got: \(data)")
} onFailure: { error in
    print("Error: \(error)")
}

The first trailing closure has no label; subsequent ones do.

Capturing values

Closures capture references to constants and variables from the surrounding scope:

func makeCounter() -> () -> Int {
    var count = 0
    let increment: () -> Int = {
        count += 1
        return count
    }
    return increment
}

let counter = makeCounter()
print(counter())   // 1
print(counter())   // 2
print(counter())   // 3

Even though count is local to makeCounter, it persists because the returned closure captured it. Each call to makeCounter() creates an independent count:

let a = makeCounter()
let b = makeCounter()
print(a(), a(), b(), a())   // 1 2 1 3

This is a classic example of why "closure" is the right name — it closes over its environment.

Capture lists

Sometimes you want to capture by value instead of by reference, or you want to capture self weakly to avoid a retain cycle (we'll get to that in the memory management chapter). Capture lists let you control this:

var x = 10
let captureByValue = { [x] in
    print("inside closure: \(x)")
}
x = 20
captureByValue()   // prints 10  — captured the old value
print(x)           // 20

Without the capture list, the closure would print 20.

For reference types (classes, which we'll cover later), capture lists let you avoid keeping objects alive longer than necessary:

final class Worker {
    var onDone: (() -> Void)?
    func start() {
        onDone = { [weak self] in
            self?.cleanup()
        }
    }
    func cleanup() { print("cleaning up") }
}

Closures are reference types

This is subtle but important: even though closures look like values, they're reference types:

func makeIncrementer() -> () -> Int {
    var n = 0
    return {
        n += 1
        return n
    }
}

let inc = makeIncrementer()
let alsoInc = inc        // not a copy — same closure
print(inc())             // 1
print(alsoInc())         // 2 — they share the captured n

Two references to the same closure share captured state.

@escaping closures

By default, closure parameters are non-escaping — the function promises to call them (or not) before returning. The compiler enforces this. Closures stored for later use, or run asynchronously, must be marked @escaping:

var pendingHandlers: [() -> Void] = []

func register(_ handler: @escaping () -> Void) {
    pendingHandlers.append(handler)   // stored for later → escaping
}

func runImmediately(_ handler: () -> Void) {
    handler()                          // called and forgotten → non-escaping
}

Why does this matter? Because non-escaping closures don't need to be heap-allocated and don't need to capture self in a special way — the compiler can optimize them. Marking everything @escaping would defeat that.

For escaping closures that capture self, you must reference self explicitly:

final class Service {
    var name = "Service"
    var pending: (() -> Void)?

    func setup() {
        pending = {
            // self.name — required because the closure escapes
            print(self.name)
        }
    }
}

For non-escaping closures, the explicit self requirement is relaxed (because there's no chance of a cycle).

@autoclosure

This is a niche but interesting feature. @autoclosure automatically wraps an expression in a closure, which lets the function defer evaluation:

func logIfNeeded(_ shouldLog: Bool, _ message: @autoclosure () -> String) {
    if shouldLog {
        print(message())   // only evaluated here
    }
}

logIfNeeded(false, expensive())   // expensive() never runs

The standard library uses this for assert(_:_:) and ?? (nil-coalescing) — a ?? b only evaluates b if a is nil. This is an @autoclosure parameter under the hood.

Use sparingly — magic at the call site is a debugging hazard.

Closures are everywhere

A few more places you'll meet them:

let nums = [3, 1, 4, 1, 5, 9, 2, 6]

let max = nums.max()                           // built-in — no closure
let maxByMagnitude = nums.max(by: { abs($0) < abs($1) })

let result = nums.reduce(into: [String: Int]()) { acc, n in
    acc["v\(n)", default: 0] += 1
}

let firstBig = nums.first(where: { $0 > 4 })

// Sort in place
var sortable = nums
sortable.sort { $0 > $1 }   // descending

Internalize closures, especially shorthand arguments — they're the lingua franca of modern Swift APIs.


10. Optionals — Swift's Most Important Feature

If there's one feature that defines Swift, it's optionals. Optionals encode "this value might be missing" directly in the type system. Most languages have null pointers; Swift has nil too, but you can never accidentally use a nil value as if it weren't — the compiler won't let you. This eliminates a whole class of crashes.

Declaring optionals

A type followed by ? means "either a value of that type, or nil":

var name: String? = "Ada"   // String optional
name = nil                  // legal
let count: Int? = nil       // legal

let definitely: String = "Ada"
// definitely = nil   // ❌ Cannot assign nil to non-optional

Under the hood, String? is shorthand for Optional<String>, a generic enum with two cases. We'll see the underlying implementation in the enum chapter, but for now think of it as a box that either contains a String or doesn't.

You can't use an optional like its underlying type

let maybeName: String? = "Ada"
// print(maybeName.count)   // ❌ Value of optional type 'String?' must be unwrapped

This is the whole point. The compiler forces you to acknowledge the possibility of nil before accessing the value.

Unwrapping options

There are several ways to safely extract the value:

1. if let (optional binding)

let maybeName: String? = "Ada"

if let name = maybeName {
    print(name.count)   // name is String, not String?
}

Same shorthand as before:

if let maybeName {
    print(maybeName.count)   // shadowed: now String inside the block
}

2. guard let

func process(_ input: String?) -> Int {
    guard let input else { return 0 }
    return input.count
}

3. Nil-coalescing ??

Provide a default for nil:

let maybeName: String? = nil
let name = maybeName ?? "Anonymous"
print(name)   // "Anonymous"

You can chain:

let result = first ?? second ?? third ?? "default"

4. Optional chaining ?.

Calling a method or accessing a property through ?. returns nil if the receiver is nil, propagating optionality through the expression:

let maybeName: String? = "Ada"
let upper = maybeName?.uppercased()    // String? — "ADA" or nil
let length = maybeName?.count          // Int?

Chained accesses produce a single optional even if you chain many:

struct Address { var city: String }
struct Person { var name: String; var address: Address? }

let p: Person? = Person(name: "Ada", address: Address(city: "London"))
let city = p?.address?.city   // String?

If any link is nil, the whole expression is nil.

5. Forced unwrapping !

You can assert an optional contains a value with !:

let maybeName: String? = "Ada"
let name = maybeName!   // String — but crashes if maybeName is nil

Use this rarely. A ! is a promise to the compiler that you, the programmer, know better. If you're wrong, the program crashes. Reach for ?, ??, if let, or guard let first. Acceptable uses are narrow:

If your code is full of !, treat that as a smell.

6. Implicitly unwrapped optionals T!

let name: String! = "Ada"
print(name.count)   // works without unwrapping
let length: Int = name.count   // implicit unwrap

The type String! says "this is optional, but I'm telling the compiler to auto-unwrap when used in a non-optional context." It will still crash if nil. These exist mainly for interop with Objective-C and for properties that aren't ready at init time but will be by first use. In pure Swift, prefer real optionals.

Optional-chaining with subscripts and method calls

let names: [String]? = ["Ada", "Grace"]
let first = names?[0]                   // String?
let upper = names?[0].uppercased()      // String?

let dict: [String: Int]? = ["a": 1, "b": 2]
let value = dict?["a"]                  // Int? (the [String:Int].subscript already returns Int?, then optional chaining wraps further... actually this is Int??)

That last case is genuine — optional chaining through an already-optional access can produce a nested optional. Use flatMap to collapse one level:

let flat = dict.flatMap { $0["a"] }     // Int?

map and flatMap on optionals

Optional has map and flatMap methods, just like collections do. They're the functional way to transform the value if present, or pass nil through:

let input: String? = "42"

let asInt: Int? = input.map { Int($0) ?? 0 }   // Int? = 42

// flatMap unwraps one level — useful when the transform itself returns an optional
let parsed: Int? = input.flatMap { Int($0) }   // Int? = 42

let invalid: String? = "abc"
let parsed2 = invalid.flatMap { Int($0) }       // Int? = nil

Once you internalize optional-as-container, these are extremely natural.

Optionals and pattern matching

Optionals work in switch:

let maybe: Int? = 5
switch maybe {
case .none:
    print("nothing")
case .some(let value):
    print("got \(value)")
}

Or the shorter form using ?:

switch maybe {
case nil:
    print("nothing")
case let value?:        // bind unwrapped value
    print("got \(value)")
}

You can also use for case:

let values: [Int?] = [1, nil, 3, nil, 5]
for case let value? in values {
    print(value)   // 1, 3, 5 — nils are skipped
}

This is wonderfully concise.

compactMap

Throw away nils from a sequence:

let strings = ["1", "two", "3", "4", "five"]
let numbers = strings.compactMap { Int($0) }   // [1, 3, 4]

compactMap calls a closure that returns optionals and gives you back an array of just the non-nil values, unwrapped.

Why all this matters

Languages without this kind of system end up with NullPointerException, NoneType errors, or undefined-is-not-a-function — all because the type "thing" implicitly includes "or nothing." Swift makes that distinction explicit, and the compiler checks it for you. After a few weeks with optionals, you'll find code in other languages mildly terrifying.


11. Enumerations

Enums in Swift are far more than the named-integer constants you might know from C. They can have associated values, methods, computed properties, conformances, and recursion. They are first-class types, alongside structs and classes.

Basic enum

enum Direction {
    case north
    case south
    case east
    case west
}

let heading: Direction = .north

if heading == .north {
    print("going up")
}

You can list multiple cases on one line if you want:

enum Direction { case north, south, east, west }

When the type is known, you can omit the type name and just write .north. This is called implicit member access and you'll see it constantly.

Enums in switch

switch is exhaustive over enum cases, so the compiler verifies you handle every one:

func describe(_ d: Direction) -> String {
    switch d {
    case .north: return "up"
    case .south: return "down"
    case .east: return "right"
    case .west: return "left"
    }
}

If you later add case northeast, the compiler will flag every switch over Direction that doesn't handle it. This is one of the best refactoring guarantees in any mainstream language.

Raw values

You can give cases a fixed underlying value:

enum HTTPStatus: Int {
    case ok = 200
    case notFound = 404
    case serverError = 500
}

print(HTTPStatus.ok.rawValue)         // 200

if let status = HTTPStatus(rawValue: 404) {
    print(status)   // notFound
}

Int raw values auto-increment from the previous case if you don't specify:

enum Planet: Int {
    case mercury = 1, venus, earth, mars, jupiter, saturn, uranus, neptune
}

print(Planet.earth.rawValue)   // 3

String raw values default to the case name if not specified:

enum Suit: String {
    case hearts, diamonds, spades, clubs
}

print(Suit.hearts.rawValue)   // "hearts"

Constructing an enum from a raw value uses an failable initializer that returns Suit?nil if the value isn't a valid case.

Associated values

This is what makes Swift enums truly powerful. Each case can carry its own data, of any type:

enum Barcode {
    case upc(Int, Int, Int, Int)
    case qrCode(String)
}

let item1 = Barcode.upc(8, 85909, 51226, 3)
let item2 = Barcode.qrCode("ABCDEFG")

switch item1 {
case .upc(let numberSystem, let manufacturer, let product, let check):
    print("UPC: \(numberSystem) \(manufacturer) \(product) \(check)")
case .qrCode(let code):
    print("QR: \(code)")
}

You can shorten the binding with case let:

switch item1 {
case let .upc(ns, m, p, c):
    print("UPC: \(ns) \(m) \(p) \(c)")
case let .qrCode(code):
    print("QR: \(code)")
}

Modeling state with enums

This is where enums shine. Imagine modeling the state of a network request:

enum LoadingState<T> {
    case idle
    case loading
    case success(T)
    case failure(Error)
}

Now:

Compare this to the alternative — a struct with optional fields where any combination is representable, and you have to write defensive code to handle the impossible states. The enum makes invalid states unrepresentable.

This is one of the most important design patterns in modern Swift. Whenever you find yourself writing "if this is true, then that field must be set, but if it's false, this other field must be set" — reach for an enum with associated values.

Methods and computed properties

Enums can have methods and computed properties (but not stored properties — instances of enums don't have a place to store extra data beyond the case):

enum Direction {
    case north, south, east, west

    var opposite: Direction {
        switch self {
        case .north: return .south
        case .south: return .north
        case .east: return .west
        case .west: return .east
        }
    }

    func turnRight() -> Direction {
        switch self {
        case .north: return .east
        case .east: return .south
        case .south: return .west
        case .west: return .north
        }
    }
}

print(Direction.north.opposite)        // south
print(Direction.north.turnRight())     // east

Mutating methods

If you want a method to change which case self is, mark it mutating:

enum LightState {
    case off, on
    mutating func toggle() {
        self = (self == .off) ? .on : .off
    }
}

var light = LightState.off
light.toggle()
print(light)   // on

We'll see mutating again with structs — it's the same mechanism, since both are value types.

CaseIterable

If you want to iterate over all cases, conform to CaseIterable. The compiler synthesizes the allCases collection:

enum Planet: CaseIterable {
    case mercury, venus, earth, mars, jupiter, saturn, uranus, neptune
}

for planet in Planet.allCases {
    print(planet)
}

print(Planet.allCases.count)   // 8

Recursive enums

Enums can have cases that contain themselves — useful for tree-shaped data. You need the indirect keyword to opt into the boxing required:

indirect enum ArithmeticExpression {
    case number(Int)
    case addition(ArithmeticExpression, ArithmeticExpression)
    case multiplication(ArithmeticExpression, ArithmeticExpression)
}

let five = ArithmeticExpression.number(5)
let four = ArithmeticExpression.number(4)
let sum = ArithmeticExpression.addition(five, four)
let product = ArithmeticExpression.multiplication(sum, ArithmeticExpression.number(2))

func evaluate(_ expression: ArithmeticExpression) -> Int {
    switch expression {
    case let .number(value):
        return value
    case let .addition(left, right):
        return evaluate(left) + evaluate(right)
    case let .multiplication(left, right):
        return evaluate(left) * evaluate(right)
    }
}

print(evaluate(product))   // (5 + 4) * 2 = 18

You can also put indirect on a single case if only that one is recursive:

enum Tree<T> {
    case leaf
    indirect case node(T, Tree<T>, Tree<T>)
}

Optionals revisited

Now that you know about enums and associated values, here's the actual definition of Optional from the standard library:

public enum Optional<Wrapped> {
    case none
    case some(Wrapped)
}

That's it. nil is sugar for .none, and String? is sugar for Optional<String>. The forced unwrap !, if let, ?., ?? — all of these are language conveniences over a plain enum. Once you see this, optionals stop feeling magical.


12. Structures and Classes

Swift gives you two ways to define a custom type with fields and methods: struct and class. They look almost identical syntactically, but they behave very differently. Choosing between them is one of the most important decisions in Swift.

Definition syntax

struct Point {
    var x: Double
    var y: Double
}

class Vehicle {
    var wheels: Int = 0
    var name: String = ""
}

Already a difference: structs auto-synthesize a memberwise initializer, classes don't. We'll cover initialization in detail soon.

let p = Point(x: 3, y: 4)         // memberwise init synthesized for free
let v = Vehicle()                  // requires defaults or an explicit init
v.wheels = 4
v.name = "Truck"

The fundamental difference: value vs reference

This is the big one. Structs are value types. Classes are reference types.

Structs are copied on assignment

struct Point { var x: Double; var y: Double }

var a = Point(x: 1, y: 2)
var b = a            // a copy is made
b.x = 99
print(a.x)           // 1   — a is unchanged
print(b.x)           // 99

Classes are shared by reference

class Box { var value = 0 }

let a = Box()
let b = a            // both refer to the same object
b.value = 42
print(a.value)       // 42  — they're aliases

This is the source of countless bugs in languages where everything is a reference type. With structs, you don't get spooky-action-at-a-distance — modifying one variable can't accidentally change another.

Identity vs equality

Because classes are reference types, you can ask whether two references point to the same instance using ===:

class Box { var value = 0 }
let a = Box()
let b = a
let c = Box()

print(a === b)   // true — same instance
print(a === c)   // false — different instances

For structs, identity isn't a concept — there's only the value. == is structural equality (if the type conforms to Equatable), which we'll cover later.

When to use which

A reasonable heuristic from the Swift core team:

This is the inverse of older languages. C# defaults you to classes; Swift wants you to default to structs. After a while, you'll find that needing a class is rarer than you'd expect.

Mutability and mutating methods

Structs are values. To modify them through a method, you must mark the method mutating:

struct Counter {
    var count = 0
    mutating func increment() {
        count += 1
    }
    func get() -> Int {   // not mutating — read-only
        return count
    }
}

var c = Counter()
c.increment()
print(c.get())   // 1

let frozen = Counter()
// frozen.increment()   // ❌ cannot mutate a let constant

With classes, you don't need mutating because the class instance is reached through a reference, not stored in the variable directly:

class CounterClass {
    var count = 0
    func increment() { count += 1 }
}

let c = CounterClass()
c.increment()         // legal even though c is `let`
c.increment()
print(c.count)        // 2

This is one of the surprises with reference types — let only fixes the reference, not the object's contents.

final classes

Mark a class final to prevent subclassing:

final class Logger {
    func log(_ msg: String) { print(msg) }
}

This is good practice for any class you don't intend to be inherited. The compiler can devirtualize calls to final methods, leading to better performance, and it prevents misuse.

static and instance members

struct Temperature {
    var celsius: Double

    static let absoluteZero = -273.15

    static func freezing() -> Temperature {
        Temperature(celsius: 0)
    }

    var fahrenheit: Double {
        celsius * 9 / 5 + 32
    }
}

print(Temperature.absoluteZero)
let t = Temperature.freezing()
print(t.fahrenheit)   // 32

static members belong to the type itself, not an instance. We'll see these constantly in idiomatic Swift — type-level constants and factory methods.

Brief preview: structs can adopt the same techniques as classes

Structs aren't second-class. They can:

They just can't inherit. For polymorphism, Swift wants you to use protocols (chapter 17) and protocol-oriented programming (chapter 19). This is a deliberate design choice that pushes you toward a different decomposition than class hierarchies — and one that often turns out cleaner.


13. Properties

Properties associate values with types. Swift distinguishes between stored and computed properties, supports observers, lazy initialization, and (in modern Swift) property wrappers.

Stored properties

A stored property holds a value:

struct Person {
    var name: String        // mutable stored property
    let birthYear: Int      // immutable (write-once) stored property
}

var p = Person(name: "Ada", birthYear: 1815)
p.name = "Augusta"          // ok
// p.birthYear = 1820       // ❌ Cannot assign to property

let properties of a struct are set once at initialization and then frozen.

Computed properties

A computed property doesn't store a value — it computes one each time:

struct Rectangle {
    var width: Double
    var height: Double

    var area: Double {
        return width * height
    }
}

let r = Rectangle(width: 3, height: 4)
print(r.area)   // 12

The return is optional for single-expression getters. You can also provide a setter:

struct Temperature {
    var celsius: Double

    var fahrenheit: Double {
        get { celsius * 9 / 5 + 32 }
        set { celsius = (newValue - 32) * 5 / 9 }
    }
}

var t = Temperature(celsius: 100)
print(t.fahrenheit)   // 212
t.fahrenheit = 32
print(t.celsius)      // 0

The newValue parameter is implicit; you can give it a name with set(name) if you prefer.

A read-only computed property (getter only) can omit the get block entirely:

var area: Double { width * height }

Property observers

willSet and didSet run whenever the property value changes (other than during initialization):

struct StepCounter {
    var totalSteps: Int = 0 {
        willSet(newSteps) {
            print("about to set total to \(newSteps)")
        }
        didSet {
            if totalSteps > oldValue {
                print("added \(totalSteps - oldValue) steps")
            }
        }
    }
}

var s = StepCounter()
s.totalSteps = 200
// about to set total to 200
// added 200 steps

s.totalSteps = 500
// about to set total to 500
// added 300 steps

oldValue is the implicit name in didSet; newValue in willSet. Observers fire for direct assignments, even self-equal ones (s.totalSteps = s.totalSteps).

Observers don't fire on initial assignment in init, since the property hasn't "changed" yet.

Lazy properties

A lazy property is computed the first time it's accessed and then cached:

struct DataLoader {
    lazy var bigArray: [Int] = {
        print("computing...")
        return (1...1_000_000).map { $0 * 2 }
    }()

    var name = "Loader"
}

var d = DataLoader()
// nothing computed yet
print(d.bigArray.count)   // prints "computing..." once, then 1000000
print(d.bigArray.count)   // doesn't recompute

A few notes:

Type properties

Defined with static:

struct Configuration {
    static let timeout: TimeInterval = 30
    static var retries: Int = 3

    static var defaultURL: URL {
        URL(string: "https://example.com")!
    }
}

print(Configuration.timeout)   // 30
Configuration.retries = 5

For classes, you can also use class instead of static to allow subclasses to override:

class Animal {
    class var sound: String { "generic noise" }
}

class Dog: Animal {
    override class var sound: String { "bark" }
}

print(Dog.sound)   // bark

Computed property with a setter on a class — care needed

If a computed property captures a reference object and the setter modifies it, the same value-vs-reference distinction applies. Most computed property setters update other stored properties on the same instance, so this is rarely an issue, but it's worth being aware of.


14. Methods

Methods are functions associated with a type. They follow the same rules as functions (argument labels, default values, etc.) but with a few additions specific to types.

Instance methods

struct Circle {
    var radius: Double

    func area() -> Double {
        Double.pi * radius * radius
    }

    func circumference() -> Double {
        2 * Double.pi * radius
    }
}

let c = Circle(radius: 5)
print(c.area())          // 78.539...
print(c.circumference()) // 31.415...

Methods can call each other:

struct Circle {
    var radius: Double

    func area() -> Double {
        Double.pi * radius * radius
    }

    func describe() -> String {
        "Circle with radius \(radius), area \(area())"
    }
}

self

Inside a method, self refers to the instance. You only need to write it explicitly when there's ambiguity (e.g., a parameter shadows a property):

struct Person {
    var name: String
    func introduce(name: String) {
        print("\(self.name) meets \(name)")
    }
}

Mutating methods on structs

To modify properties of a struct from inside a method, mark it mutating:

struct Counter {
    var value = 0
    mutating func increment() {
        value += 1
    }
    mutating func reset() {
        self = Counter()      // even reassigning self is allowed
    }
}

var c = Counter()
c.increment()
c.increment()
print(c.value)   // 2
c.reset()
print(c.value)   // 0

A mutating method can be called only on a var, not a let.

Type methods

static for value types and any non-overridable case; class for class-level methods that subclasses can override:

struct Math {
    static func square(_ x: Double) -> Double { x * x }
    static func cube(_ x: Double) -> Double { x * x * x }
}

print(Math.square(4))   // 16
class Vehicle {
    class func category() -> String { "vehicle" }
}

class Car: Vehicle {
    override class func category() -> String { "car" }
}

print(Car.category())   // car

Methods with the same name

Methods can be overloaded by argument labels, parameter types, or return type:

struct Geometry {
    func describe(_ value: Int) -> String { "int: \(value)" }
    func describe(_ value: Double) -> String { "double: \(value)" }
    func describe(coords: (Int, Int)) -> String { "point: \(coords)" }
}

let g = Geometry()
print(g.describe(5))
print(g.describe(5.0))
print(g.describe(coords: (1, 2)))

Use overloading with restraint — readers shouldn't have to squint at types to figure out which method runs.


15. Initialization

Initialization is the process of preparing an instance for use — assigning every stored property an initial value, performing setup. Swift has strict rules here, all aimed at one guarantee: by the end of init, every stored property has a value.

Default initializers

If every stored property has a default value and you don't write any initializers yourself, Swift gives you one for free:

struct ServerConfig {
    var host = "localhost"
    var port = 8080
    var useTLS = true
}

let c = ServerConfig()   // free, parameterless init

Memberwise initializers (structs only)

If a struct has stored properties without defaults, Swift synthesizes a memberwise init:

struct Point {
    var x: Double
    var y: Double
}

let p = Point(x: 3, y: 4)   // memberwise init

You can also pass values for default-having properties to override them:

struct ServerConfig {
    var host = "localhost"
    var port = 8080
}

let c = ServerConfig(host: "api.example.com", port: 443)

This synthesized init is internal by default — it's not visible outside its module unless you write it explicitly. Library authors usually write their own public initializers.

Custom initializers

Write your own when you want validation, computed defaults, or to combine inputs:

struct Person {
    let name: String
    let birthYear: Int

    init(name: String, age: Int, currentYear: Int = 2024) {
        self.name = name
        self.birthYear = currentYear - age
    }
}

let p = Person(name: "Ada", age: 36)
print(p.birthYear)   // 1988

Note: writing a custom init for a struct suppresses the synthesized memberwise init. To keep both, declare your custom init in an extension (we'll see that in chapter 18) — extensions don't suppress synthesized inits.

The two-phase initialization rule

This is the core Swift guarantee:

  1. Phase 1: Every stored property must be assigned by the end of any path through init. The compiler checks this.
  2. Phase 2: Once all properties are initialized, you can call methods, access self, or pass self around.

Practically:

struct Rectangle {
    let width: Double
    let height: Double

    init(width: Double, height: Double) {
        self.width = width
        self.height = height
        // Phase 2 begins here — now I can call methods or use computed properties
        print("Created \(area())")
    }

    func area() -> Double { width * height }
}

If you tried to call area() before assigning both properties, the compiler would reject the code.

Failable initializers

An initializer can fail by returning nil. Mark it init?:

struct PositiveNumber {
    let value: Int

    init?(_ value: Int) {
        guard value > 0 else { return nil }
        self.value = value
    }
}

let a = PositiveNumber(5)    // PositiveNumber? — Optional(.value: 5)
let b = PositiveNumber(-1)   // nil

Constructing a failable type returns an optional. This is exactly what Int("abc") does — returns nil because the string isn't a valid integer.

Throwing initializers

If failure has more information than just "couldn't construct," throw an error instead:

enum ConfigError: Error {
    case missingField(String)
    case invalidValue(field: String, value: String)
}

struct Config {
    let host: String
    let port: Int

    init(json: [String: Any]) throws {
        guard let host = json["host"] as? String else {
            throw ConfigError.missingField("host")
        }
        guard let portValue = json["port"] as? Int, portValue > 0 else {
            throw ConfigError.invalidValue(field: "port", value: "\(json["port"] ?? "nil")")
        }
        self.host = host
        self.port = portValue
    }
}

We'll cover error handling in detail in chapter 21.

Initializer delegation

Initializers can call other initializers of the same type to avoid duplication:

struct Color {
    var red, green, blue: Double

    init(red: Double, green: Double, blue: Double) {
        self.red = red
        self.green = green
        self.blue = blue
    }

    init(white: Double) {
        self.init(red: white, green: white, blue: white)
    }
}

let gray = Color(white: 0.5)

This is convenience delegation for value types. Classes have a more complex story, which we'll cover in the inheritance chapter.

Required initializers (classes)

In a class, required init forces every subclass to provide that initializer. This becomes important when working with protocols and frameworks that need to construct instances by reflection. We'll meet this in chapter 16.

Deinitializers (classes only)

Classes can have a deinitializer that runs when the last reference goes away:

class FileHandle {
    let path: String
    init(path: String) {
        self.path = path
        print("opened \(path)")
    }
    deinit {
        print("closed \(path)")
    }
}

do {
    let f = FileHandle(path: "/tmp/foo")
    // ...
}   // f goes out of scope, deinit runs

Structs don't have deinit — they're copied on assignment, so "the last reference" doesn't make sense.


16. Inheritance

Inheritance is exclusive to classes in Swift. Structs and enums cannot inherit, but they can compose and conform to protocols (which we'll see in chapter 17). Many problems people instinctively reach for inheritance to solve are better solved with protocols in Swift.

That said, when you genuinely need inheritance — say, when bridging to Objective-C frameworks, or modeling a clear is-a hierarchy — here's how it works.

Subclassing

class Vehicle {
    var speed: Double = 0

    func describe() -> String {
        "vehicle going \(speed) mph"
    }

    func makeNoise() {
        // base does nothing
    }
}

class Bicycle: Vehicle {
    var hasBasket = false
}

let b = Bicycle()
b.speed = 12
print(b.describe())   // "vehicle going 12.0 mph"  — inherited
b.hasBasket = true

Bicycle inherits everything from Vehicle. To inherit, write : SuperclassName.

Swift only supports single inheritance — a class can have at most one direct superclass. This is intentional; multiple inheritance creates ambiguity. For multi-source behavior, use protocols.

Overriding

To replace a superclass's method, mark yours with override:

class Train: Vehicle {
    override func makeNoise() {
        print("Choo choo!")
    }

    override func describe() -> String {
        "train going \(speed) mph"
    }
}

override is mandatory — the compiler checks that there's actually something to override. This catches typos that would otherwise silently shadow.

To call the superclass's version from within an override, use super:

class FastTrain: Train {
    override func describe() -> String {
        super.describe() + " (very fast)"
    }
}

Overriding properties

You can override stored properties with computed ones, or override the getter/setter, or just add observers:

class Car: Vehicle {
    var gear = 1

    override var speed: Double {
        didSet {
            gear = Int(speed / 10) + 1
        }
    }
}

You can't override a property to make it less accessible (turn read-write into read-only) or change its type.

Preventing inheritance and overriding

final blocks subclassing or overriding:

class Logger {
    final func log(_ msg: String) { print(msg) }
}

final class StaticLogger {
    static func log(_ msg: String) { print(msg) }
}

class SubLogger: Logger {
    // override func log(_ msg: String) {}   // ❌ cannot override 'final'
}

// class WrongerLogger: StaticLogger {}      // ❌ cannot inherit from final class

Apply final aggressively. It signals intent ("this is a leaf class"), prevents misuse, and gives the compiler more optimization room.

Initialization with inheritance

This is where inheritance gets intricate. Swift's two-phase init rule applies to inheritance with strict ordering:

  1. The subclass init must assign all of its new stored properties.
  2. Then call super.init() to let the superclass initialize its properties.
  3. Then it can use self freely.
class Vehicle {
    var wheels: Int

    init(wheels: Int) {
        self.wheels = wheels
    }
}

class Car: Vehicle {
    var color: String

    init(color: String) {
        self.color = color           // 1. Initialize subclass property
        super.init(wheels: 4)        // 2. Delegate to super
        // 3. self is fully initialized — can do further setup
    }
}

You must call super.init before using self. The compiler enforces this.

Designated and convenience initializers

Classes have two flavors of init:

class Vehicle {
    var wheels: Int
    var name: String

    init(wheels: Int, name: String) {
        self.wheels = wheels
        self.name = name
    }

    convenience init(wheels: Int) {
        self.init(wheels: wheels, name: "Unnamed")
    }

    convenience init() {
        self.init(wheels: 4)
    }
}

let a = Vehicle()                            // chain: convenience → convenience → designated
let b = Vehicle(wheels: 2)                   // chain: convenience → designated
let c = Vehicle(wheels: 4, name: "Truck")    // direct: designated

This rule structure prevents you from creating partially-initialized objects.

required initializers

class BaseBuilder {
    required init() { /* ... */ }
}

class CustomBuilder: BaseBuilder {
    required init() {
        super.init()
        // additional setup
    }
}

required forces every subclass to also provide the initializer. This is needed when a protocol requires it, or for runtime construction by reflection.

Avoid deep hierarchies

A well-designed Swift codebase rarely has deep class hierarchies. If you find yourself drawing a UML diagram with five levels of inheritance, consider:

In Swift, classes and inheritance are tools you reach for for specific problems, not the default decomposition.


17. Protocols

Protocols are the heart of Swift's type system. A protocol defines a contract — a set of requirements (methods, properties, initializers) — and any type can adopt that protocol by satisfying the requirements. Crucially, any type means structs, classes, enums, and even protocol-based types themselves.

Defining a protocol

protocol Greetable {
    var name: String { get }
    func greet() -> String
}

The requirements:

Note that we didn't write any implementation. That's the whole point — protocols specify what, not how.

Conforming to a protocol

struct Person: Greetable {
    let name: String
    func greet() -> String {
        "Hello, my name is \(name)"
    }
}

struct Robot: Greetable {
    let name: String
    func greet() -> String {
        "BEEP. I am \(name)."
    }
}

let entities: [Greetable] = [Person(name: "Ada"), Robot(name: "R2")]
for e in entities {
    print(e.greet())
}

Both types conform by providing what the protocol asks for. We then use them through the protocol type — heterogeneous storage, common interface.

Property requirements

Protocols can require:

protocol Counter {
    var count: Int { get }              // read-only
    var label: String { get set }       // read-write
    static var maxCount: Int { get }    // type-level
}

A { get } requirement can be satisfied by any kind of property (let, var, computed get-only, computed get-set). A { get set } requirement requires read-write — it can't be satisfied by let or by a computed property without a setter.

Method requirements

Method requirements look like function declarations without a body:

protocol Shape {
    func area() -> Double
    func perimeter() -> Double
    mutating func scale(by factor: Double)
}

Note mutating is part of the requirement. Classes ignore it; structs and enums need it if their implementation modifies self.

Initializer requirements

Protocols can require initializers. Conforming classes must mark them required so subclasses also have them:

protocol JSONInit {
    init(json: [String: Any]) throws
}

final class User: JSONInit {
    let name: String
    required init(json: [String: Any]) throws {
        guard let name = json["name"] as? String else {
            throw NSError(domain: "x", code: 0)
        }
        self.name = name
    }
}

For final classes, required is implicit (since there are no subclasses).

Protocol composition

Combine multiple protocol requirements with &:

protocol Named { var name: String { get } }
protocol Aged { var age: Int { get } }

func describe(_ p: Named & Aged) {
    print("\(p.name), \(p.age)")
}

struct Person: Named, Aged {
    var name: String
    var age: Int
}

describe(Person(name: "Ada", age: 36))

Use typealias to name common combinations:

typealias NamedAndAged = Named & Aged

Self in protocols

Self (capital S) inside a protocol refers to the conforming type:

protocol Copyable {
    func copy() -> Self
}

struct Document: Copyable {
    var content: String
    func copy() -> Document {       // returns Self == Document
        Document(content: content)
    }
}

Self lets you write protocols where each implementation can use its own type as a return type (or parameter type). This is more powerful than returning the protocol itself, because callers know the concrete type at compile time.

Associated types

Protocols can have associated types — type placeholders that conforming types fill in:

protocol Container {
    associatedtype Item
    var count: Int { get }
    mutating func add(_ item: Item)
    subscript(i: Int) -> Item { get }
}

struct IntBox: Container {
    typealias Item = Int       // explicit; often inferred
    private var items: [Int] = []
    var count: Int { items.count }
    mutating func add(_ item: Int) { items.append(item) }
    subscript(i: Int) -> Int { items[i] }
}

Associated types are how Swift's standard library defines Sequence, Collection, and Iterator — generically, with the element type as a placeholder.

struct Stack<T>: Container {
    private var elements: [T] = []
    var count: Int { elements.count }
    mutating func add(_ item: T) { elements.append(item) }
    subscript(i: Int) -> T { elements[i] }
}

The compiler infers Item == T from the use sites.

Constraints on associated types

You can constrain an associated type to conform to other protocols:

protocol Container {
    associatedtype Item: Equatable
    var count: Int { get }
    func contains(_ item: Item) -> Bool
}

Now anything used for Item must be Equatable.

You can also add where clauses for richer constraints:

protocol PathFinder {
    associatedtype Node
    associatedtype Path: Sequence where Path.Element == Node
    func findPath(from: Node, to: Node) -> Path?
}

This says: Path is a sequence whose element type is Node. The relationship is enforced at compile time.

Equatable, Hashable, Comparable

These standard-library protocols are everywhere:

struct Point: Equatable, Hashable {
    let x: Int
    let y: Int
}

// Synthesized for free — no implementation needed!
let a = Point(x: 1, y: 2)
let b = Point(x: 1, y: 2)
print(a == b)        // true

let set: Set<Point> = [a, b, Point(x: 3, y: 4)]
print(set.count)     // 2 (a and b are duplicates)

If all stored properties are Equatable/Hashable, the compiler synthesizes the conformance automatically. Same for enums (including those with associated values, as long as the associated values themselves conform).

Comparable requires implementing <:

struct Version: Comparable {
    let major, minor, patch: Int

    static func < (lhs: Version, rhs: Version) -> Bool {
        if lhs.major != rhs.major { return lhs.major < rhs.major }
        if lhs.minor != rhs.minor { return lhs.minor < rhs.minor }
        return lhs.patch < rhs.patch
    }
    // == is auto-synthesized via Equatable
}

CustomStringConvertible

Override how an instance prints:

struct Point: CustomStringConvertible {
    var x, y: Double
    var description: String { "(\(x), \(y))" }
}

print(Point(x: 1, y: 2))   // (1.0, 2.0)

String(describing:) and print use this protocol when present.


18. Extensions

Extensions let you add functionality to a type — your own, the standard library's, or even a third-party type — without modifying its source. They're one of the cleanest tools in Swift for organizing code.

What you can add via an extension

What you cannot add: stored properties (no place to put them), or override existing implementations.

Adding methods to an existing type

extension Int {
    func squared() -> Int { self * self }
    var isEven: Bool { self % 2 == 0 }
}

print(5.squared())   // 25
print(4.isEven)      // true

This works on stdlib types, your types, and types from any imported module.

Adding methods to your own type

For organization, splitting a type's definition across multiple extensions is idiomatic:

struct Stack<T> {
    private var elements: [T] = []
}

extension Stack {
    mutating func push(_ x: T) { elements.append(x) }
    mutating func pop() -> T? { elements.popLast() }
    var top: T? { elements.last }
    var count: Int { elements.count }
}

extension Stack: CustomStringConvertible {
    var description: String { "Stack(\(elements))" }
}

This separation is purely cosmetic — the compiler treats Stack as one type — but it's lovely for readability. Many Swift codebases keep the basic struct definition tiny and put all behavior in extensions, often grouping by purpose ("MARK: - Equality", "MARK: - Persistence", etc.).

Conforming via extensions

Even a third-party or stdlib type can be made to conform to one of your protocols:

protocol Describable {
    func describe() -> String
}

extension Int: Describable {
    func describe() -> String { "the number \(self)" }
}

extension String: Describable {
    func describe() -> String { "the string \"\(self)\"" }
}

let things: [Describable] = [42, "hello", 3.14 as? Describable].compactMap { $0 }

A common idiom is to put the protocol conformance and its implementation in a single extension, dedicated to that conformance.

Conditional conformance

You can make a generic type conform to a protocol only when its type parameter satisfies certain conditions:

struct Pair<T> {
    let first: T
    let second: T
}

extension Pair: Equatable where T: Equatable {
    static func == (a: Pair<T>, b: Pair<T>) -> Bool {
        a.first == b.first && a.second == b.second
    }
}

print(Pair(first: 1, second: 2) == Pair(first: 1, second: 2))   // true

Array does this in the standard library: [T] is Equatable if T: Equatable, Hashable if T: Hashable, etc. It's how Swift collections "just work" with == when they should and don't compile when they can't.

Extension constraints with where

You can also add methods that only exist when the generic parameter satisfies some constraint:

extension Array where Element: Numeric {
    func sum() -> Element {
        reduce(0, +)
    }
}

print([1, 2, 3, 4].sum())          // 10
print([1.5, 2.5, 3.5].sum())       // 7.5
// print(["a", "b"].sum())          // ❌ — String isn't Numeric

The standard library is full of these. Array.joined() only exists when Element is a sequence (so you can flatten arrays of arrays). Sequence.sorted() only exists when Element is Comparable.

Safe array indexing — a classic extension

Here's an extension you'll see in many codebases:

extension Array {
    subscript(safe index: Int) -> Element? {
        indices.contains(index) ? self[index] : nil
    }
}

let nums = [10, 20, 30]
print(nums[safe: 1])   // Optional(20)
print(nums[safe: 99])  // nil

Built-in subscript crashes on out-of-bounds; this gives you an opt-in safe version.

Adding initializers via extension

Because extensions can't reduce existing API, you can add new initializers without losing the synthesized memberwise init for structs:

struct Color {
    var r, g, b: Double
}

extension Color {
    init(gray: Double) {
        self.init(r: gray, g: gray, b: gray)
    }
}

let red = Color(r: 1, g: 0, b: 0)         // synthesized init still available
let mid = Color(gray: 0.5)

Without this trick, writing init(gray:) directly inside the struct would suppress the memberwise init. Extensions preserve it.


19. Protocol-Oriented Programming

Swift coined the phrase "protocol-oriented programming" in 2015. It's the idiom that contrasts most starkly with object-oriented inheritance, and once you've grokked it you start seeing class hierarchies you used to write as the awkward kludges they were.

The core idea: define abstractions with protocols, give them default implementations via protocol extensions, compose behavior by conforming. Inheritance is one tool, not the default.

Protocol extensions

You can add methods and computed properties to a protocol — any conforming type gets them for free:

protocol Animal {
    var species: String { get }
    var legCount: Int { get }
}

extension Animal {
    func describe() -> String {
        "A \(species) with \(legCount) legs"
    }
    var isBiped: Bool { legCount == 2 }
}

struct Dog: Animal {
    let species = "dog"
    let legCount = 4
}

struct Human: Animal {
    let species = "human"
    let legCount = 2
}

print(Dog().describe())     // A dog with 4 legs
print(Human().isBiped)      // true

Dog and Human only had to provide species and legCount. They got describe() and isBiped for free.

Default implementations

A conforming type can override the default by implementing the same method itself:

struct Snake: Animal {
    let species = "snake"
    let legCount = 0

    func describe() -> String {
        "A slithery snake"
    }
}
print(Snake().describe())   // A slithery snake

This is similar to inheriting and overriding, but without an inheritance chain — Snake doesn't inherit from Animal, it conforms to it. There are no abstract base classes here; just a contract and reusable defaults.

Adding requirements vs adding extensions

A subtle but crucial distinction:

protocol Greeter {
    func greet() -> String
}

extension Greeter {
    func greet() -> String { "Hello (default)" }
    func wave() -> String { "Wave (extension only)" }
}

struct Friendly: Greeter {
    func greet() -> String { "Hi!" }
    func wave() -> String { "*waves enthusiastically*" }
}

let g: Greeter = Friendly()
print(g.greet())   // "Hi!"           — declared in protocol, dynamic
print(g.wave())    // "Wave (extension only)"  — extension only, STATIC dispatch

This often surprises people: even though Friendly overrides wave(), calling it through the protocol type uses the extension's version. To get dynamic dispatch, the method must be a protocol requirement.

Rule of thumb: if a method should be overridable by conforming types, declare it in the protocol (as a requirement). Use extensions for derived behavior that's expressible in terms of the requirements.

Constrained protocol extensions

Default implementations can be conditional, just like type-level conformances:

protocol Container {
    associatedtype Item
    var items: [Item] { get }
}

extension Container where Item: Numeric {
    func sum() -> Item {
        items.reduce(0, +)
    }
}

extension Container where Item: Equatable {
    func contains(_ item: Item) -> Bool {
        items.contains(item)
    }
}

sum() only exists when Item is Numeric. contains only when Item is Equatable.

Composition over inheritance

A real example: imagine modeling something with multiple optional capabilities.

The class-hierarchy approach forces decisions about inheritance — should LoggablePersistableSyncable inherit from LoggablePersistable? It gets messy.

The protocol approach:

protocol Loggable {
    func log()
}

protocol Persistable {
    func save()
}

protocol Syncable {
    func sync() async
}

extension Loggable {
    func log() { print(self) }
}

Now any type can pick and choose:

struct UserPreferences: Loggable, Persistable {
    let theme: String
    func save() { /* write to disk */ }
}

struct AnalyticsEvent: Loggable, Syncable {
    let name: String
    func sync() async { /* upload */ }
}

Each type has exactly the capabilities it needs. There's no awkward base class with optional features.

Type erasure (brief)

Sometimes you want to store heterogeneous protocol values that have an associated type. Plain [Container] is a problem — Container has an associated type Item, so [Container] is ambiguous. The classical solution is type erasure, often via an AnyContainer<T> wrapper that hides the original conforming type.

In modern Swift (5.7+) you can often use the any keyword (chapter 27) instead — [any Container] works in many cases. But for older code or when you need to flatten the existential, type-erased wrappers are still common. The standard library's AnySequence, AnyHashable, AnyCollection are examples.


20. Generics

Generics let you write code that works with multiple types while preserving type safety. They underpin most of the standard library — Array<Element>, Optional<Wrapped>, Dictionary<Key, Value> are all generic.

Generic functions

func swapValues<T>(_ a: inout T, _ b: inout T) {
    let temp = a
    a = b
    b = temp
}

var x = 1, y = 2
swapValues(&x, &y)

var s1 = "hello", s2 = "world"
swapValues(&s1, &s2)

<T> introduces a type parameter. Any type can be substituted — Int, String, your own type — but within a single call, all Ts must be the same.

Generic types

struct Stack<T> {
    private var elements: [T] = []
    mutating func push(_ x: T) { elements.append(x) }
    mutating func pop() -> T? { elements.popLast() }
    var top: T? { elements.last }
    var count: Int { elements.count }
}

var ints = Stack<Int>()
ints.push(1); ints.push(2)
print(ints.pop() ?? -1)   // 2

var strings = Stack<String>()
strings.push("a"); strings.push("b")

The <T> after the type name says "this type is parameterized by some type T."

Multiple type parameters

struct Pair<First, Second> {
    let first: First
    let second: Second
}

let p1 = Pair(first: "name", second: 42)
let p2 = Pair<String, Int>(first: "explicit", second: 7)

Generic constraints

Often you need to require that a generic type conforms to a protocol:

func max<T: Comparable>(_ a: T, _ b: T) -> T {
    a > b ? a : b
}

print(max(3, 5))           // 5
print(max("apple", "pear")) // "pear"

<T: Comparable> says "T must conform to Comparable." Without that constraint, > wouldn't be available inside the function.

Multiple constraints

func sortAndDescribe<T: Comparable & CustomStringConvertible>(_ items: [T]) -> String {
    items.sorted().map(\.description).joined(separator: ", ")
}

The \.description is a key path — we'll cover those in chapter 28.

where clauses

For more complex constraints:

func zip<S1: Sequence, S2: Sequence>(
    _ a: S1, _ b: S2
) -> [(S1.Element, S2.Element)]
where S1.Element: Equatable, S1.Element == S2.Element
{
    var result: [(S1.Element, S2.Element)] = []
    var it1 = a.makeIterator()
    var it2 = b.makeIterator()
    while let x = it1.next(), let y = it2.next() {
        result.append((x, y))
    }
    return result
}

where clauses can express:

Generic protocols

Protocols don't take <> parameters; they use associatedtype (chapter 17). The two mechanisms achieve similar ends but via different mechanisms.

Quick contrast:

// Generic struct — caller picks T
struct Box<T> { var value: T }
let intBox = Box<Int>(value: 5)

// Protocol with associatedtype — conforming type picks Item
protocol Container { associatedtype Item; func get() -> Item }
struct IntContainer: Container {
    func get() -> Int { 5 }
}

When you want to write a function that takes "any container with Int items," you can do:

func describe<C: Container>(_ c: C) where C.Item == Int {
    print(c.get())
}

Variance is not directly expressible

This is a sharp corner. In Swift, Array<Animal> is not a supertype of Array<Dog>, even if Dog: Animal:

class Animal {}
class Dog: Animal {}

let dogs: [Dog] = [Dog(), Dog()]
// let animals: [Animal] = dogs   // ❌ — invariant
let animals: [Animal] = dogs.map { $0 as Animal }   // explicit conversion

This is intentional. If [Animal] were a supertype of [Dog], you could write code that put a Cat (also an Animal) into a [Dog], which would be a type error in disguise. Some languages allow this with runtime checks; Swift forbids it at compile time.

Generic specialization

This is a performance note rather than a feature you write. The Swift compiler specializes generic code at use sites, generating optimized concrete versions. So a generic Array<Int>.sort() compiles to roughly the same code as a hand-rolled IntArray.sort(). Generics don't have the runtime cost of, say, Java's type erasure.

When the compiler can't specialize (typically across module boundaries without @inlinable), it falls back to existential boxing — slower but correct. Modern Swift's @_specialize attribute and @inlinable keyword let library authors hint specialization across modules, but you don't usually need to think about this.


21. Error Handling

Swift errors are values, not exceptions in the traditional sense. They don't unwind the stack with hidden control flow — every function that can throw is marked throws, every call that catches a throw is marked try, and the compiler enforces this. This makes errors visible at every boundary.

Defining error types

Conform to the Error protocol — which has no requirements, it's just a marker:

enum FileError: Error {
    case notFound(path: String)
    case noPermission
    case corrupted(reason: String)
}

Enums are usually the right shape for errors — finite cases, often with associated data.

Throwing errors

Mark a function throws if it can throw:

func loadConfig(path: String) throws -> String {
    if path.isEmpty {
        throw FileError.notFound(path: path)
    }
    if path.hasPrefix("/private") {
        throw FileError.noPermission
    }
    return "config contents"
}

The function returns String on success, or throws an error on failure.

Calling throwing functions

You have several options:

do/catch

do {
    let config = try loadConfig(path: "/etc/app.conf")
    print(config)
} catch FileError.notFound(let path) {
    print("Couldn't find \(path)")
} catch FileError.noPermission {
    print("Permission denied")
} catch {
    print("Unknown error: \(error)")
}

The try annotation marks each potentially-throwing call. The compiler will reject loadConfig(...) without try inside a do/catch. Inside the catch clauses, you can pattern-match like in switch. The bare catch at the end catches anything not matched above; inside it, the variable error is implicit.

try? — convert to optional

let config = try? loadConfig(path: "/etc/app.conf")
// config: String?  — nil if anything was thrown

try? discards the error, leaving you with an optional. Useful when you don't care why it failed.

try! — assert no error

let config = try! loadConfig(path: "/known/good/path")

If the function throws, try! crashes the program. Use only when you can prove failure is impossible — e.g., a bundled resource with a known path. Same caveats as ! for optionals: be sparing.

Propagating errors

If your function is also throws, you can let the error propagate by writing try without surrounding do/catch:

func startup() throws {
    let config = try loadConfig(path: "/etc/app.conf")
    print("Loaded \(config)")
}

The error flies up to the caller of startup(). This is the cleanest pattern when you don't have anything specific to do at this layer.

Multiple catch patterns

Patterns in catch use the same matching as switch:

do {
    try doSomething()
} catch FileError.notFound, FileError.noPermission {
    print("file problem")
} catch let FileError.corrupted(reason) {
    print("corrupted: \(reason)")
} catch {
    throw error    // re-throw anything else (this catch's `error` is implicit)
}

Rethrowing

rethrows says "this function throws only if its closure argument throws":

func map<T, U>(_ items: [T], transform: (T) throws -> U) rethrows -> [U] {
    var result: [U] = []
    for item in items {
        result.append(try transform(item))
    }
    return result
}

let safe = map([1, 2, 3]) { $0 * 2 }     // no try needed — closure doesn't throw

let unsafe = try map([1, 2, 3]) { x throws -> Int in
    if x == 2 { throw FileError.noPermission }
    return x * 2
}

The standard library uses this everywhere — Sequence.map, Array.filter, etc., are all rethrows. They throw if and only if the closure you pass throws.

Typed throws (Swift 6.0+)

Swift 6 introduces typed throws: declaring exactly which error type a function throws. This is a recent and not-yet-ubiquitous feature, but it's powerful:

func parse(_ s: String) throws(FileError) -> Int {
    guard let n = Int(s) else {
        throw FileError.corrupted(reason: "not a number")
    }
    return n
}

do {
    let n = try parse("abc")
} catch {
    // error is known to be FileError, no need to cast
    switch error {
    case .notFound, .noPermission, .corrupted:
        print("handle it")
    }
}

Without typed throws, errors are erased to any Error. With typed throws, the compiler can track the specific type, eliminating downcasts.

For most code, untyped throws is the right default — it's flexible and composes well. Typed throws are most useful for tightly-scoped APIs where you really know what can go wrong.

defer for cleanup

We saw defer in chapter 7. It pairs beautifully with throwing code:

func processFile(path: String) throws {
    let handle = openFile(path)
    defer { closeFile(handle) }    // runs whether we return normally or throw

    try parse(handle)              // if this throws, we still close
    try validate(handle)
}

Resource cleanup that's guaranteed regardless of how the scope exits.

Result type

The standard library has a Result<Success, Failure: Error> enum that represents either success or failure as a value. It's useful for stashing async errors, building custom error pipelines, or interoperating with callback-based code:

let result: Result<Int, FileError> = .success(42)

switch result {
case .success(let n): print("got \(n)")
case .failure(let e): print("error: \(e)")
}

// Convert to throws
let value = try result.get()

// Convert from throws
let other = Result { try parse("42") }

Most modern Swift uses throws directly, but Result is still common in Combine pipelines and bridging older callback APIs. With async/await (chapter 30), throws is usually cleaner.

Don't fight the type system

The number-one mistake people make with Swift errors is reaching for try! to silence the compiler. The compiler is asking you a question: "what should happen if this fails?" Answer it — even if the answer is "log it and recover with a default":

let config = (try? loadConfig(path: "/etc/app.conf")) ?? "default"

That's a good answer. try! is rarely the right one in production code.


22. Type Casting

Sometimes you need to ask "is this value of some specific type?" or "can I treat it as that type?" Swift gives you three operators for this: is, as, and as?/as!.

is — type check

class Animal {}
class Dog: Animal {}
class Cat: Animal {}

let pet: Animal = Dog()

if pet is Dog {
    print("it's a dog")
}

is returns a Bool. It works with classes, protocols, structs, and enums.

as — guaranteed cast

When the compiler can prove the cast is always safe, use as:

let n = 5
let d = n as Int        // identity cast — useless but legal
let m = 5 as Double     // upcast through literal — legal
let dog = Dog()
let animal = dog as Animal      // upcast — always safe, can be done with `as`

Upcasts (to a parent class or to a protocol the type conforms to) always succeed and are written with as.

as? — conditional cast

For downcasts (toward a subtype), the result is optional — nil if the cast fails:

let pet: Animal = Dog()

if let dog = pet as? Dog {
    print("it's a dog: \(dog)")
}

if let cat = pet as? Cat {
    print("it's a cat")
} else {
    print("not a cat")
}

This is the safe form — use it whenever you're not certain.

as! — forced cast

Same as as? but crashes if the cast fails:

let dog = pet as! Dog   // crashes if pet isn't a Dog

Same caveats as try! and !. Use sparingly. The most legitimate use is bridging code where the type is enforced by an external system.

Casting in switch

Pattern matching with case let lets you test type and bind in one move:

let things: [Any] = [1, "hi", 3.14, Dog()]

for thing in things {
    switch thing {
    case let n as Int:
        print("int \(n)")
    case let s as String:
        print("string \(s)")
    case let d as Double:
        print("double \(d)")
    case is Dog:
        print("a dog")
    default:
        print("something else")
    }
}

This is clean and frequently the right tool.

Any and AnyObject

var things: [Any] = []
things.append(1)
things.append("hello")
things.append(Dog())
things.append(3.14)

Use sparingly. Reaching for Any usually means you've lost type information you'd rather have. But it's there when you genuinely need heterogeneous collections (often when bridging to legacy APIs).

AnyObject is mostly useful for protocols that should only be class-bound (chapter 24's discussion of memory management touches on this).

When you don't need casting

If you find yourself casting frequently, your design might have an opportunity. Often a switch-on-enum or a polymorphic protocol method gives you what you want without runtime type tests. Swift's type system rewards modeling alternatives explicitly with enums or protocols.


23. Access Control

Swift has five access levels, controlling visibility of types, properties, methods, and other declarations. Default is internal, which is fine 90% of the time.

The five levels

From most to least restrictive:

Level Visible from
private The enclosing declaration (and extensions in the same file)
fileprivate The enclosing source file
internal The same module
package The same Swift package (Swift 5.9+)
public Any module that imports this one
open Same as public, plus subclassable/overridable from other modules

Examples

public struct PublicAPI {
    public init() {}
    public var visible = 0          // visible to any importer
    internal var moduleOnly = 0     // visible inside same module (this is the default)
    fileprivate var fileOnly = 0    // visible in this source file
    private var hidden = 0          // visible in this declaration only

    public func compute() -> Int {
        hidden + moduleOnly         // private members visible inside the type
    }
}

If you don't write a level, it's internal. For application code (single-module), this means everything is visible everywhere within your project, and you only need explicit levels when building libraries or when you want to enforce encapsulation within your app.

private(set)

A frequent pattern: a property that's read-public but write-private:

public class Counter {
    public private(set) var count = 0    // read public, write private
    public func increment() { count += 1 }
}

Outside the class, counter.count is readable but counter.count = 5 is rejected. Inside, both are allowed.

private vs fileprivate

private is restricted to the declaring type (and that type's extensions in the same file). fileprivate is restricted to the entire source file.

If you have a type plus a few free helpers in the same file, fileprivate lets them all see each other without exposing internals to the rest of the module. In modern Swift, private is preferred most of the time; reach for fileprivate only when you genuinely need cross-type access within a file.

open vs public

Both make a class visible from outside the module. The difference: a public class cannot be subclassed outside the declaring module. An open class can.

// In LibraryModule:
public class Logger {}        // can be used outside but not subclassed
open class Plugin {}          // can be used and subclassed outside

// In ClientModule:
class CustomLogger: Logger {} // ❌ Logger is public, not open — can't subclass
class CustomPlugin: Plugin {} // ✅ Plugin is open

This forces library authors to opt into supporting subclassing. Subclassing is a strong commitment (you're promising stable inheritable behavior across versions), and open is how you signal that promise.

package access (Swift 5.9+)

package lets symbols be visible across all modules within a single Swift package without exposing them publicly:

package func internalToolUtility() {}

This is invaluable for Swift packages with multiple targets — internal helpers can be shared among targets without becoming part of the public API.

Access control for protocols

A protocol's access level limits the maximum access level of its requirements:

public protocol Greeter {
    func greet() -> String   // implicitly public
}

Conforming types must implement public requirements with at least the same access level.


24. Memory Management and ARC

Swift uses Automatic Reference Counting (ARC) for class instances. Every class instance has a count of strong references to it; when that count drops to zero, the instance is deallocated. Value types (structs, enums) aren't reference-counted — they're copied or moved as needed and live on the stack or inline in their containing object.

You don't manually retain or release objects in Swift. Most of the time ARC just works. The places it bites you are strong reference cycles, where two objects hold strong references to each other and neither's count ever reaches zero.

Strong references (the default)

class Person {
    var name: String
    var apartment: Apartment?
    init(name: String) {
        self.name = name
        print("\(name) created")
    }
    deinit { print("\(name) deinitialized") }
}

class Apartment {
    var unit: String
    var tenant: Person?
    init(unit: String) {
        self.unit = unit
        print("Apt \(unit) created")
    }
    deinit { print("Apt \(unit) deinitialized") }
}

var p: Person? = Person(name: "Ada")
var a: Apartment? = Apartment(unit: "4A")

p?.apartment = a
a?.tenant = p

p = nil
a = nil

// nothing is deinitialized — they reference each other in a cycle!

Person strongly references Apartment, which strongly references Person. Setting both outer variables to nil drops the local references, but the cycle keeps both alive. Memory leak.

Weak references

Mark one side of the cycle as weak:

class Apartment {
    var unit: String
    weak var tenant: Person?    // weak — doesn't keep Person alive
    init(unit: String) { self.unit = unit }
    deinit { print("Apt \(unit) deinitialized") }
}

Now setting p = nil deallocates Person, which automatically nilifies the weak reference. weak references are always optional and always var (since they can change to nil at any time).

Rule of thumb: when you have a parent-child relationship, the parent holds a strong reference to the child, and the child holds a weak reference back to the parent.

Unowned references

unowned is similar to weak but assumes the referenced object will outlive the reference. It's not optional, and accessing it after deallocation is a crash:

class Customer {
    var name: String
    var card: CreditCard?
    init(name: String) { self.name = name }
    deinit { print("\(name) deinitialized") }
}

class CreditCard {
    let number: UInt64
    unowned let customer: Customer    // unowned — can never be nil
    init(number: UInt64, customer: Customer) {
        self.number = number
        self.customer = customer
    }
    deinit { print("Card \(number) deinitialized") }
}

A credit card always belongs to a customer; the customer outlives the card. unowned is right.

Use weak when the referenced object's lifetime might end first, unowned when you're certain it won't.

Cycles in closures

This is the trap that gets everyone. Closures capture self strongly by default, so any closure that references self and is stored on self creates a cycle:

final class Network {
    var name = "Net"
    var onComplete: (() -> Void)?

    func start() {
        onComplete = {
            print(self.name)   // captures self strongly
        }
    }
}

Network strongly references onComplete, which strongly references self (the Network), which references onComplete... cycle. Fix it with a capture list:

func start() {
    onComplete = { [weak self] in
        guard let self else { return }
        print(self.name)
    }
}

Or [unowned self] if you're certain self outlives the closure.

The guard let self else { return } shorthand (Swift 5.7+) is a clean way to convert the optional self back into a non-optional inside the closure.

When you don't need a capture list

Non-escaping closures (e.g., the closure passed to map or forEach) don't store self, so they can't cause a cycle. You don't need [weak self] for them. Reach for capture lists for escaping closures stored on self or that outlive the function call.

Value types don't cycle

Two structs can't form a reference cycle because they don't reference each other — copies are made. This is one reason to prefer structs by default: ARC pitfalls don't apply.

If you need cycle-prone shapes — a tree where children reference parents — and you're using structs, you'll run into "structs can't be recursive without indirect" rules. That usually means classes are the right tool, with weak parent links.

withExtendedLifetime and weak self gotchas

A pattern you'll occasionally see:

extension Network {
    func send() {
        let request = makeRequest()
        URLSession.shared.dataTask(with: request) { [weak self] data, _, _ in
            guard let self else { return }
            self.handle(data)
        }.resume()
    }
}

The [weak self] ensures that a long-running network call doesn't extend self's lifetime. If the user navigates away, self deallocates, and the closure does nothing.

What to internalize


25. Property Wrappers

A property wrapper encapsulates the access logic for a property — validation, transformation, lazy storage, observation, you name it — and lets you apply it declaratively with an @-attribute. Once you write the wrapper, you can reuse it across many properties without copy-pasting the boilerplate.

A first wrapper

Suppose you want a property whose value is always clamped to a range. Without a wrapper:

struct Settings {
    private var _volume: Double = 0
    var volume: Double {
        get { _volume }
        set { _volume = min(max(newValue, 0), 100) }
    }
}

Tedious — and you'd have to rewrite this for every clamped property. With a wrapper:

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

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

    var wrappedValue: Value {
        get { value }
        set { value = min(max(newValue, range.lowerBound), range.upperBound) }
    }
}

struct Settings {
    @Clamped(0...100) var volume: Double = 0
    @Clamped(-1...1)  var pan: Double = 0
}

var s = Settings()
s.volume = 150
print(s.volume)   // 100
s.pan = 2.0
print(s.pan)      // 1.0

The @Clamped(0...100) annotation means "store this property using the Clamped wrapper, with this range." All read/write goes through wrappedValue's getter and setter.

Anatomy of a wrapper

A property wrapper is a type with:

Projected values

If a wrapper exposes a projectedValue, you access it with $propertyName:

@propertyWrapper
struct Logged<T> {
    private var value: T
    private var history: [T] = []

    init(wrappedValue: T) {
        value = wrappedValue
    }

    var wrappedValue: T {
        get { value }
        set {
            history.append(value)
            value = newValue
        }
    }

    var projectedValue: [T] {
        history
    }
}

struct Tracker {
    @Logged var counter: Int = 0
}

var t = Tracker()
t.counter = 5
t.counter = 10
t.counter = 7
print(t.counter)    // 7         — wrappedValue
print(t.$counter)   // [0, 5, 10] — projectedValue

$counter returns the array of historical values. Projected values are how SwiftUI's @State exposes a Binding ($state returns a Binding<T>) — but again, we're staying language-only here.

Wrapper with multiple init parameters

@propertyWrapper
struct Trimmed {
    private var value: String

    init(wrappedValue: String) {
        self.value = wrappedValue.trimmingCharacters(in: .whitespacesAndNewlines)
    }

    var wrappedValue: String {
        get { value }
        set { value = newValue.trimmingCharacters(in: .whitespacesAndNewlines) }
    }
}

struct Form {
    @Trimmed var name: String = "  Ada  "
}

let f = Form()
print("[\(f.name)]")   // [Ada]

Composing wrappers

You can stack wrappers, with the outer wrapper's wrappedValue being the inner wrapper:

struct DataModel {
    @Clamped(0...100) @Logged var score: Int = 50
}

This requires the wrappers to chain correctly — read the docs of each wrapper to understand composition.

When (and when not) to use property wrappers

Use them when:

Don't use them when:

The standard library uses property wrappers for things like @TaskLocal (per-task storage). SwiftUI uses them heavily (@State, @Binding, @Environment, etc.). Use them where they pay off; resist the urge to wrap everything.


26. Result Builders

Result builders let you construct a value by writing a sequence of expressions, with the compiler combining them according to rules you specify. They're how SwiftUI's view-building syntax works, but you can use them for any DSL — HTML builders, CLI argument parsers, algebraic expressions, anything where "a sequence of statements" should fold into a single value.

Motivation

Imagine an HTML builder. Without result builders:

let page = html(children: [
    head(children: [
        title(children: [text("Hello")])
    ]),
    body(children: [
        h1(children: [text("Welcome")]),
        p(children: [text("Hi there.")])
    ])
])

Workable, but commas and brackets dominate. With result builders, you can write:

let page = HTML {
    Head {
        Title("Hello")
    }
    Body {
        H1("Welcome")
        P("Hi there.")
    }
}

That's the goal.

A minimal result builder

@resultBuilder
struct StringBuilder {
    static func buildBlock(_ parts: String...) -> String {
        parts.joined(separator: "\n")
    }
}

@StringBuilder
func makePage() -> String {
    "Header"
    "Content"
    "Footer"
}

print(makePage())
// Header
// Content
// Footer

The @resultBuilder attribute marks the type as a builder. The compiler then treats a function or closure annotated with @StringBuilder specially: it collects each statement and feeds them to buildBlock to combine.

The builder methods

You implement static methods on the builder type to handle different shapes of input. The full set:

Method Purpose
buildBlock(_:...) Combine multiple expressions into one
buildOptional(_:) Handle if without else
buildEither(first:) / buildEither(second:) Handle if/else
buildArray(_:) Handle for loops
buildExpression(_:) Lift individual expressions before passing to buildBlock
buildLimitedAvailability(_:) Handle if #available
buildFinalResult(_:) Final transformation before returning

You only need the methods relevant to the shapes you want to support.

Conditionals

@resultBuilder
struct StringBuilder {
    static func buildBlock(_ parts: String...) -> String {
        parts.joined(separator: "\n")
    }
    static func buildOptional(_ part: String?) -> String {
        part ?? ""
    }
    static func buildEither(first: String) -> String { first }
    static func buildEither(second: String) -> String { second }
}

@StringBuilder
func greet(name: String, formal: Bool) -> String {
    "Hello"
    if formal {
        "Dear \(name),"
    } else {
        "Hey \(name)!"
    }
    "Welcome."
}

print(greet(name: "Ada", formal: false))
// Hello
// Hey Ada!
// Welcome.

Loops

extension StringBuilder {
    static func buildArray(_ parts: [String]) -> String {
        parts.joined(separator: "\n")
    }
}

@StringBuilder
func list(_ items: [String]) -> String {
    "Items:"
    for item in items {
        "- \(item)"
    }
    "End."
}

print(list(["apple", "banana"]))
// Items:
// - apple
// - banana
// End.

A small DSL example

struct AttributedText {
    var text: String
    var attributes: [String: String] = [:]
}

@resultBuilder
struct DocumentBuilder {
    static func buildBlock(_ parts: AttributedText...) -> [AttributedText] { parts }
    static func buildOptional(_ part: [AttributedText]?) -> [AttributedText] { part ?? [] }
    static func buildArray(_ parts: [[AttributedText]]) -> [AttributedText] {
        parts.flatMap { $0 }
    }
    static func buildExpression(_ part: AttributedText) -> AttributedText { part }
    static func buildExpression(_ part: String) -> AttributedText { AttributedText(text: part) }
}

func bold(_ s: String) -> AttributedText {
    AttributedText(text: s, attributes: ["bold": "true"])
}

@DocumentBuilder
func sample() -> [AttributedText] {
    "Once upon a time"
    bold("the dragon awoke")
    "and breathed fire."
}

for line in sample() {
    print("\(line.text)  [\(line.attributes)]")
}

The buildExpression overload lets us write either a String literal or an AttributedText, with the builder lifting plain strings into AttributedText.

When to write a result builder

When you have a domain where users will write many declarative statements that should all be folded into a single output value. SwiftUI is the canonical example, but anywhere you'd write "list of things" with optional conditionals and loops, a result builder makes the code cleaner.

For one-off data construction, regular code or array literals are usually clearer. Result builders pay off when the same DSL is used in many places.


27. Opaque and Existential Types (some vs any)

This is a topic where Swift's evolution has produced two related-looking keywords (some and any) that mean very different things. Understanding the distinction is crucial for writing modern Swift.

The problem with returning protocols

Suppose you have a protocol with an associated type:

protocol Producer {
    associatedtype Output
    func produce() -> Output
}

struct IntProducer: Producer {
    func produce() -> Int { 42 }
}

Now try to return a Producer:

// func makeProducer() -> Producer { IntProducer() }
// ❌ — protocol 'Producer' can only be used as a generic constraint
//        because it has Self or associated type requirements

Why? Because Producer doesn't tell us which Output the producer makes. Two different Producers could have totally different output types. The compiler can't make sense of "a value of type Producer" without knowing the associated types.

Opaque types: some

some Producer means: "a single specific type that conforms to Producer, but I'm not telling you which." The compiler knows the actual type internally; the caller doesn't.

func makeProducer() -> some Producer {
    IntProducer()
}

let p = makeProducer()
let value = p.produce()
print(type(of: value))   // Int

Crucially:

This is return-type abstraction. The implementation can change, but callers can't depend on the specific type, only on the protocol contract.

SwiftUI's body: some View is exactly this pattern. You return some specific View value — the compiler infers what; the caller treats it abstractly.

Existential types: any

any Producer means: "a box that holds some value conforming to Producer, and the specific type can vary at runtime."

func makeProducers() -> [any Producer] {
    [IntProducer(), AnotherProducer()]   // each can be a different type
}

Existentials carry their type information at runtime. They're more flexible — you can store mixed concrete types in one variable — but they have runtime cost (the type info, the indirection) and you can't fully use the protocol's full surface (e.g., you can't call methods that use Self in their signature).

In Swift 5.6, any became required for existential types (a small but loud syntactic change). Before that, you could write Producer directly to mean an existential. Now you write any Producer to make the cost explicit.

When to use which

Use some when... Use any when...
You return one specific concrete type. You need a heterogeneous collection of conformers.
You want the optimizer to see through to the specific type. The specific type varies at runtime.
The caller doesn't need to distinguish types. You need to store, pass around, or switch on different concrete types.

some is almost always what you want for return types when you don't need heterogeneity. any is the right call when you genuinely need an existential.

some parameters (Swift 5.7+)

You can also use some for parameters:

func consume(_ producer: some Producer) {
    print(producer.produce())
}

This is shorthand for a generic parameter:

func consume<P: Producer>(_ producer: P) {
    print(producer.produce())
}

Both forms compile to the same thing — the second is just spelled differently. Use some parameters when you don't need to refer to the type by name elsewhere in the signature.

When any is necessary — and how to use it

Suppose you do want a heterogeneous collection:

let producers: [any Producer] = [IntProducer(), StringProducer()]
for p in producers {
    let output = p.produce()
    print(output)        // type is `any` — limited
}

The compiler erases produce()'s return type into Any (since each producer can produce a different type). You'd need to downcast or design around this.

Often a better solution is a sum type:

enum AnyProduction {
    case int(Int)
    case string(String)
}

If the set of producers is finite, this is cleaner.

Don't worry about this on day one

The some/any distinction is one of the more advanced parts of the language. For most code, some for parameters and return types is the right default — same as a generic parameter, same performance, less syntactic noise. any is there when you need it.


28. Key Paths

A key path is a typed reference to a property. Instead of accessing person.name directly, you can refer to "the .name property of Person" as a value, store it, pass it around, and apply it later. Key paths show up in collection operations, generic helpers, and Swift's structured-concurrency-aware APIs.

Basic key paths

struct Person {
    var name: String
    var age: Int
}

let nameKeyPath = \Person.name        // KeyPath<Person, String>
let ageKeyPath = \Person.age          // KeyPath<Person, Int>

let p = Person(name: "Ada", age: 36)
print(p[keyPath: nameKeyPath])       // "Ada"
print(p[keyPath: ageKeyPath])        // 36

The \Type.property syntax produces a KeyPath<Root, Value>. You apply it with the [keyPath:] subscript.

Writable key paths

let nameKP = \Person.name           // WritableKeyPath if Person.name is var
var p = Person(name: "Ada", age: 36)
p[keyPath: nameKP] = "Augusta"
print(p.name)   // Augusta

If the property is var, the key path is WritableKeyPath, which can both read and write.

For class instances (reference semantics), you get ReferenceWritableKeyPath, which can write through a let constant (since the constant only fixes the reference, not the contents).

Chaining

struct Address {
    var city: String
}
struct Employee {
    var name: String
    var address: Address
}

let cityKP = \Employee.address.city
let emp = Employee(name: "Ada", address: Address(city: "London"))
print(emp[keyPath: cityKP])   // "London"

Key paths in higher-order functions

This is where they shine. Many standard library APIs accept a key path directly:

struct Person {
    var name: String
    var age: Int
}

let people = [
    Person(name: "Ada", age: 36),
    Person(name: "Grace", age: 79),
    Person(name: "Linus", age: 54)
]

let names = people.map(\.name)            // ["Ada", "Grace", "Linus"]
let totalAge = people.map(\.age).reduce(0, +)
let sortedByAge = people.sorted { $0.age < $1.age }
let oldest = people.max(by: { $0.age < $1.age })

people.map(\.name) is shorthand for people.map { $0.name }, but cleaner. Methods that take (Element) -> T closures generally accept key paths too.

Key paths as functions

Swift treats key paths and (Root) -> Value functions interchangeably in many contexts. You can convert with ^-style operators in some libraries, but the cleanest approach in modern Swift is just letting the API accept either.

Use cases

extension Sequence {
    func sorted<T: Comparable>(byKeyPath kp: KeyPath<Element, T>) -> [Element] {
        sorted { $0[keyPath: kp] < $1[keyPath: kp] }
    }
}

let byAge = people.sorted(byKeyPath: \.age)

\.self

A key path to the value itself:

let identityKP = \Int.self
print(5[keyPath: identityKP])   // 5

Surprisingly useful in generic code that needs an "identity" key path as a placeholder.


29. Pattern Matching Deep Dive

We've used patterns throughout this guide — in switch, if let, guard let, and for case let. Now let's go deeper. Pattern matching is one of Swift's most expressive features, and mastering it unlocks beautiful code.

Where patterns appear

Wildcard

let (_, year) = ("Ada", 1815)
print(year)   // 1815

_ matches anything without binding. We've seen it ignoring tuple components and switch values.

Identifier (binding)

let value = 42
switch value {
case let x:
    print(x)   // matches anything, binds to x
}

let x binds whatever was matched. Combine with case let .some(x) to extract from optionals or enum associated values.

Value-binding patterns in tuples

let point = (3, 7)
switch point {
case let (x, 0):
    print("on x-axis at \(x)")
case let (0, y):
    print("on y-axis at \(y)")
case let (x, y):
    print("at \(x), \(y)")
}

Mix bound names with literal matches.

Range patterns

let n = 42
switch n {
case 0:
    print("zero")
case 1..<10:
    print("small")
case 10...100:
    print("medium")
default:
    print("big")
}

Both half-open and closed ranges work.

Enum patterns

enum Result<T> {
    case success(T)
    case failure(String)
}

let r: Result<Int> = .success(42)

switch r {
case .success(let value) where value > 0:
    print("positive: \(value)")
case .success(let value):
    print("non-positive: \(value)")
case .failure(let msg):
    print("failed: \(msg)")
}

The where clause adds an additional condition to the case.

Optional patterns

We saw these earlier:

let maybe: Int? = 5
switch maybe {
case nil:
    print("none")
case let value?:           // shorthand for .some(value)
    print("some: \(value)")
}

In for case:

let optionals: [Int?] = [1, nil, 2, nil, 3]
for case let value? in optionals {
    print(value)   // 1, 2, 3
}

Type-casting patterns

let things: [Any] = [1, "hi", 3.14, true]
for thing in things {
    switch thing {
    case is Int:
        print("an int")
    case let s as String where s.count > 1:
        print("string: \(s)")
    case is Double:
        print("a double")
    default:
        print("other")
    }
}

Expression patterns

In a switch, you can match against any value with ~=:

let x = 5
switch x {
case 1...3:    // ~= operator on Range
    print("low")
case 4...6:
    print("mid")
default:
    print("high")
}

You can overload ~= to make any custom type pattern-matchable. The compiler synthesizes this for Equatable types and Ranges.

if case

Sometimes you want to test for one specific pattern without a full switch:

enum LoginState {
    case loggedOut
    case loggedIn(name: String)
    case banned(reason: String)
}

let state = LoginState.loggedIn(name: "Ada")

if case .loggedIn(let name) = state {
    print("Welcome, \(name)")
}

if case .loggedIn = state {   // don't bind, just test
    print("Logged in (we don't care who)")
}

guard case .loggedIn(let name) = state else {
    return
}
// name is in scope here

if case is the pattern-matching counterpart to if. It's invaluable when you only care about one specific shape and don't want to write a switch with a default.

for case

Filter a sequence to elements matching a pattern:

enum Event {
    case login(name: String)
    case logout
    case message(String)
}

let events: [Event] = [
    .login(name: "Ada"),
    .message("Hello!"),
    .logout,
    .login(name: "Grace"),
]

for case .login(let name) in events {
    print("login: \(name)")
}
// login: Ada
// login: Grace

Combining patterns

You can combine bindings, conditions, and casts richly:

let mixed: [Any?] = [1, "two", nil, 3, nil, "five"]

for case let n as Int in mixed.compactMap({ $0 }) where n > 1 {
    print(n)   // 3
}

This is a lot to parse — case let n as Int extracts only Int values, then where n > 1 filters further.

When patterns get this dense, consider extracting a helper. Readability beats cleverness.


30. Concurrency: async/await Fundamentals

Swift 5.5 (2021) introduced structured concurrency: async, await, Task, and actors. This is a sweeping addition that replaces callback-based and Combine-based async patterns with something far more readable. The model is built around structured concurrency — child tasks have lifetimes tied to a parent, errors propagate naturally, and cancellation is part of the type system.

This chapter covers the basics; the next two cover tasks/groups/cancellation, then actors.

Async functions

Mark a function async if it can suspend:

func fetchData() async -> String {
    // Imagine a network call here
    try? await Task.sleep(for: .seconds(1))
    return "data"
}

You call async functions with await:

func process() async {
    let data = await fetchData()
    print(data)
}

await is a suspension point — the function may pause here and resume later, possibly on a different thread. While suspended, the calling thread is free to do other work.

If the async function can throw, mark it async throws:

enum NetError: Error { case timeout }

func loadConfig() async throws -> String {
    try await Task.sleep(for: .milliseconds(500))
    if Bool.random() { throw NetError.timeout }
    return "config"
}

func setup() async {
    do {
        let config = try await loadConfig()
        print(config)
    } catch {
        print("error: \(error)")
    }
}

Note the order: try await. Both annotations apply — the call can both throw and suspend.

Calling async code from sync code

You can't call await directly from a synchronous function. You need to start a Task:

func mainSync() {
    Task {
        let data = await fetchData()
        print(data)
    }
}

A Task represents a unit of asynchronous work. We'll go deeper in the next chapter. For top-level code in a script, you can also use await directly:

// In top-level code (not inside a function):
let result = await fetchData()
print(result)

For @main types, mark the entry point async:

@main
struct App {
    static func main() async {
        let result = await fetchData()
        print(result)
    }
}

Concurrent execution with async let

You can start two async operations in parallel:

func loadAll() async throws -> (String, String) {
    async let user = fetchUser()
    async let posts = fetchPosts()
    return try await (user, posts)
}

Both fetchUser() and fetchPosts() start running concurrently. The await waits for both.

Without async let, sequential awaits run one after another:

let user = await fetchUser()      // first
let posts = await fetchPosts()    // then this
// Total time: sum of both

With async let:

async let user = fetchUser()      // start in background
async let posts = fetchPosts()    // also start in background
let (u, p) = await (user, posts)  // wait for both
// Total time: max of both

This is one of the most beautiful pieces of the new model. Compare to writing this with callbacks or DispatchGroup — it'd be ten times the code.

await is a yield point

Conceptually, when you await, your function:

  1. Pauses execution.
  2. Returns control to the system.
  3. Some other work may run on the same thread.
  4. When the awaited operation completes, your function resumes.

You don't manage threads explicitly. The runtime decides where work runs based on available threads and where it makes sense (the same thread, or a different one — Swift can move execution between threads at suspension points).

This means: state can change across an await. If you read self.x, await something, then read self.x again, the values may differ. Code between awaits is the only thing guaranteed to be uninterrupted.

Mistakes to avoid


31. Tasks, Task Groups, and Cancellation

A Task represents an asynchronous unit of work — an entry point into the structured concurrency runtime. There are several flavors and a rich API around them.

Creating a task

let task = Task {
    return 42
}

let value = await task.value   // 42

Task { ... } creates a top-level task that runs concurrently with the rest of your code. You can await its value to get the result.

Throwing tasks

If the closure throws, the task's value is throwing too:

let task = Task<Int, Error> {
    if Bool.random() { throw NetError.timeout }
    return 42
}

do {
    let v = try await task.value
} catch {
    print("error: \(error)")
}

The compiler infers the generic parameters from the closure body — you rarely write Task<...> explicitly.

Detached tasks

A regular Task inherits its parent task's priority, cancellation status, and (importantly) its actor isolation. A Task.detached does not — it starts fresh, with no inherited context:

Task.detached(priority: .background) {
    await doExpensiveStuff()
}

Use detached tasks sparingly. The default Task { } is almost always what you want; detached is for genuine fire-and-forget work that should not be tied to the calling context.

Task priorities

Task(priority: .high) { ... }
Task(priority: .background) { ... }

Priorities are hints to the scheduler: .userInitiated, .utility, .background. Don't over-engineer this; the default is fine for most cases.

Cancellation

A Task can be cancelled. This is a cooperative mechanism — cancellation sets a flag; the task itself decides when (or whether) to honor it.

let task = Task {
    for i in 1...100 {
        if Task.isCancelled { return }
        try await Task.sleep(for: .milliseconds(100))
        print(i)
    }
}

try await Task.sleep(for: .milliseconds(350))
task.cancel()

Task.isCancelled is true if cancellation was requested. You can also throw if cancelled:

try Task.checkCancellation()   // throws CancellationError if cancelled

Most async standard-library APIs honor cancellation automatically — Task.sleep, URLSession.bytes, etc. throw CancellationError rather than completing. So in many cases, you just propagate try and cancellation works.

Task groups

For dynamic concurrency — fanning out N tasks where N is determined at runtime — use a task group:

func sumOfSquares(_ numbers: [Int]) async -> Int {
    await withTaskGroup(of: Int.self) { group in
        for n in numbers {
            group.addTask {
                return n * n
            }
        }

        var total = 0
        for await result in group {
            total += result
        }
        return total
    }
}

let result = await sumOfSquares([1, 2, 3, 4, 5])
print(result)   // 55

Inside the group:

Throwing variant:

func loadAll(urls: [URL]) async throws -> [Data] {
    try await withThrowingTaskGroup(of: Data.self) { group in
        for url in urls {
            group.addTask {
                try await fetchData(from: url)
            }
        }
        var results: [Data] = []
        for try await data in group {
            results.append(data)
        }
        return results
    }
}

If any child throws, the group cancels remaining children and re-throws. This is exactly what you want — fail fast, no orphaned work.

async let vs task groups

Cancelling a group

If the parent task is cancelled, the group propagates cancellation to all children. You can also cancel individual children inside a group via group.cancelAll().

Task.yield() and Task.sleep

Task.yield() suspends voluntarily, giving the scheduler a chance to run other tasks. Useful in long-running synchronous loops:

for i in 0..<1_000_000 {
    if i.isMultiple(of: 1000) {
        await Task.yield()
    }
    process(i)
}

Task.sleep(for:) suspends for a duration. It supports Duration (Swift 5.7+) or older nanoseconds: parameter.

try await Task.sleep(for: .seconds(2))
try await Task.sleep(for: .milliseconds(500))

Timeouts

Swift doesn't ship a built-in timeout(after:operation:), but you can build one with task groups:

struct TimeoutError: Error {}

func withTimeout<T: Sendable>(
    _ duration: Duration,
    operation: @Sendable @escaping () async throws -> T
) async throws -> T {
    try await withThrowingTaskGroup(of: T.self) { group in
        group.addTask { try await operation() }
        group.addTask {
            try await Task.sleep(for: duration)
            throw TimeoutError()
        }
        let result = try await group.next()!
        group.cancelAll()
        return result
    }
}

// usage:
do {
    let data = try await withTimeout(.seconds(5)) {
        try await fetchData()
    }
} catch is TimeoutError {
    print("timed out")
}

The Sendable constraints — we'll explain those next — are necessary because the closure crosses task boundaries.


32. Actors and Data Isolation

Tasks gave us concurrency. But concurrency creates a new problem: if two tasks touch the same mutable state at the same time, the result is undefined behavior — a data race. Classes don't protect you here. A counter incremented from two tasks at once will lose updates, and in the worst case can crash or corrupt memory.

The pre-Swift-5.5 solution was locks, dispatch queues, and serial queues — manually serialize access. The new solution is the actor: a reference type that guarantees its mutable state is touched by only one task at a time, automatically. You don't write the locking. The compiler enforces that you can only reach the actor's state through await, and the actor itself serializes those accesses.

Declaring an actor

actor Counter {
    private var value = 0

    func increment() {
        value += 1
    }

    func current() -> Int {
        value
    }
}

That looks like a class, but with the keyword actor. The state value is isolated — only code running "on" the actor can touch it directly. Everyone else has to ask, and asking takes time, so it's await.

let counter = Counter()

await counter.increment()
let n = await counter.current()

If you forget the await, you get a compile error: actor-isolated instance method 'increment()' can not be referenced from a non-isolated context. The compiler is forcing you through the door.

What "isolation" actually means

When code runs inside the actor — say, one method calling another — it's already on the actor's executor, so it can touch value directly with no await. From the outside, every hop to the actor is a suspension point.

actor BankAccount {
    private var balance: Double = 0

    func deposit(_ amount: Double) {
        balance += amount       // direct access, we're isolated
        logTransaction(amount)  // also direct, internal call stays isolated
    }

    private func logTransaction(_ amount: Double) {
        print("deposited \(amount), balance now \(balance)")
    }
}

From outside, even reading balance would require await — but it's private here, so you can't reach it at all. That's the typical pattern: all stored state is private; the public API is methods that mutate or query that state.

Why this fixes data races

Two tasks both call await counter.increment() at the same time. Behind the scenes, the actor has a mailbox. Each call is enqueued. The actor runs one to completion, then runs the next. There is no interleaving of value += 1 from two tasks. The race is gone, by construction.

Compare to a class:

final class BadCounter {
    var value = 0
    func increment() { value += 1 }
}

let bad = BadCounter()
await withTaskGroup(of: Void.self) { group in
    for _ in 0..<1_000 {
        group.addTask { bad.increment() }   // data race
    }
}
print(bad.value)  // probably less than 1000, possibly crashes

In Swift 6 strict mode, that code won't even compile — passing bad (a non-Sendable class) into concurrent tasks is an error. We'll cover Sendable in the next section.

Actor reentrancy

This trips people up. When an actor method awaits something, the actor is not blocked — it picks up other queued messages while waiting. This is called reentrancy, and it means actor state can change across an await.

actor ImageCache {
    private var cache: [URL: Data] = [:]

    func image(for url: URL) async throws -> Data {
        if let cached = cache[url] {
            return cached
        }
        let data = try await download(url)   // suspension point!
        cache[url] = data
        return data
    }

    private func download(_ url: URL) async throws -> Data { /* ... */ }
}

Looks fine. But what if two tasks ask for the same URL simultaneously? Both check cache, both miss, both kick off a download. The second one overwrites the first when both return. You did two downloads instead of one.

The fix is to track in-flight requests:

actor ImageCache {
    private var cache: [URL: Data] = [:]
    private var inFlight: [URL: Task<Data, Error>] = [:]

    func image(for url: URL) async throws -> Data {
        if let cached = cache[url] { return cached }
        if let task = inFlight[url] { return try await task.value }

        let task = Task { try await self.download(url) }
        inFlight[url] = task
        defer { inFlight[url] = nil }

        let data = try await task.value
        cache[url] = data
        return data
    }

    private func download(_ url: URL) async throws -> Data { /* ... */ }
}

The lesson: across an await, anything could have happened on this actor. Re-check invariants if you depend on them.

nonisolated

Sometimes a method on an actor doesn't touch isolated state and shouldn't require await. Mark it nonisolated:

actor User {
    let id: UUID         // immutable, no isolation needed
    private var name: String

    init(id: UUID, name: String) {
        self.id = id
        self.name = name
    }

    nonisolated var description: String {
        "User \(id)"     // only touches `id`, which is `let`
    }

    func rename(_ new: String) {
        name = new       // isolated
    }
}

let u = User(id: UUID(), name: "alice")
print(u.description)     // no await needed

nonisolated says "I don't need actor protection." The compiler verifies this — if you try to read name from a nonisolated method, it errors.

This matters for protocol conformance. If you need an actor to conform to CustomStringConvertible, the description getter is synchronous — it can't await. So you mark it nonisolated and only touch immutable state.

MainActor and global actors

Some code must run on a specific thread — UI code on the main thread is the canonical example. Swift expresses this with global actors, and the most important one is @MainActor.

@MainActor
final class ViewModel {
    var items: [String] = []

    func reload() {
        items = ["a", "b", "c"]   // we're on the main actor
    }
}

Anything annotated @MainActor runs on the main executor. From elsewhere, you call into it with await:

let vm = ViewModel()      // creating it doesn't require main actor here, depends on context
await vm.reload()

You can mark individual functions or properties:

@MainActor
func updateUI() { /* ... */ }

Or hop onto the main actor inside a function:

func process() async {
    let result = await heavyComputation()
    await MainActor.run {
        // we're on main actor here
        updateUI(with: result)
    }
}

Global actors are declared with @globalActor:

@globalActor
actor DatabaseActor {
    static let shared = DatabaseActor()
}

@DatabaseActor
func writeRecord(_ r: Record) { /* ... */ }

Now writeRecord is isolated to the DatabaseActor's single executor — useful when you want all DB writes serialized through one place.

What to internalize about actors

Actors give you safe shared mutable state by serializing access. Reads and writes from outside are async. Internal calls stay synchronous and isolated. Reentrancy is the gotcha: state can shift across await. nonisolated is your escape hatch for things that genuinely don't need protection. @MainActor is the pragmatic tool you'll reach for constantly.


33. Sendable and Data-Race Safety

Actors solve mutable shared state. But there's a related question: when you pass a value between tasks or actors, is that value safe to send? A String or Int is fine — it's a value type with no shared reference. A class Counter with mutable state is not fine — sending it to another task hands them a reference, and now both can mutate it.

Swift formalizes this with the Sendable protocol. If a type is Sendable, it's safe to pass between concurrency domains. If it isn't, the compiler refuses.

What Sendable means

Sendable is a marker protocol — it has no requirements. Conformance is a promise about thread safety:

struct Point: Sendable {       // synthesized automatically since x, y are Sendable
    let x: Double
    let y: Double
}

final class Config: Sendable { // OK: final + immutable lets only
    let url: URL
    let timeout: TimeInterval
    init(url: URL, timeout: TimeInterval) {
        self.url = url
        self.timeout = timeout
    }
}

class MutableBag {            // NOT Sendable, has mutable state
    var items: [String] = []
}

@Sendable closures

A @Sendable closure can be passed across concurrency boundaries. It can only capture Sendable values, and Task { } requires its closure to be @Sendable.

func runAsync(_ body: @Sendable @escaping () async -> Void) {
    Task { await body() }
}

let n = 42
runAsync {
    print(n)        // OK: Int is Sendable, captured by value
}

var counter = 0
runAsync {
    counter += 1    // ERROR: capturing var of non-Sendable nature
}

@Sendable is a contract: this closure won't cause data races when run elsewhere.

Synthesized conformance

The compiler synthesizes Sendable conformance for:

If your struct has any non-Sendable stored property, conformance fails:

class Logger { /* not Sendable */ }

struct Service: Sendable {     // ERROR
    let logger: Logger          // not Sendable
}

You either make Logger an actor, make it a final class with only immutable Sendable state, or rethink the design.

@unchecked Sendable

The escape hatch. You're telling the compiler: "trust me, I've handled the synchronization."

final class LockedCache: @unchecked Sendable {
    private let lock = NSLock()
    private var storage: [String: Data] = [:]

    func get(_ key: String) -> Data? {
        lock.lock(); defer { lock.unlock() }
        return storage[key]
    }

    func set(_ key: String, _ value: Data) {
        lock.lock(); defer { lock.unlock() }
        storage[key] = value
    }
}

Use sparingly. Every @unchecked Sendable is a place the compiler can't help you — if your locking is wrong, you get a data race the compiler thought was impossible. Most of the time, an actor is the better answer.

Strict concurrency checking

Swift 5.x introduced Sendable but warnings for violations were opt-in. Swift 6 turns those warnings into errors by default. The package compiler flag is -strict-concurrency=complete, and Swift 6 mode is the production setting.

What you'll hit when you turn it on:

// before:
var globalCache: [String: Data] = [:]    // ERROR in Swift 6

// option 1: main-actor-isolated global
@MainActor var globalCache: [String: Data] = [:]

// option 2: actor
actor GlobalCache {
    static let shared = GlobalCache()
    private var storage: [String: Data] = [:]
    func get(_ k: String) -> Data? { storage[k] }
    func set(_ k: String, _ v: Data) { storage[k] = v }
}

Sending values vs sharing references

The mental model: when you send a Sendable value type across an await, the receiver gets its own copy. No sharing. When you "send" an actor, you're sending a reference — but that's fine because the actor mediates all access. When you'd want to send a regular class with mutable state… you can't, and that's the point.

Swift 5.9 added sending parameters: a way to transfer ownership of a non-Sendable value into a function, with the compiler verifying the caller no longer uses it. This is more advanced — most code lives at the Sendable / actor level.

What to internalize

Sendable is the type-system tool that prevents data races at compile time. Value types with Sendable contents get conformance for free. Classes with mutable state need an actor or careful locking. @Sendable closures capture only Sendable state. Swift 6 turns this from a suggestion into law. The payoff: a class of bugs you used to debug with print statements at 2am simply cannot exist in your code anymore.


34. AsyncSequence and AsyncStream

async/await handles single values. But often you have a stream — file lines arriving as you read, network bytes, user events, sensor readings. Swift models these with AsyncSequence, the async cousin of Sequence.

The AsyncSequence protocol

AsyncSequence is to async iteration what Sequence is to sync iteration. It defines an iterator that produces values via await, and you consume it with for try await:

for try await line in fileURL.lines {
    print(line)
}

fileURL.lines is an AsyncSequence<String>. Each iteration awaits the next line. The loop suspends, the underlying I/O proceeds, the line arrives, the loop resumes.

You can use most Sequence operations: map, filter, reduce, prefix, dropFirst. They have async variants too:

let firstFive = fileURL.lines.prefix(5)
for try await line in firstFive {
    print(line)
}

let count = try await fileURL.lines.filter { $0.hasPrefix("ERROR") }.reduce(0) { acc, _ in acc + 1 }

Defining your own AsyncSequence

You can implement the protocol directly, but it's verbose. Most of the time you'll use AsyncStream instead.

For completeness, here's a manual implementation that yields integers with delays:

struct Counter: AsyncSequence {
    typealias Element = Int
    let limit: Int

    struct AsyncIterator: AsyncIteratorProtocol {
        var current = 0
        let limit: Int

        mutating func next() async -> Int? {
            guard current < limit else { return nil }
            try? await Task.sleep(for: .milliseconds(100))
            defer { current += 1 }
            return current
        }
    }

    func makeAsyncIterator() -> AsyncIterator {
        AsyncIterator(limit: limit)
    }
}

for await n in Counter(limit: 5) {
    print(n)   // 0, 1, 2, 3, 4 with 100ms gaps
}

AsyncStream: the practical builder

AsyncStream lets you bridge any callback-based or push-based source into an AsyncSequence. You provide a closure with a continuation, and you call yield(_:) on it whenever a new value arrives:

let timer = AsyncStream<Date> { continuation in
    let task = Task {
        while !Task.isCancelled {
            continuation.yield(Date())
            try? await Task.sleep(for: .seconds(1))
        }
        continuation.finish()
    }

    continuation.onTermination = { _ in
        task.cancel()
    }
}

for await timestamp in timer.prefix(3) {
    print(timestamp)
}

The continuation has three operations:

AsyncThrowingStream is the same but lets you finish(throwing: error) to terminate with an error the consumer will see via try await.

Buffering policy

By default AsyncStream buffers unbounded values if the consumer is slow. You can set a policy:

let stream = AsyncStream<Event>(bufferingPolicy: .bufferingNewest(10)) { continuation in
    // if consumer is slow, only the latest 10 values are kept
}

Options: .unbounded (default), .bufferingNewest(n), .bufferingOldest(n).

Bridging legacy APIs

This pattern is the bread and butter of AsyncStream. Wrapping a delegate, a callback, or a notification:

extension NotificationCenter {
    func notifications(named name: Notification.Name) -> AsyncStream<Notification> {
        AsyncStream { continuation in
            let token = self.addObserver(forName: name, object: nil, queue: nil) { note in
                continuation.yield(note)
            }
            continuation.onTermination = { _ in
                self.removeObserver(token)
            }
        }
    }
}

for await note in NotificationCenter.default.notifications(named: .myEvent).prefix(5) {
    handle(note)
}

The cleanup in onTermination is essential — without it you leak the observer.

Common patterns

Combining streams isn't built into the standard library — for that you reach for the swift-async-algorithms package, which adds merge, zip, combineLatest, chunked, debounce, throttle, and friends.

Cancellation propagates naturally. If the consuming task is cancelled, the for await loop exits at the next iteration and onTermination fires.

Errors in AsyncThrowingStream are terminal — once you call finish(throwing:), no more values are produced.

What to internalize

AsyncSequence is iteration-with-await. AsyncStream is the easy way to make one — give it a closure, yield values, finish when done, clean up in onTermination. It's the standard way to bridge any push-based source (notifications, delegates, callbacks) into the async/await world. For complex stream composition, reach for swift-async-algorithms.


35. Macros

Macros, added in Swift 5.9, let you generate code at compile time. You've seen them already — #available, #warning, #fileID are macros, and so is @Observable, the SwiftUI/Observation framework's marker. They run in a separate compiler process, take your source code as input, and produce more source code as output. The result is type-checked normally, so unlike C macros they can't violate the type system.

Two flavors

Freestanding macros start with # and expand to expressions, declarations, or statements:

let url = #URL("https://example.com")        // expression macro
#warning("don't ship this")                   // declaration-level

Attached macros start with @ and modify the declaration they're attached to. They can:

@Observable
final class Model {
    var name: String = ""
    var count: Int = 0
}

@Observable here uses several attached macro roles to inject observation tracking into the stored properties and add conformance to Observable. You write four lines; the compiler sees something like sixty.

Using macros

You use them like any other declaration. The compiler resolves the macro, runs it, and substitutes the expansion into your source. In Xcode you can right-click a macro use and "Expand Macro" to see what it became.

A few standard-library and Apple-framework macros worth knowing:

Why macros matter

Before macros, frameworks resorted to runtime tricks: KVO, Mirror, code generation tools (Sourcery), or property wrappers stretched past their design. Each has costs — runtime overhead, fragility, build complexity. Macros move that work to compile time, with full type checking.

The trade-off: macros are more work to author than property wrappers, and tooling is heavier. For library authors solving real boilerplate problems, they're a great fit. For one-off code, regular Swift is usually clearer.

Authoring a macro (overview)

Implementing a macro requires:

  1. A separate Swift package target of type .macro that depends on swift-syntax.
  2. Implementing a type that conforms to one of the macro protocols (ExpressionMacro, MemberMacro, AccessorMacro, etc.).
  3. Manipulating the input syntax tree and returning a new syntax tree.
  4. Declaring the macro in your library with #externalMacro(module:type:).

A minimal expression macro:

// in MyMacrosImpl module:
import SwiftSyntax
import SwiftSyntaxMacros

public struct StringifyMacro: ExpressionMacro {
    public static func expansion(
        of node: some FreestandingMacroExpansionSyntax,
        in context: some MacroExpansionContext
    ) -> ExprSyntax {
        guard let argument = node.arguments.first?.expression else {
            fatalError("compiler bug: missing argument")
        }
        return "(\(argument), \(literal: argument.description))"
    }
}

// in MyMacros library module:
@freestanding(expression)
public macro stringify<T>(_ value: T) -> (T, String)
    = #externalMacro(module: "MyMacrosImpl", type: "StringifyMacro")

// in user code:
let result = #stringify(2 + 3)  // expands to (2 + 3, "2 + 3")

You give the compiler a callable that takes a syntax tree and returns one. The string-interpolation-into-ExprSyntax syntax ("(\(argument), \(literal: ...))") is swift-syntax's convenience for building syntax nodes from string literals.

This is genuinely a deep rabbit hole. The Swift forums and the swift-syntax repo have tutorials, and Xcode's macro template gives you a working starting point.

When to author a macro vs not

Use one when:

Skip macros when:

What to internalize

Macros are compile-time code generation, type-checked. You'll use them constantly (@Observable, #Predicate, @Test). You'll write them rarely, and only in library code where the boilerplate-elimination payoff is real. The expansion is always inspectable in Xcode, which means they can't truly hide what's going on — there's no magic, just generated source.


36. Custom Operators and Advanced Idioms

Swift lets you define your own operators and use the type system in ways that turn runtime checks into compile-time guarantees. This last technical chapter is a sampler of advanced idioms that experienced Swift programmers reach for.

Custom operators

You can declare new operators with custom precedence and associativity.

infix operator **: ExponentiationPrecedence

precedencegroup ExponentiationPrecedence {
    associativity: right
    higherThan: MultiplicationPrecedence
}

func ** (base: Double, exponent: Double) -> Double {
    pow(base, exponent)
}

let result = 2.0 ** 3.0 ** 2.0   // 2 ** (3 ** 2) = 512

Three pieces: declare the operator (infix/prefix/postfix), declare the precedence group (or reuse a built-in), implement the function.

Custom operators are best for domain-specific work where the symbolic notation matches the math: vectors, matrices, units. Avoid them for general code. applyTransform(to: x) reads better than ~~>(x) for everything but specialized DSLs.

Function composition operator

A common helper operator for composing functions:

infix operator >>>: AdditionPrecedence

func >>> <A, B, C>(f: @escaping (A) -> B, g: @escaping (B) -> C) -> (A) -> C {
    { a in g(f(a)) }
}

let increment: (Int) -> Int = { $0 + 1 }
let double: (Int) -> Int = { $0 * 2 }
let combined = increment >>> double
print(combined(3))   // 8: (3 + 1) * 2

Phantom types

A phantom type is a generic parameter that never appears in stored properties — it exists only to constrain the type at compile time. Useful for tagging values to prevent mixups:

struct Tagged<Tag, Value> {
    let value: Value
}

enum UserIDTag {}
enum OrderIDTag {}

typealias UserID = Tagged<UserIDTag, Int>
typealias OrderID = Tagged<OrderIDTag, Int>

let user = UserID(value: 42)
let order = OrderID(value: 42)

func fetchUser(id: UserID) { /* ... */ }

fetchUser(id: user)   // OK
fetchUser(id: order)  // ERROR: cannot convert OrderID to UserID

Both wrap an Int, but they're distinct types. You can no longer pass an order ID where a user ID is expected, even though both are Int underneath.

Type-state encoding

Encode the state of an object into its type, so invalid operations fail to compile.

enum Locked {}
enum Unlocked {}

struct Door<State> {
    private init() {}
}

extension Door where State == Locked {
    static func locked() -> Door<Locked> { Door() }
    func unlock() -> Door<Unlocked> { Door<Unlocked>() }
}

extension Door where State == Unlocked {
    func open() { print("open!") }
    func lock() -> Door<Locked> { Door<Locked>() }
}

let d = Door<Locked>.locked()
// d.open()                     // compile error: locked door has no open()
let unlocked = d.unlock()
unlocked.open()                  // OK
let relocked = unlocked.lock()
// relocked.open()               // compile error again

This is overkill for most code, but for protocols, builders, and APIs that have illegal-state-equals-bug semantics, it catches errors at compile time that would otherwise be runtime crashes.

@dynamicMemberLookup and @dynamicCallable

These attributes let types respond to arbitrary member access or call syntax, dispatched at runtime. Mostly for bridging dynamic systems (JSON, scripting languages):

@dynamicMemberLookup
struct JSON {
    let storage: [String: Any]

    subscript(dynamicMember key: String) -> JSON? {
        guard let value = storage[key] as? [String: Any] else { return nil }
        return JSON(storage: value)
    }
}

let json = JSON(storage: ["user": ["name": ["first": "alice"]]])
let first = json.user?.name?.first

You wrote .user.name.first and the compiler turned each access into subscript(dynamicMember:) calls.

Builder patterns with method chaining

The fluent builder using @discardableResult and self-returning methods:

final class RequestBuilder {
    private var url: URL?
    private var headers: [String: String] = [:]
    private var method: String = "GET"

    @discardableResult
    func url(_ url: URL) -> Self {
        self.url = url
        return self
    }

    @discardableResult
    func header(_ key: String, _ value: String) -> Self {
        headers[key] = value
        return self
    }

    @discardableResult
    func method(_ method: String) -> Self {
        self.method = method
        return self
    }

    func build() -> URLRequest? {
        guard let url else { return nil }
        var req = URLRequest(url: url)
        req.httpMethod = method
        for (k, v) in headers { req.setValue(v, forHTTPHeaderField: k) }
        return req
    }
}

let req = RequestBuilder()
    .url(URL(string: "https://example.com")!)
    .method("POST")
    .header("Authorization", "Bearer xyz")
    .build()

Modern Swift often prefers result builders for this (chapter 26), but method chaining still has its place when the builder steps need control flow or aren't purely declarative.

Conditional conformance

Make a generic type conform to a protocol only when its parameters do:

struct Pair<A, B> {
    let first: A
    let second: B
}

extension Pair: Equatable where A: Equatable, B: Equatable {
    static func == (lhs: Pair, rhs: Pair) -> Bool {
        lhs.first == rhs.first && lhs.second == rhs.second
    }
}

let p1 = Pair(first: 1, second: "a")
let p2 = Pair(first: 1, second: "a")
print(p1 == p2)   // true

struct NotEquatable {}
let p3 = Pair(first: NotEquatable(), second: NotEquatable())
// p3 == p3       // ERROR: not Equatable

Standard library uses this everywhere: Array<Element> is Equatable only when Element is.

@_disfavoredOverload and overload resolution

When you have two overloads where Swift prefers the wrong one, @_disfavoredOverload (private but widely used) tells the compiler to pick the other one when it has a choice. Underscored attributes are unstable, but this one is common in libraries.

What to internalize

Most of these are tools you use sparingly. Custom operators, phantom types, and type-state encoding belong in libraries and DSLs. Conditional conformance is something you'll write often once you make a generic type. The takeaway isn't to use all of them — it's to know they exist so when you hit a problem they fit, you reach for the right tool instead of fighting the type system.


37. Where to Go Next

You've covered the language. Real fluency comes from using it. Some directions worth your time:

Read Swift Evolution

Every language change goes through a public proposal: discussion, review, acceptance or rejection. Reading active and accepted proposals at github.com/swiftlang/swift-evolution is the best way to understand why Swift looks the way it does. Each proposal explains the problem, alternatives considered, and design trade-offs.

Pinned reading: SE-0296 (async/await), SE-0306 (Actors), SE-0316 (Global Actors), SE-0337 (Strict Concurrency), SE-0382 (Expression Macros), SE-0395 (Observation), SE-0414 (Region-based Isolation).

swift.org documentation

The official site at swift.org hosts:

Server-side Swift

Swift on the server is mature. Vapor and Hummingbird are the two main frameworks. Both lean heavily on async/await and structured concurrency. If you've written backends in Node or Go, Swift will feel both familiar (async-first) and stricter (type-safe to a fault).

Worth exploring even if you stay client-side — the server world surfaces concurrency problems that are easy to ignore in UI code.

Embedded Swift

Swift now compiles for embedded targets — microcontrollers, kernel-adjacent code, situations with no allocator and no runtime. The Embedded mode strips features that need a runtime (existentials, reflection) and produces tiny binaries. The Swift Embedded examples on GitHub are good to skim even if you never ship to a microcontroller; they reveal which features are "free" and which carry runtime cost.

Packages worth studying

Reading well-written Swift teaches you idioms no tutorial can. Some good reading:

Practice ideas

The fastest way to internalize the material in this guide:

  1. Build a small CLI in pure Swift using swift package init --type executable. Parse arguments, read files, do something useful.
  2. Reimplement a piece of the standard library — write your own Optional, your own Result, your own simple Array. You'll find sharp corners you skimmed past.
  3. Wrap a delegate-based or callback-based API in AsyncStream.
  4. Write a tiny actor-based cache with eviction, expiration, and proper handling of in-flight requests (chapter 32's reentrancy gotcha).
  5. Author a property wrapper for something you keep reaching for — clamped values, validated strings, lazy-initialized expensive objects.
  6. Write generic code with associated types and constraints — try implementing a typed event bus, or a type-safe dependency container.

Stay current

Swift moves. Every WWDC ships language and library changes. The annual rhythm:

Following a couple of Swift-focused blogs (Hacking with Swift, Swift Weekly Brief) and the forums is enough to stay current without it becoming a job.

Final thought

Swift rewards careful reading. The standard library is dense, but every type and protocol is there for a reason — usually documented on the forums. When something seems weird, dig: there's almost always a thoughtful design rationale. The language is expressive enough that you can write Swift that looks like Python, like Haskell, or like Rust depending on the day. The skill is choosing the right level for the problem in front of you.

Good luck, and have fun.