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.
some vs any)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.
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
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.
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.
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 constantA 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 variableUse var only when you actually need to reassign:
var counter = 0
counter = 1
counter += 1
print(counter) // 2
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.
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.
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.
Allowed, occasionally readable:
var x = 0, y = 0, z = 0
But splitting these out usually reads better. Aesthetic minimalism in Swift rarely improves clarity.
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.
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.
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)
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.
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
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.
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.
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.
Swift's operators look familiar but have a few important differences worth knowing up front.
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.
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.
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.
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.
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
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
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.
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.
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.
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.
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.
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)
}
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.
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.
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.
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"
== 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.
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.
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.
Swift gives you three primary collection types, all generic and all value types.
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.
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]
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).
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]
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 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
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.
ages["Linus"] = nil // removes the key
ages.removeValue(forKey: "Ada")
ages.removeAll()
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 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.
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
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.
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 / elselet 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 varThis 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.
guardguard 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
}
switchSwift'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.
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.
switch character {
case "a", "e", "i", "o", "u":
print("vowel")
default:
print("consonant")
}
fallthroughIf 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.
for-infor 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")
}
enumerated()let names = ["Ada", "Grace", "Linus"]
for (index, name) in names.enumerated() {
print("\(index): \(name)")
}
while and repeat-whilevar 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 continuefor 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
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
}
}
}
deferdefer 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.
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.
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)")
}
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)
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.
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].
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.)
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
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.
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.
@discardableResultIf 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 typeA 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"
}
}
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.
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.
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).
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.
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.
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") }
}
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 closuresBy 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).
@autoclosureThis 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.
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.
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.
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.
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.
There are several ways to safely extract the value:
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
}
guard letfunc process(_ input: String?) -> Int {
guard let input else { return 0 }
return input.count
}
??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"
?.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.
!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.
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.
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 optionalsOptional 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 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.
compactMapThrow 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.
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.
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.
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.
switchswitch 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.
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.
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)")
}
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:
success and failure simultaneously.switch.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.
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
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.
CaseIterableIf 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
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>)
}
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.
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.
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"
This is the big one. Structs are value types. Classes are reference types.
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
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.
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.
A reasonable heuristic from the Swift core team:
struct by default. Most data in your app is a value (a coordinate, a user, an event, a configuration). Value semantics make code predictable.class when you need reference semantics or identity. Some examples: a coordinator object that lives for a while and accumulates state, a network session, an actor (technically distinct, see chapter 32), or when you must inherit from a class (e.g., bridging to ObjC frameworks).class when the object is genuinely shared mutable state. A logger, a cache.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.
mutating methodsStructs 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 classesMark 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 membersstruct 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.
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.
Properties associate values with types. Swift distinguishes between stored and computed properties, supports observers, lazy initialization, and (in modern Swift) property wrappers.
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.
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 }
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.
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:
lazy requires var because the property's value changes (from "uncomputed" to "computed").lazy is not thread-safe. If multiple threads access an uninitialized lazy property simultaneously, the closure could run more than once.self already initialized, so you can reference other properties — unlike default values for non-lazy 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
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.
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.
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())"
}
}
selfInside 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)")
}
}
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.
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 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.
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.
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
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.
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.
This is the core Swift guarantee:
init. The compiler checks this.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.
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.
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.
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.
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.
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.
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.
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.
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)"
}
}
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.
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.
This is where inheritance gets intricate. Swift's two-phase init rule applies to inheritance with strict ordering:
super.init() to let the superclass initialize its properties.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.
Classes have two flavors of init:
convenience keyword.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 initializersclass 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.
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.
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.
protocol Greetable {
var name: String { get }
func greet() -> String
}
The requirements:
name is a read-only property of type String. The get keyword expresses that.greet() is a method that returns a String.Note that we didn't write any implementation. That's the whole point — protocols specify what, not how.
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.
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 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.
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).
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 protocolsSelf (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.
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.
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, ComparableThese 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
}
CustomStringConvertibleOverride 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.
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 cannot add: stored properties (no place to put them), or override existing implementations.
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.
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.).
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.
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.
whereYou 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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."
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)
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.
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 clausesFor 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:
A == B)T: Protocol)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())
}
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.
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.
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.
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.
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.
You have several options:
do/catchdo {
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 optionallet 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 errorlet 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.
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.
catch patternsPatterns 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)
}
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.
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 cleanupWe 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 typeThe 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.
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.
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 checkclass 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 castWhen 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 castFor 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 castSame 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.
switchPattern 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 AnyObjectAny: any value, of any type — including value types.AnyObject: any class instance.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).
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.
Swift has five access levels, controlling visibility of types, properties, methods, and other declarations. Default is internal, which is fine 90% of the time.
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 |
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 fileprivateprivate 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 publicBoth 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.
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.
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.
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.
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 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.
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.
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.
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 gotchasA 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.
weak.self, use [weak self] and guard let self else { return }.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.
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.
A property wrapper is a type with:
wrappedValue property — what the surface property reads/writes.wrappedValue (so you can apply default values).projectedValue — the "$prefix" view of the wrapper.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.
@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]
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.
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.
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.
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.
@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.
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.
@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.
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.
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 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.
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.
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.
somesome 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:
IntProducer or StringProducer.p.produce() and the compiler knows the result type — even though the type isn't named at the call site.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.
anyany 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.
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.
any is necessary — and how to use itSuppose 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.
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.
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.
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.
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).
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"
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.
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.
sorted(by: \.age) (in newer APIs).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)
\.selfA 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.
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.
switch casesif case and guard casefor caselet/varcatch clauseslet (_, year) = ("Ada", 1815)
print(year) // 1815
_ matches anything without binding. We've seen it ignoring tuple components and switch values.
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.
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.
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 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.
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
}
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")
}
}
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 caseSometimes 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 caseFilter 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
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.
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.
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.
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)
}
}
async letYou 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 pointConceptually, when you await, your function:
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.
await: the compiler will tell you. Always.try await matters; ignoring try is a compiler error.async let or a task group, don't sequence them.Task internally.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.
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.
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.
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(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.
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.
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:
addTask starts a child task that runs concurrently.for await yields each result as it completes (in completion order, not submission order).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 groupsasync let is for a fixed, small set of concurrent operations known at compile time.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.sleepTask.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))
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.
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.
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.
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.
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.
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.
nonisolatedSometimes 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 actorsSome 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.
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.
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.
Sendable meansSendable is a marker protocol — it has no requirements. Conformance is a promise about thread safety:
Sendable stored properties are automatically Sendable.actor types are always implicitly Sendable (they enforce isolation).final classes with only immutable let properties of Sendable types can conform.@unchecked Sendable and manually guarantee safety with locks.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 closuresA @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.
The compiler synthesizes Sendable conformance for:
Sendablelet Sendable properties (you must write the conformance yourself, but the compiler checks it)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 SendableThe 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.
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:
final + immutable, or convert to a struct, or wrap in an actor.@Sendable closures. Fix: capture by value ([value = self.value]), or extract to a let.var and a non-Sendable type are flagged. Fix: make them let, mark them @MainActor, or wrap in an actor.// 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 }
}
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.
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.
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.
AsyncSequence protocolAsyncSequence 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 }
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 builderAsyncStream 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:
yield(value) — produce a valuefinish() — end the stream cleanlyonTermination = { ... } — called when the consumer stops iterating, so you can clean up (cancel timers, remove observers, close files)AsyncThrowingStream is the same but lets you finish(throwing: error) to terminate with an error the consumer will see via try await.
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).
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.
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.
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.
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.
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:
@attached(member))@attached(extension))@attached(body))@attached(accessor))@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.
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:
#Predicate { ... } (Foundation) — builds a Predicate from a closure-like syntax for use in SwiftData/Core Data queries.@Observable (Observation) — replaces ObservableObject + @Published.@Model (SwiftData) — turns a class into a persistent SwiftData model.#expect(...) and @Test (Swift Testing) — the new testing framework.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.
Implementing a macro requires:
.macro that depends on swift-syntax.ExpressionMacro, MemberMacro, AccessorMacro, etc.).#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.
Use one when:
Skip macros when:
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.
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.
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.
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
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.
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 @dynamicCallableThese 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.
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.
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 resolutionWhen 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.
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.
You've covered the language. Real fluency comes from using it. Some directions worth your time:
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).
The official site at swift.org hosts:
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.
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.
Reading well-written Swift teaches you idioms no tutorial can. Some good reading:
swift-collections (Apple) — OrderedSet, OrderedDictionary, Deque, Heap. Beautifully written generic code.swift-algorithms (Apple) — sliding windows, chunks, permutations as lazy collections. A masterclass in Sequence design.swift-async-algorithms (Apple) — merge, zip, combineLatest, debounce, throttle for AsyncSequence.swift-syntax — the parser and tree the compiler uses. Required if you ever write a macro.pointfreeco/swift-composable-architecture — a state-management library built around effects and reducers; controversial but instructive.apple/swift-nio — the event-loop networking library underneath every server framework.The fastest way to internalize the material in this guide:
swift package init --type executable. Parse arguments, read files, do something useful.Optional, your own Result, your own simple Array. You'll find sharp corners you skimmed past.AsyncStream.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.
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.