Control Flow

A program becomes useful when it makes decisions and repeats work. Swift provides if and guard for branching, switch for multi-way matching, and four loop forms — with compiler-enforced exhaustiveness that eliminates whole classes of bugs.

This lesson covers each construct, the transfer statements that steer loops, and how to combine conditions with optional binding so the happy path stays readable.

Conditionals

Every condition in Swift must produce a real Bool. There is no automatic truthiness, so if someInt or if someString is a type error — you must say exactly what you mean.

if / else if / else

The classic three-part branch. Braces are mandatory, and parentheses around the condition are optional and normally omitted.

let temperature = 28

if temperature > 30 {
    print("hot")            // only the first matching branch runs
} else if temperature >= 20 {
    print("pleasant")       // prints: pleasant
} else {
    print("cold")
}

// Since Swift 5.9 an if can also be used as an expression that yields a value:
let label = if temperature > 25 { "summer" } else { "other" }
print(label)                // prints: summer

Early Exit with guard

guard states a requirement and bails out when it fails. Because the compiler forces an exit from the enclosing scope, everything after the guard is guaranteed valid — which keeps the happy path flat instead of deeply nested.

func greet(name: String?) {
    guard let name else {            // must succeed to continue
        print("no name given")
        return                       // guard requires leaving the scope
    }
    // `name` is a non-optional String from here on
    print("Hello, \(name)!")
}

greet(name: "Ada")                   // prints: Hello, Ada!
greet(name: nil)                     // prints: no name given

Multi-Way Branching

Swift's switch is far more capable than its C ancestor: it matches patterns rather than only constants, supports ranges and tuples, and never falls through to the next case by accident.

switch Statements

Cases are tested top to bottom and the first match runs alone. The compiler demands exhaustiveness, so either every possible value is covered or a default must be present.

let code = 404

switch code {
case 200:
    print("OK")
case 301, 302:
    print("Redirect")              // several values may share one case
case 400...499:
    print("Client error")          // prints: Client error
case 500...:
    print("Server error")          // open-ended range
default:
    print("Unknown")               // required: Int cannot be fully enumerated
}

Pattern Matching & where

A case can destructure tuples, ignore parts with _, bind matched pieces with let, and filter with a where clause. Read top to bottom, the first matching pattern wins — so order the specific cases first.

let point = (x: 3, y: 0)

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

fallthrough & Exhaustiveness

When the next case genuinely should run too, fallthrough makes that explicit. Enums without a raw value must list every case, which turns "forgot a case" from a runtime bug into a compile error.

let grade = "B"

switch grade {
case "A":
    print("excellent")
case "B":
    print("good")
    fallthrough                  // continue into the next case body too
case "C":
    print("passed")              // prints: good, then passed
default:
    print("invalid")
}

// Like if, switch can produce a value (Swift 5.9+):
let points = switch grade {
case "A": 4
case "B": 3
default: 0
}
print(points)                    // prints: 3

Loops

Swift offers two loop families: for-in for walking a sequence or range, and while / repeat-while for repetition driven by a condition. Choose for-in whenever the number of iterations is already known — it cannot run forever.

for-in Loops

for-in works on anything conforming to Sequence: ranges, arrays, dictionaries (as key/value pairs), strings, and stride sequences for a custom step.

for i in 1...3 { print(i) }                  // prints: 1 2 3

let fruits = ["apple", "pear"]
for fruit in fruits { print(fruit) }         // element only

for (index, fruit) in fruits.enumerated() {
    print(index, fruit)                      // 0 apple, then 1 pear
}

let ages = ["Ada": 36, "Linus": 54]
for (name, age) in ages { print(name, age) } // dictionary order is not defined

// stride gives control over the step and the direction
for even in stride(from: 0, through: 10, by: 2) { print(even) }  // 0 2 4 6 8 10
for down in stride(from: 5, to: 0, by: -1) { print(down) }       // 5 4 3 2 1

while & repeat-while

while tests its condition before each pass, so the body may never run. repeat-while tests after, which guarantees at least one execution — the right choice for retry and input loops.

var countdown = 3
while countdown > 0 {          // may run zero times
    print(countdown)
    countdown -= 1
}                              // prints: 3 2 1

var attempts = 0
repeat {
    attempts += 1              // always runs at least once
    print("attempt \(attempts)")
} while attempts < 2           // prints: attempt 1, then attempt 2

break, continue & Labels

break leaves a loop immediately and continue jumps to the next iteration. With nested loops, a label tells both statements which loop you mean — without it they always act on the innermost one.

for n in 1...10 {
    if n % 2 == 0 { continue }   // skip the even numbers
    if n > 5 { break }           // stop as soon as n passes 5
    print(n)                     // prints: 1 3 5
}

outer: for i in 1...3 {
    for j in 1...3 {
        if j == 2 { continue outer }   // restart the OUTER loop
        print(i, j)                     // prints: 1 1, 2 1, 3 1
    }
}

Combining Control Flow

Real code mixes branching, looping, and optional handling in the same function. Two patterns keep that mixture readable: bind optionals in the condition itself, and structure the function so the failure cases exit early.

Optional Binding in Conditions

An if let or guard let unwraps an optional only inside the branch that proved it non-nil. Several bindings and extra conditions can be comma-separated, and all of them must succeed.

let input: String? = "42"

if let text = input, let number = Int(text) {
    print(number * 2)             // prints: 84 — both unwrapped, then used
} else {
    print("not a number")
}

// Commas chain bindings and plain conditions into one sentence
let raw: String? = "swift"
if let raw, raw.count > 3, raw.hasPrefix("sw") {
    print("looks like swift")     // prints: looks like swift
}

Structuring Readable Logic

Flat beats nested. Guard the preconditions first so the rest of the function runs unindented, replace long if/else if chains with switch, and name intermediate conditions so the code states its intent.

struct User {
    let name: String
    let age: Int
    let isActive: Bool
}

func describe(_ user: User) -> String {
    guard user.isActive else { return "inactive" }   // exit early
    guard user.age >= 18 else { return "minor" }

    // A named Bool documents the intent better than a raw expression
    let isVeteran = user.age >= 65
    return isVeteran ? "active senior" : "active member"
}

print(describe(User(name: "Ada", age: 70, isActive: true)))   // active senior
print(describe(User(name: "Bo", age: 15, isActive: true)))    // minor

Common Pitfalls

Off-by-One & Ranges

Indexing a collection with 0...count runs one step too far and traps. The half-open 0..<count form ends exactly at the last valid index, which is why it is the idiomatic choice for positional loops.

let items = ["a", "b", "c"]

for i in 0..<items.count { print(items[i]) }   // 0, 1, 2 — never equals count
print(items[items.count - 1])                  // "c" — the last element

// 0...items.count would crash on its final pass:
// for i in 0...items.count { print(items[i]) }   // ❌ index out of range

Infinite Loops

A while loop whose condition never becomes false hangs the program. Make sure the body changes the condition, and give any deliberate "loop forever" an explicit exit.

var n = 0
while n < 3 {
    print(n)
    n += 1                     // this mutation is what ends the loop
}                              // prints: 0 1 2

// A deliberate infinite loop still needs a way out
var ticks = 0
while true {
    ticks += 1
    if ticks == 2 { break }    // without this the loop never ends
}
print(ticks)                   // prints: 2

You can now direct the flow of any program with branches, loops, and early exits. Next, package that logic into reusable units in Functions.