Each stage builds on the previous one. Work through them in order and you’ll have a solid Swift foundation ready for real app development.
Learn Swift Stage 1: Swift Introduction
Every Swift developer started exactly where you are right now. These five lessons will take you from “I’ve never written a line of code” to writing real Swift programs you can run yourself.
You don’t need any prior coding knowledge to follow along. You don’t need a computer science degree. You just need Xcode (Apple’s free app for Mac) or Swift Playgrounds (free on iPad or Mac). Every lesson is designed to fit in about 30 minutes and ends with a small challenge so you can test what you just learned.
By the end of Stage 1, you’ll understand how Swift programs work, how to store information in your code, and how to make your programs display different kinds of output. That might sound simple, but it’s the actual foundation everything else is built on.
What is Swift?
Swift is a programming language made by Apple. A programming language is simply a way to give instructions to a computer. You write those instructions in a specific format the computer understands, and the computer follows them.
Think of it like a recipe. A recipe is written in a specific format — ingredients first, then steps in order — because that’s how a cook expects to read it. Swift is the same idea: a specific format for writing instructions that a computer can read and follow.
Apple uses Swift to build iOS apps, Mac apps, Apple Watch apps, and more. When you learn Swift, you’re learning the same language that Apple’s own engineers use every day.
Where do you actually write Swift?
You have two good options as a beginner, and both are free:
For the rest of Stage 1, instructions will say “open a Playground.” In Xcode: go to File → New → Playground, choose “Blank,” and you’re ready. In Swift Playgrounds on iPad: tap the + button and create a new blank playground.
What does a Swift program look like?
Here’s the simplest possible Swift program. This is real, working code:
print("Hello, world!")That one line tells the computer: display the text “Hello, world!” on the screen. When you type it into a Playground and run it, that’s exactly what you’ll see.
| Part | What it means |
|---|---|
print | This is a built-in Swift instruction that means “show something on the screen.” It’s called a function, but don’t worry about that word yet — just know that print displays output. |
( ) | The parentheses are where you put whatever you want to display. Everything inside the parentheses gets shown. |
"Hello, world!" | The quotation marks tell Swift “this is text, not code.” The words between the quotes are displayed exactly as written. |
How Swift runs your code
When you hit run, Swift reads your code from top to bottom, one line at a time, and executes each instruction in order. If you have three lines of code, it runs line 1 first, then line 2, then line 3. This is important to remember as your programs get longer.
print(Hello) instead of print("Hello"), Swift will show an error because it tries to look up something called Hello in your code rather than displaying the word as text. Quotation marks = text. No quotation marks = something defined in your code.You’ll need three separate
print() statements, each on its own line. Try it without looking anything up first.Go deeper with AI
You already used print() in the last lesson. Now let’s understand it properly. It’s the most important tool you have as a beginner because it lets you see what’s happening inside your code.
print() does more than show text
You can print more than just words. You can print numbers, the result of a calculation, or even multiple things at once. Try all of these in a Playground:
// Printing text (words need quotation marks)
print("I am learning Swift")
// Printing a number (numbers don't need quotation marks)
print(42)
// Printing the result of a calculation
print(10 + 5)
// Printing multiple things separated by a comma
print("The answer is", 42)
// Printing a blank line (empty parentheses)
print()| What you wrote | What Swift does with it |
|---|---|
print("I am learning Swift") | Displays the text exactly as written, because it’s in quotation marks. |
print(42) | Displays the number 42. No quotes needed for numbers. |
print(10 + 5) | Swift calculates 10 + 5 first, gets 15, and displays that result. |
print("The answer is", 42) | When you separate items with a comma, Swift prints them both on the same line with a space between them. |
print() | Prints nothing, which creates a blank line. Useful for spacing output. |
Comments: notes in your code
You saw those lines starting with // in the code above. Those are called comments. They’re notes you write for yourself (or other developers) to explain what the code does. Swift completely ignores anything after // on a line.
// This whole line is a comment — Swift ignores it entirely
print("This line runs") // This part is a comment, but print() still runsGet into the habit of adding comments to your code. Beginners often skip them, but they’re genuinely helpful when you come back to code you wrote a week ago and can’t remember what it was doing.
Basic math you can use inside print()
Swift understands the standard math operators you already know:
print(10 + 3) // Addition → 13
print(10 - 3) // Subtraction → 7
print(10 * 3) // Multiplication → 30
print(10 / 3) // Division → 3 (not 3.33... — we'll explain this soon)
print(10 % 3) // Remainder → 1 (10 divided by 3 leaves remainder 1)So far your code has used values directly — you typed the number 42, you typed “Hello, world!”. But real programs need to store information and use it in multiple places. That’s what variables and constants are for.
Think of it like a labeled box
Imagine a cardboard box with a label on it. You put something inside the box, and whenever you need it, you refer to it by the label instead of by what’s inside. Variables and constants work exactly the same way. You give a piece of information a name, and you use that name to refer to it throughout your code.
var: a box whose contents can change
// Create a variable called "score" and put the number 0 inside it
var score = 0
// Use it — print() now uses the value stored in "score"
print(score) // 0
// Change the value stored in "score"
score = 10
print(score) // 10
// You can do math with a variable
score = score + 5
print(score) // 15| Line | What it does |
|---|---|
var score = 0 | Creates a new variable named score and gives it the value 0. The word var is how you tell Swift “I’m creating a variable.” |
print(score) | Instead of printing a literal value, Swift looks up what’s stored in score and prints that. Notice: no quotation marks around score, because score is a variable, not text. |
score = 10 | Replaces what’s in the box. The old value (0) is gone, the new value (10) is stored. |
score = score + 5 | Read the right side first: get the current value of score (10), add 5, get 15. Then store 15 back into score. This pattern is extremely common in Swift. |
let: a box that’s sealed shut
Sometimes you have information that should never change. Like your date of birth, or the number of days in a week. For those, you use let instead of var.
// A constant — this value will never change
let daysInWeek = 7
print(daysInWeek) // 7
// This would cause an error — you can't change a constant:
// daysInWeek = 8 ← Swift will refuse to compile thisIf you try to change a constant, Swift won’t let your code run at all. It will show an error that says something like “cannot assign to value: ‘daysInWeek’ is a ‘let’ constant.” This is actually a feature — Swift is protecting you from accidentally changing something you didn’t mean to change.
let. If Swift complains that you need to change the value, switch it to var. Experienced Swift developers prefer let whenever possible because it makes code safer and easier to reason about.Naming your variables
Variable names can be almost anything, but there are a few rules and conventions to follow:
// Good names — clear and descriptive
var playerName = "Alex"
var highScore = 1500
let maxLives = 3
// Swift uses "camelCase" — no spaces, each new word starts with capital
var numberOfItemsInCart = 0
// These would cause errors:
// var 1player = "Alex" ← can't start with a number
// var player name = "Alex" ← can't have spacesplayerName or highScore. This is the standard convention — if you follow it from the start, your code will look professional and be easy to read.points and start it at 0. Then simulate a game where the player earns 10 points, then loses 3 points, then earns 7 more. After each change, print the current score. At the end, also use a constant to store the player’s name and print a final message.The output should show four different point values as the score changes, then a final line with the player name and their final score.
points = points + 10 to add points. You can also write this as points += 10 — both do the same thing.When you create a variable, Swift needs to know what kind of thing you’re storing. Is it text? A whole number? A number with a decimal? True or false? Each of those is a different data type, and Swift handles them differently.
In the last lesson, you created variables without thinking about types — Swift figured it out automatically. Let’s understand what’s actually happening.
The four most important data types
var name: String = "Aisha"
var city = "Toronto" // Swift infers this is a String
var emptyText = "" // An empty String is still a StringString is any text — words, sentences, even a single character. It always goes inside quotation marks. The name “String” comes from “a string of characters.”var age: Int = 28
var score = 1500 // Swift infers this is an Int
var temperature = -5 // Negative numbers work fineInt is short for “integer” — a whole number with no decimal point. Use it for things like scores, counts, ages, and quantities. It can be positive or negative.var price: Double = 9.99
var height = 1.75 // Swift infers this is a Double
var pi = 3.14159 // Common math constantsDouble is for numbers that have (or might have) a decimal point. Use it for prices, measurements, percentages, and anything where fractions matter. This is why 10 / 3 gave you 3 earlier — both were Ints, so Swift did integer division.var isLoggedIn: Bool = false
var hasCompletedLevel = true
var isPremiumUser = falseBool (short for “Boolean”) can only ever be true or false. It’s incredibly useful for tracking state in an app — is the user logged in? Has the player finished the level? Is this item favorited? You’ll use Booleans constantly once you start building apps.Type inference: Swift is smart
You’ve probably noticed that in most examples you don’t need to write the type explicitly. That’s because Swift can figure it out from the value you assign:
// With explicit type annotation (the part after the colon)
var name: String = "Jordan"
var score: Int = 100
// Without — Swift infers the same types automatically
var name2 = "Jordan" // Swift sees quotes → String
var score2 = 100 // Swift sees whole number → Int
var price = 4.99 // Swift sees decimal → Double
var done = true // Swift sees true/false → BoolBoth styles are correct. Beginners often leave out the type annotation since it’s less to type, and Swift handles it fine. You’ll want to add explicit types sometimes when Swift can’t infer correctly, but most of the time inference works perfectly.
Why you can’t mix types
Once a variable has a type, it keeps that type forever. You can’t store text in a variable that was created for numbers:
var score = 100 // score is now an Int
// This would cause an error:
// score = "one hundred" ← can't put a String in an Int variable
// This is fine — still an Int:
score = 200var or let. Then print all four values.You know how to print text. You know how to print variables. Now it’s time to combine them — put the value of a variable right inside a sentence of text. This technique is called string interpolation, and once you learn it, you’ll use it in almost every Swift program you write.
The problem it solves
Imagine you want to print “Your score is 150” where 150 comes from a variable. Without string interpolation, you’d have to do something awkward like this:
var score = 150
// Awkward way — using a comma to join things
print("Your score is", score) // Your score is 150 ✓ but limitedThe comma approach works but it’s clunky — you can’t control spacing well, and it gets messy with complex sentences. String interpolation gives you a much cleaner way.
String interpolation: \( ) inside a string
To drop a variable’s value inside a string, you write a backslash followed by parentheses, with the variable name inside: \(variableName)
var score = 150
var playerName = "Maya"
// Use \( ) to drop the variable right into the string
print("Your score is \(score)")
print("Great job, \(playerName)!")
print("\(playerName) has \(score) points.")| Part | What happens |
|---|---|
"Your score is \(score)" | Swift sees the \(score) part, looks up the current value of score, and replaces \(score) with that value in the final output. |
\(playerName) | Works the same way for Strings — the variable’s value gets inserted right where \(playerName) appears. |
| The quotes around the whole thing | The entire thing is still a String — the quotation marks wrap everything, and \( ) sections inside get substituted with values. |
You can put expressions inside \( )
It’s not just variable names you can put inside the parentheses. You can put any expression — a calculation, a combination, anything Swift can evaluate to a value:
var items = 3
var priceEach = 4.99
// A calculation inside the interpolation
print("Total: $\(items * priceEach)")
// Combining two strings
var firstName = "Jamie"
var lastName = "Chen"
print("Full name: \(firstName) \(lastName)")
// Using it with Bool
var isPremium = true
print("Premium account: \(isPremium)")\( ) with Strings, Ints, Doubles, and Bools — Swift automatically converts whatever’s inside to text so it can be displayed. This is one of the most convenient things about Swift.A common gotcha: the backslash
\(variable). Not a forward slash, not just parentheses. The backslash tells Swift “this is not regular text — look up a value here.” If you see your output showing \(score) literally instead of the variable’s value, check that you used a backslash and not a forward slash.Quick reference: String interpolation patterns
| Syntax | What It Does |
|---|---|
| “Hello \(name)” | Inserts the value of the variable name into the string |
| “\(a + b)” | Calculates a + b and inserts the result |
| “\(first) \(last)” | Inserts two variables with a space between |
| “$\(price)” | You can add characters right before or after \( ) — the $ stays literal |
| “\(isActive)” | Works with Bool — inserts “true” or “false” |
Stage 1 Recap: Swift Introduction
You’ve finished Stage 1 of learning Swift. Here’s what you now know how to do:
- Write and run Swift code in Xcode Playgrounds or Swift Playgrounds
- Use
print()to display text, numbers, calculations, and multiple values - Add comments with
//to explain your code - Create variables with
var(changeable) and constants withlet(fixed) - Work with four core data types:
String,Int,Double, andBool - Use string interpolation with
\( )to embed variable values inside text
These are the absolute building blocks of the Swift programming language. Everything you’ll learn from here — conditions, loops, functions, structs — builds on exactly what you now know.
Stage 2 is where things get interesting. You’ll teach your programs to make decisions with if/else and switch statements. That’s when your code stops being a simple sequence of steps and starts responding to different situations — which is what all real apps do.
Learn Swift Stage 2: Making Decisions
Every app you’ve ever used is constantly asking questions — and Stage 2 is where you learn how to ask them in code.
All you need to follow along is Xcode and a fresh Swift Playground. No new setup required. Each of the five lessons in this stage takes about 30 minutes and ends with a hands-on challenge you solve yourself. The challenge is where the real learning happens, so don’t skip it.
By the end of Stage 2, you’ll be able to compare values, branch your code based on conditions, combine multiple conditions in a single check, use switch statements to handle multiple cases cleanly, and write quick one-line decisions with the ternary operator. These are the tools that turn a passive list of instructions into a program that actually thinks.
Before your code can make a decision, it needs a way to ask a question. Is this number bigger than that one? Are these two words the same? Is the score high enough to win? These are all comparisons, and they’re the foundation of every decision your app will ever make.
Think about a bouncer at a venue checking IDs. Their job is simple: look at the age on the ID, compare it to the minimum age, and decide yes or no. Your code does the exact same thing. It compares two values and gets back a yes or a no.
In Swift, that yes-or-no answer is a Bool — either true or false. You already know what a Bool is from Stage 1. Now you’ll see where they actually come from.
let myAge = 20 // Store an age as an Int
let minimumAge = 18 // The age requirement to compare against
let isOldEnough = myAge >= minimumAge // Is 20 greater than or equal to 18?
print(isOldEnough) // true
let isExactMatch = myAge == minimumAge // Is 20 exactly equal to 18?
print(isExactMatch) // false
let isTooYoung = myAge < minimumAge // Is 20 less than 18?
print(isTooYoung) // false| Line | What it does |
|---|---|
myAge >= minimumAge | Checks if 20 is greater than or equal to 18. It is, so the result is true. That Bool gets stored in isOldEnough. |
myAge == minimumAge | Checks if 20 is exactly 18. It isn’t, so the result is false. |
myAge < minimumAge | Checks if 20 is less than 18. It isn’t, so the result is false. |
let isOldEnough = ... | The result of any comparison is just a Bool. You can store it in a constant exactly like any other value. |
== (two equals signs) checks whether two values are equal. A single = assigns a value. Using one equals sign when you meant two is one of the most frequent beginner bugs in any programming language. Swift will give you a compiler error, which actually helps catch it early.All Six Comparison Operators
print(5 == 5) // true
print(5 == 6) // false
print("hello" == "hello") // true — works with strings too
print("Hello" == "hello") // false — Swift is case-sensitive"Hello" and "hello" are not considered equal.print(5 != 6) // true — 5 and 6 are different
print("cat" != "dog") // true — they're not the same
print(10 != 10) // false — they're equal, so "not equal" is false! in != means “not”. So != reads as “not equal”. Useful for checking if something has changed or if a field is not empty.print(3 < 10) // true — 3 is less than 10
print(10 < 3) // false — 10 is not less than 3
print(5 < 5) // false — equal values don't count as "less than"false — equal is not less than.print(10 > 3) // true — 10 is greater than 3
print(3 > 10) // false — 3 is not greater than 10
print(5 > 5) // false — equal values don't count as "greater than"print(3 <= 5) // true — 3 is less than 5
print(5 <= 5) // true — equal counts here
print(6 <= 5) // false — 6 is neither less than nor equal to 5print(5 >= 5) // true — exactly equal counts
print(6 >= 5) // true — 6 is greater than 5
print(4 >= 5) // false — 4 is neither greater than nor equal to 53 < 10, the arrow opens toward 3 (the smaller one). In 10 > 3, it opens toward 3 again. Once that clicks, you’ll never mix them up.Quick Reference
| Operator | What It Checks |
|---|---|
| == | Are the two values exactly equal? |
| != | Are the two values different? |
| < | Is the left value strictly less than the right? |
| > | Is the left value strictly greater than the right? |
| <= | Is the left value less than or equal to the right? |
| >= | Is the left value greater than or equal to the right? |
Create a constant called password and set it to any string you like. Then create a constant called passwordLength that stores how many characters are in the password — strings have a .count property that gives you this number. Finally, create a Bool constant called isStrongEnough that is true when the password is at least 8 characters long. Print all three values.
myString.count. Which of the six operators means “at least”?Now that you can compare values and get a true or false result, you need a way to actually do something based on that result. That’s what an if statement does. It lets your code take different paths depending on a condition.
Think about a traffic light. When the light is green, cars go. When it’s yellow, they slow down. When it’s red, they stop. Each colour triggers a different action. An if statement works the same way: “if this condition is true, do this thing. Otherwise, do something else.”
This is one of the most important ideas in all of programming. Every app you’ve ever used relies on if statements constantly — checking if you’re logged in, if a form is filled out, if a score is high enough. Once you understand this, you can start writing code that actually responds to things.
let score = 75 // A student's test score out of 100
if score >= 90 { // Check if score is 90 or above
print("Grade: A") // Only runs if the condition above is true
} else if score >= 80 { // Only checked if the first condition was false
print("Grade: B") // Runs if score is 80 to 89
} else if score >= 70 { // Only checked if both previous conditions were false
print("Grade: C") // 75 is in range 70–79 — this runs
} else { // Runs if none of the conditions above were true
print("Grade: F") // Covers anything below 70
}| Part | What it does |
|---|---|
if score >= 90 { } | The first condition Swift checks. If it’s true, the code inside the curly braces runs and Swift skips everything else. |
else if score >= 80 { } | Only evaluated if the first condition was false. You can chain as many else if blocks as you need. |
else { } | The safety net. Runs when no other condition matched. Optional, but good practice so every possible value is handled. |
The curly braces { } | Everything between { and } belongs to that condition. This group of lines is called a “block”. |
score >= 70 matched, the else block was skipped entirely. Exactly one branch ever runs — even if multiple conditions could technically be satisfied.The Three Forms of an if Statement
let isRaining = true
if isRaining { // A Bool is already true or false — no == needed
print("Don't forget your umbrella!") // Only runs when isRaining is true
}
// If isRaining were false, nothing prints — and that's totally fineelse required if you only want something to happen when the condition is true. Notice you don’t write isRaining == true — a Bool can be used directly as the condition.let isLoggedIn = false
if isLoggedIn {
print("Welcome back!") // Runs when isLoggedIn is true
} else {
print("Please sign in.") // Runs when isLoggedIn is false — this one prints
}let hour = 14 // 2pm in 24-hour time
if hour < 12 {
print("Good morning!") // Hours 0 through 11
} else if hour < 18 {
print("Good afternoon!") // Hours 12 through 17 — hour 14 lands here
} else {
print("Good evening!") // Hour 18 and above
}else if branches as you need. Swift checks them top to bottom and stops the moment one matches.let hasTicket = true
let age = 16
if hasTicket { // First check: do they have a ticket?
if age >= 18 { // Second check — only runs if they have a ticket
print("Enjoy the show!")
} else {
print("Sorry, adults only.") // Has ticket but is under 18 — this prints
}
} else {
print("No ticket, no entry.")
}} belongs to which block. In Lesson 2.3 you’ll see a cleaner way to handle situations that need multiple checks at once.Create a variable called temperatureC representing today’s temperature in Celsius. Write an if / else if / else chain that prints different advice based on the temperature: below 0 prints "Freezing! Bundle up.", 0 to 14 prints "Cold. Grab a jacket.", 15 to 25 prints "Nice weather today!", and anything above 25 prints "Hot! Stay hydrated.". Test by changing the value and running again.
So far you’ve been checking one condition at a time. But real decisions usually involve more than one thing. “You can watch this movie if you’re 18 or older AND you have a subscription.” “You get a discount if it’s your birthday OR you’re a member.” These are multiple conditions combined into one decision.
Swift gives you three logical operators for exactly this. && means AND, || means OR, and ! means NOT. They let you combine or flip Bool values so you can express complex conditions without nesting a pile of if statements inside each other.
The good news is that these operators work exactly like the English words “and”, “or”, and “not”. Once you make that connection, they become very natural to read and write.
let age = 20 // The user's age
let hasSubscription = true // Whether they have an active subscription
let isBirthday = false // Whether today is their birthday
// AND: both conditions must be true for the whole thing to be true
if age >= 18 && hasSubscription {
print("Enjoy the content!") // Runs — both conditions are true
}
// OR: at least one condition must be true
if hasSubscription || isBirthday {
print("You get a bonus reward!") // Runs — hasSubscription is true (birthday doesn't matter)
}
// NOT: flip a Bool from true to false, or false to true
if !isBirthday {
print("No birthday bonus today.") // Runs — !false is true
}| Operator | What it means |
|---|---|
&& (AND) | Both sides must be true for the result to be true. If either side is false, the whole thing is false. |
|| (OR) | At least one side must be true. The whole thing is only false when both sides are false. |
! (NOT) | Flips a Bool. !true becomes false. !false becomes true. Place it directly in front of a Bool, no space. |
let hasID = true
let isOnGuestList = false
if hasID && isOnGuestList { // Both must be true — only hasID is true
print("Welcome in!")
} else {
print("Sorry, you need both.") // This runs — isOnGuestList is false
}&&, one false value is enough to make the entire condition false. This replaces the nested if pattern from Lesson 2.2 — cleaner and reads more like natural English.let isStudent = false
let isSenior = true
if isStudent || isSenior { // At least one must be true — isSenior is
print("Discounted ticket!") // This runs
} else {
print("Full price.")
}||, the condition only fails when every single part is false. Even if ten things are false, one true one makes the whole condition true.var isGameOver = false
if !isGameOver { // Read aloud: "if NOT isGameOver"
print("Keep playing!") // !false is true — this runs
}! directly in front of a Bool with no space: !isGameOver not ! isGameOver. A space causes a compiler error. Often cleaner than writing isGameOver == false.let isWeekend = true
let hasChores = false
let friendsAvailable = true
// Can hang out if: it's the weekend AND (no chores OR friends are free)
if isWeekend && (!hasChores || friendsAvailable) {
print("Let's go!") // Runs — all conditions satisfied
}&& has higher priority than || — parentheses make your intent explicit and your code easier to read.!isGameOver, not ! isGameOver. The ! must be attached directly to the value it’s flipping. A space between them will produce a compiler error.let canEnter = isOldEnough && hasTicket — then just write if canEnter. Same logic, much easier to understand at a glance.A theme park ride has two rules: riders must be at least 120cm tall AND must not have a fear of heights. Create variables for heightCm (an Int) and fearOfHeights (a Bool). Write an if/else that prints "Enjoy the ride!" when both requirements are met. If not, print a specific message explaining which requirement wasn’t met. Try to give a different message for each failure scenario.
&& and ! together. For failure messages, think about separate else if branches checking each condition individually.You’ve seen how if / else if / else handles multiple conditions. But when you’re checking one specific value against a list of possible options, a switch statement is often a much cleaner choice.
Think of a vending machine. You press A1 and get chips. You press B2 and get a drink. You press C3 and get chocolate. The machine doesn’t work through a long chain of “was it A1? No. Was it B2? No. Was it C3?” — it just checks the one value you gave it and jumps straight to the matching slot. That’s exactly what a switch statement does.
Swift’s switch statements are also more powerful than in many other languages. They can match ranges of numbers, multiple values at once, and they require you to handle every possible case — which catches bugs before they happen.
let day = "Monday" // The value we want to check
switch day { // Start the switch with the value to examine
case "Monday":
print("Back to the grind.") // "Monday" matches — this runs
case "Friday":
print("Almost the weekend!")
case "Saturday", "Sunday": // Two values can share one case
print("Enjoy your weekend!")
default: // Handles any value not listed above
print("Just another weekday.")
}| Part | What it does |
|---|---|
switch day | The value being examined. Swift compares it against each case below in order. |
case "Monday": | If day equals “Monday”, this block runs. No curly braces — the next case or default ends this block automatically. |
case "Saturday", "Sunday": | Separate multiple matching values with commas to give them the same response. |
default: | The catch-all, like else. Runs when no case matched. Required unless every possible value is already covered. |
break statement to stop execution. Swift ends each case automatically. Fewer accidental bugs, less code to write.More Powerful Ways to Use switch
let score = 83
switch score {
case 90...100: // ... means "90 up to and including 100"
print("Grade: A")
case 80...89:
print("Grade: B") // 83 falls here — this runs
case 70...79:
print("Grade: C")
default:
print("Grade: F")
}... creates a closed range that includes both endpoints. This is where switch shines over a long if / else if chain — ranges make the intent much clearer at a glance.let month = 2 // February
switch month {
case 1, 2, 3: // January, February, or March
print("Winter / Early Spring") // February is 2 — this runs
case 4, 5, 6:
print("Spring / Early Summer")
case 7, 8, 9:
print("Summer / Early Autumn")
case 10, 11, 12:
print("Autumn / Winter")
default:
print("Invalid month number")
}let temperature = -5
switch temperature {
case let t where t < 0: // Match any negative number, name it "t"
print("Freezing: \(t) degrees") // Runs — -5 is less than 0. "t" holds -5
case let t where t > 30:
print("Very hot: \(t) degrees")
default:
print("Temperature: \(temperature)")
}where keyword adds a filter to a case. let t gives the matched value a temporary name you can use inside that case. This is an intermediate pattern — you’ll see it more as you build real apps.switch when you’re checking one value against a list of specific options. Reach for if when conditions involve different variables or more complex expressions. Either can work — pick whichever reads more clearly.Create a constant called planet and set it to the name of any planet in our solar system as a String. Write a switch statement that prints a gravity fact for at least five planets — something like "Mars: 0.38x Earth gravity". Include a default case that prints "Unknown planet". Group any two planets with similar gravity into a single case using a comma.
case "Mercury", "Mars":.You’ve been writing full if/else blocks to make decisions. Now meet the compact version: the ternary operator. It lets you express a simple two-option decision in a single line of code.
Think of it like a question with exactly two possible answers. “Is it raining? If yes — umbrella. If no — no umbrella.” The ternary operator is that structure compressed into one line: condition ? valueIfTrue : valueIfFalse. Ask a question, give the yes answer, give the no answer.
The word “ternary” just means it takes three parts: the question, the yes answer, and the no answer. Don’t let the name throw you off. Once you see the pattern a couple of times, it becomes second nature.
let temperature = 28 // Current temperature in Celsius
// The long way with if/else:
var advice: String
if temperature > 25 {
advice = "Wear sunscreen!"
} else {
advice = "Bring a jacket."
}
print(advice) // "Wear sunscreen!"
// The short way with the ternary operator — same result, one line:
let quickAdvice = temperature > 25 ? "Wear sunscreen!" : "Bring a jacket."
print(quickAdvice) // "Wear sunscreen!"| Part | What it does |
|---|---|
temperature > 25 | The condition. Evaluated first — produces true or false. |
? | Separates the condition from the two possible values. Read it aloud as “then”. |
"Wear sunscreen!" | The value produced when the condition is true. Comes immediately after the ?. |
: | Separates the true value from the false value. Read it aloud as “otherwise”. |
"Bring a jacket." | The value produced when the condition is false. |
temperature > 25 ? "Hot" : "Cold" reads as: “Is the temperature greater than 25? If yes — ‘Hot’. If no — ‘Cold’.” That mental model will carry you far.Common Ways to Use the Ternary Operator
let isLoggedIn = true
let greeting = isLoggedIn ? "Welcome back!" : "Please sign in."
print(greeting) // "Welcome back!"let constant. You can’t assign a constant inside an if/else block easily — the ternary handles it cleanly in one expression, keeping the result immutable.let itemCount = 1
// Ternary inside \( ) picks the right word based on the count
print("You have \(itemCount) \(itemCount == 1 ? "item" : "items") in your cart.")
// "You have 1 item in your cart." (not "1 items")let isMember = false
let price = 10.00
let finalPrice = isMember ? price * 0.8 : price // 20% off for members
print("Total: $\(finalPrice)") // "Total: $10.0" — not a member, full priceString on one side and an Int on the other — Swift won’t allow it.// This works but is a nightmare to read:
let score = 75
let grade = score >= 90 ? "A" : score >= 80 ? "B" : score >= 70 ? "C" : "F"
print(grade) // "C" — correct, but good luck reading that a month from now
// Use switch or if/else if instead — same result, much clearerswitch or if / else if. Readable code always beats clever code.Create a constant called testScore (an Int out of 100). Using the ternary operator, create a constant called result that equals "Pass" if the score is 50 or above and "Fail" if it’s below 50. Then write a single print call that outputs something like "Score: 72 — Result: Pass" using string interpolation. The whole solution should be exactly three lines of code.
\(result) inside your print string. Which comparison operator means “50 or above”?Stage 2 Recap: Making Decisions
You just covered five of the most fundamental ideas in all of programming. Every single app in existence uses these tools. Here’s what you now have in your toolkit:
- Lesson 2.1 — Comparison operators: Six operators (
==,!=,<,>,<=,>=) that compare two values and produce a Bool. Every decision in your app starts here. - Lesson 2.2 — if / else if / else: The core decision-making tool. Swift checks conditions top to bottom and stops at the first match. Exactly one branch runs.
- Lesson 2.3 — Logical operators:
&&(AND),||(OR), and!(NOT) let you combine and flip Bool values so you can check multiple things at once without nesting if statements inside each other. - Lesson 2.4 — switch statements: When you’re checking one value against many specific options, switch is cleaner than a chain of else-ifs. Supports exact values, comma-separated values, and number ranges — and forces you to handle every possible case.
- Lesson 2.5 — The ternary operator: A compact one-line way to express a simple two-option decision. Best for assigning values and embedding choices inside string interpolation. Use it when the logic is simple — reach for if/else when it isn’t.
If you haven’t done all five challenges yet, go back and do them now. Reading code builds familiarity. Writing code builds understanding. Those are two different things, and you need both.
In Stage 3, you’ll learn about loops — how to make Swift repeat a block of code automatically so you’re not writing the same thing over and over. You’ll also meet functions, which let you give a block of code a name and reuse it anywhere in your app. With decisions and loops both in your toolkit, the things you can build are about to grow fast.
Learn Swift Stage 3: Repeating Things
Loops are how you tell Swift to do something more than once — and they’re about to become one of the most useful tools you have.
To follow along, open a Swift Playground in Xcode. Each lesson takes about 25-30 minutes including the challenge at the end. You should have completed Stages 1 and 2 before starting here — specifically, you should already be comfortable with variables, data types, and if/else statements.
By the end of Stage 3 you will understand how to repeat code using for loops and while loops, how to control that repetition with break and continue, and how to use nested loops — plus when not to.
Imagine you had to write print("Hello!") ten times in a row. You could do it — but it would be tedious and silly. That’s exactly the kind of problem a loop solves.
A for loop runs a block of code a set number of times. Think of it like a lap counter at a running track. You decide how many laps, the runner goes, and the counter keeps track so you don’t have to.
In Swift, you tell a for loop how many times to run by giving it a range — a starting number and an ending number. Here’s the simplest version:
for i in 1...5 { // run this loop for i = 1, 2, 3, 4, 5
print("Lap \(i)") // print the current lap number using string interpolation
}| Part | What it does |
|---|---|
for | The keyword that starts a for loop. Swift sees this and knows repetition is coming. |
i | A temporary variable that holds the current count. It automatically gets a new value each time the loop runs. You can name this anything — number, count, lap — whatever makes sense. |
in | Connects the variable to the range. Read the whole thing as: “for each value of i, in the range 1 to 5…” |
1...5 | The closed range operator. It means “every number from 1 up to and including 5.” The three dots are the range operator. |
{ } | The loop body. Everything inside these curly braces runs once for each number in the range. |
print("Lap \(i)") | Uses string interpolation to print the current value of i. On the first loop i is 1, then 2, and so on. |
for i in 1...5 and then try to use i after the closing brace. That won’t work — i only exists inside the loop. Once the loop ends, it disappears.The Two Range Operators
Swift has two ways to write a range, and they differ in one important way: whether the last number is included or not.
for i in 1...10 {
print(i) // prints 1, 2, 3, 4, 5, 6, 7, 8, 9, 10
}...) creates a closed range. The loop runs for every number from 1 up to and including 10. Use this when you want to include the final value.for i in 1..<10 {
print(i) // prints 1, 2, 3, 4, 5, 6, 7, 8, 9 — stops before 10
}..<) creates a half-open range. The loop runs up to but not including the last number. This is extremely common in iOS development because lists start at index 0.for _ in 1...3 {
print("Hello!") // prints "Hello!" exactly 3 times
}_. Swift treats this as “I know there’s a value here, but I don’t need it.”// Count by 2s: 0, 2, 4, 6, 8, 10
for i in stride(from: 0, to: 11, by: 2) {
print(i)
}
// Count down: 10, 8, 6, 4, 2, 0
for i in stride(from: 10, through: 0, by: -2) {
print(i)
}stride. Use to: (excludes the end) or through: (includes the end). Use a negative by: value to count downward.i is a programming convention short for “index” or “iteration.” You’ll see it everywhere. But it’s just a regular variable name — feel free to use something more descriptive like day or score when it makes your code easier to read.Write a for loop that prints the 7 times table from 7 x 1 all the way up to 7 x 12. Each line should look like this: 7 x 1 = 7
Then try a bonus: use a stride loop to print only the even multiples of 7 (7 x 2, 7 x 4, 7 x 6, and so on up to 7 x 12).
\( ) — for example \(7 * i) gives you the result of 7 times i.AI Practice Prompts
In the last lesson you looped over a range of numbers. But loops get really powerful when you loop over a list of actual things — names, scores, colors, whatever your app is working with.
That list is called an array. Think of an array like a numbered shelf with slots. Each slot holds one value, and you can have as many slots as you want. We’ll go deep on arrays in Stage 4 — but right now you just need to know enough to loop over one.
// Create an array of names — square brackets hold the list
let names = ["Alice", "Bob", "Charlie"]
// Loop over every name in the array
for name in names {
print("Hello, \(name)!") // name holds each value in turn
}| Part | What it does |
|---|---|
let names = [...] | Creates an array called names that holds three strings. The values are separated by commas inside square brackets. We use let because we’re not changing the array here. |
for name in names | Starts a loop. On each pass, the variable name is automatically set to the next item in the array — first “Alice”, then “Bob”, then “Charlie”. |
name (the loop variable) | This is a new temporary variable you’re creating. It’s a singular version of names, but you can call it anything. Swift assigns it the current array item automatically. |
print("Hello, \(name)!") | Uses string interpolation to include the current name in the output. Each time through the loop, name holds a different value. |
names, name the loop variable name (singular). This makes your code read almost like plain English: “for each name in names.” Same idea: for score in scores, for color in colors.More Array Loop Patterns
let scores = [95, 82, 74, 100, 60]
for score in scores {
if score >= 90 {
print("\(score) — Great job!")
} else {
print("\(score) — Keep practicing.")
}
}score in any expression — including an if statement. This is a preview of how real apps process lists of data.let fruits = ["Apple", "Banana", "Cherry"]
for (index, fruit) in fruits.enumerated() {
print("\(index + 1). \(fruit)") // index starts at 0, so we add 1
}.enumerated() gives you both the position (starting from 0) and the value on each loop pass. The (index, fruit) syntax unpacks both values at once — you’ll see this pattern in real iOS apps all the time. Output: 1. Apple, 2. Banana, 3. Cherry.let prices = [4.99, 12.50, 3.25, 7.00]
var total = 0.0 // starts at zero — declared with var so we can change it
for price in prices {
total += price // add each price to the running total
}
print("Total: \(total)") // 27.74total is declared with var outside the loop so its value persists between passes. total += price is shorthand for total = total + price. This pattern — loop to accumulate — is one of the most common things you’ll do with arrays.var, not let. The array itself can be let if you’re not changing it — only the accumulator needs var.Create an array of 5 player names and a separate array of 5 scores (one for each player). Loop through the names and for each one print a message like: Alice scored 87 points.
Bonus: After the loop, calculate and print the average score across all players.
0..<names.count) and use the loop variable as an index into both arrays. names.count gives you the number of items in the array.AI Practice Prompts
A for loop is great when you know in advance how many times you want to repeat something. But what if you don’t know? What if you want to keep going until something changes?
That’s the job of a while loop. Think of it like a vending machine. It keeps taking coins as long as the balance is less than the item price. It doesn’t know upfront how many coins you’ll put in — it just checks each time and stops when the condition is met.
var coins = 0 // start with 0 coins
let price = 5 // the item costs 5 coins
while coins < price { // keep looping as long as we don't have enough
coins += 1 // add one coin each time
print("Coins inserted: \(coins)")
}
print("Vending!") // this runs after the loop ends| Part | What it does |
|---|---|
while | The keyword that starts a while loop. Swift will keep repeating the loop body as long as the condition is true. |
coins < price | The condition. Swift checks this before every single loop pass. The moment it becomes false (coins equals 5), the loop stops immediately. |
coins += 1 | This is what changes the condition. Every pass adds 1 to coins, so eventually the condition will become false. Without this line, you’d have an infinite loop. |
Code after the closing } | Runs once after the loop ends. The loop must finish before Swift moves on to the next statement. |
coins += 1 is the thing that eventually makes coins < price false.While Loop Variations
var attempts = 0
repeat {
attempts += 1
print("Attempt \(attempts)")
} while attempts < 3 // condition is checked AFTER each runrepeat-while loop is like a while loop but the condition is checked at the bottom instead of the top. This guarantees the loop body runs at least once, even if the condition starts out false. Use this when you always want at least one attempt — like showing a dialog box or making a first network request.var number = 1
while true { // condition is always true
print(number)
number *= 2 // double the number each time
if number > 100 {
break // stop when number exceeds 100
}
}while true creates a loop with no natural end condition — it will run until something inside explicitly uses break to stop it. You’ll learn about break in the next lesson. Use this pattern when the stopping logic is complex enough that it’s cleaner to put it inside the loop.var isRunning = true // this Bool controls the loop
var count = 0
while isRunning {
count += 1
print("Count: \(count)")
if count == 4 {
isRunning = false // flip the flag to stop the loop
}
}You want to save up $500. You start with $0 and add $75 each week. Write a while loop that prints your savings balance at the end of each week, and stops once you’ve reached or exceeded $500. At the end, also print how many weeks it took.
Bonus: Try the same thing using a repeat-while loop and see if the output is any different.
var because it changes. Think about whether your condition should be balance < 500 or balance <= 500 and what difference it makes.AI Practice Prompts
Loops don’t always need to run perfectly from start to finish. Sometimes you hit a situation where you want to stop the whole loop early. Other times you want to skip just one pass but keep going. Swift gives you two keywords for this: break and continue.
Think of a conveyor belt at a factory. continue is like saying “skip this defective item, keep the belt running.” break is like hitting the emergency stop — the whole belt shuts off immediately.
// break: stop the loop the moment we find what we need
let items = ["pen", "notebook", "scissors", "tape", "stapler"]
let target = "scissors"
for item in items {
print("Checking: \(item)")
if item == target {
print("Found it!")
break // stop the entire loop right here
}
}Notice the loop never checked “tape” or “stapler” — break stopped everything the moment we found what we were looking for.
Now here’s continue — it skips the rest of the current pass and jumps to the next one:
// continue: skip negative numbers, process only positives
let numbers = [3, -1, 7, -5, 2, 9]
for number in numbers {
if number < 0 {
continue // skip this item and move to the next one
}
print("Positive: \(number)")
}| Keyword | What it does |
|---|---|
break | Immediately exits the entire loop. Code after the closing brace of the loop continues to run normally, but the loop itself is done — no more passes happen. |
continue | Skips only the rest of the current loop pass. The loop itself keeps running — it just jumps directly to the next item or next count, skipping any code below the continue statement. |
More Patterns with Break and Continue
var energy = 100
while true {
energy -= 15
print("Energy remaining: \(energy)")
if energy <= 0 {
print("Out of energy!")
break // exits the while true loop
}
}break works in while loops too. When combined with while true, it’s the only way to stop the loop. This is a clean pattern when the stopping logic is complex — put the exit condition wherever it makes most sense in the loop body.let words = ["swift", "xcode", "apple", "ios", "swiftui"]
for word in words {
if !word.hasPrefix("swift") { // if the word doesn't start with "swift"
continue // skip it
}
print("Swift-related: \(word)")
}continue is great for filtering — skip items you’re not interested in and only process the ones that matter. This pattern appears a lot in real apps when you’re processing a list and only some items need handling.outerLoop: for i in 1...3 {
for j in 1...3 {
if i == 2 && j == 2 {
print("Stopping everything at (\(i), \(j))")
break outerLoop // exits the outer loop, not just the inner one
}
print("(\(i), \(j))")
}
}break only exits the innermost loop. Add a label (a name followed by a colon) to the outer loop and use break labelName to exit that specific loop. This is less common but useful in nested loop situations you’ll see in the next lesson.break exits the loop, continue skips one pass. A very common mistake is using break when you meant continue, which stops the loop entirely when you only wanted to skip one item. Re-read the output carefully when something looks wrong.You have an array of product weights: [102, 98, 115, 50, 103, 99, 110, 30, 105]. Acceptable weight is between 90 and 115 (inclusive). Write a loop that:
- Skips any product that is within the acceptable range using
continue - Prints a “REJECT” message for any out-of-range product
- Stops the entire production line (exits the loop) with a “HALT” message if you encounter a weight under 60 (catastrophic defect)
break. Then check if the weight is acceptable and use continue. Anything still running after those checks is a normal rejection.AI Practice Prompts
A nested loop is simply a loop inside another loop. The outer loop runs once, and for each pass of the outer loop the entire inner loop runs from start to finish. Then the outer loop goes to its next pass, and the inner loop runs all the way through again.
Think of a clock. The minute hand (outer loop) moves forward once per hour. Each time it does, the second hand (inner loop) completes a full 60-second cycle. For every 1 step of the outer loop, the inner loop takes 60 steps.
// Print a simple multiplication grid (3x3)
for row in 1...3 { // outer loop: rows
for col in 1...3 { // inner loop: columns
print("\(row) x \(col) = \(row * col)")
}
print("---") // runs after each full inner loop
}| Part | What it does |
|---|---|
for row in 1...3 | The outer loop. It runs 3 times — for row = 1, row = 2, and row = 3. |
for col in 1...3 | The inner loop. For each value of row, this entire loop runs 3 times — for col = 1, 2, and 3. |
| Total iterations | The outer loop runs 3 times. The inner loop runs 3 times per outer pass. Total: 3 × 3 = 9 iterations. With nested loops, the total work multiplies quickly. |
print("---") (after inner loop) | This line is inside the outer loop but outside the inner one. It only runs once per outer pass — after the inner loop completes for that row. |
Practical Nested Loop Patterns
// Simulate checking every seat in a cinema (5 rows, 8 seats each)
for row in 1...5 {
for seat in 1...8 {
print("Row \(row), Seat \(seat)")
}
}
// Total: 40 iterations (5 x 8)let grid = [
[1, 2, 3],
[4, 5, 6],
[7, 8, 9]
]
searchLoop: for row in grid {
for value in row {
if value == 5 {
print("Found 5!")
break searchLoop // exit both loops at once
}
}
}break searchLoop exits both loops at once — without the label, only the inner loop would break.When Not to Use Nested Loops
Nested loops are a legitimate tool, but they’re also one of the easiest ways to accidentally write slow code. Here are the situations to watch out for:
// Unnecessary nesting — this just adds complexity for no reason
let names = ["Alice", "Bob", "Charlie"]
let greetings = ["Hi", "Hello", "Hey"]
// Bad: this prints every greeting for every name (9 outputs)
for name in names {
for greeting in greetings {
print("\(greeting), \(name)!")
}
}
// Better: if you want one greeting per person, just use one loop
for name in names {
print("Hi, \(name)!")
}map, filter, and forEach that often replace nested loops with cleaner, faster alternatives.Use nested loops to print a right-triangle pattern of stars. For a size of 5, the output should look like this:
*
**
***
****
*****Bonus: Modify it so the triangle is inverted (5 stars on top, 1 on the bottom). Then try making a solid square (5 stars wide, 5 rows tall).
AI Practice Prompts
Stage 3 Recap: What You’ve Learned
You now know how to make Swift repeat things — and just as importantly, how to control exactly when and how it repeats them. Here’s a quick recap:
- For loops with ranges — use
1...10(closed) or1..<10(half-open) to repeat code a set number of times; usestrideto count by custom steps - For loops over arrays — loop through any collection with
for item in array; useenumerated()when you need the index too - While loops — repeat code until a condition becomes false; use
repeat-whilewhen you always need at least one pass - Break and continue —
breakexits the entire loop,continueskips to the next pass; labeled break lets you exit a specific outer loop - Nested loops — a loop inside a loop processes every combination; be mindful of how quickly the iteration count multiplies
- Use
varfor any variable you change inside a loop,letfor anything that stays fixed
If any of these still feel shaky, go back and redo the challenge for that lesson before moving on. The challenges are the real learning — watching code run is one thing, writing it from scratch is another.
In Stage 4 you’ll go deep on collections — arrays, dictionaries, and sets — and learn how to work with them using Swift’s powerful built-in tools like map, filter, and reduce. Those tools will replace many of the patterns you wrote as loops in this stage, and you’ll start to see why Swift developers prefer them.
Learn Swift Stage 4: Organizing Code with Functions
Functions are the moment Swift stops feeling like a list of instructions and starts feeling like a real programming language — and once they click, you won’t be able to imagine writing code without them.
To follow along, you need to have finished Stages 1 through 3. That means you’re comfortable with variables, data types, if/else, switch, and loops. Each lesson takes about 30 minutes if you pause to type the examples yourself and attempt the challenge at the end. Do not skip the challenges — they are where the concepts actually land.
By the end of Stage 4, you will know how to define and call a function, pass information into one, get information back out, set default values for parameters, and understand why a variable defined inside a function is invisible to the rest of your code. Functions are the biggest mental leap in this entire curriculum — but they are also the leap that makes everything else make sense. Take your time here.
Imagine you work at a coffee shop. Every morning when a customer orders a latte, you steam the milk, pull the espresso shot, combine them, add the lid. You do the exact same six steps every single time. Now imagine if every barista had to re-invent the latte procedure from scratch for each order — writing out every step on a notepad before they started. That would be absurd. Instead, the shop trains everyone once and says: “when someone orders a latte, do this.” That training is the function. The order is calling it.
In code, a function is a named block of instructions that you write once and run as many times as you need. Without functions, every time you wanted to do the same thing you would copy and paste the same lines over and over. That gets messy fast. Functions solve this with a principle programmers call DRY: Don’t Repeat Yourself.
Here is the problem without functions:
// Greeting three users — without a function
var name1 = "Alice"
print("Hello, \(name1)! Welcome to the app.")
print("We're glad you're here, \(name1).")
var name2 = "Bob"
print("Hello, \(name2)! Welcome to the app.")
print("We're glad you're here, \(name2).")
var name3 = "Carol"
print("Hello, \(name3)! Welcome to the app.")
print("We're glad you're here, \(name3).")It works — but notice how you repeated the same two print lines three times. Now imagine changing the greeting wording. You would have to edit it in three places and hope you don’t miss one. This is exactly the kind of problem that leads to bugs. Functions fix it by letting you define the instructions once, name them, and just call that name.
You have already been calling functions without realising it. print() is a function. Apple wrote it once, named it print, and every time you type print("something") you are calling their function. Starting in Lesson 4.2, you will write your own.
| Concept | Plain English meaning |
|---|---|
DRY | “Don’t Repeat Yourself” — the principle that says the same logic should live in exactly one place in your code. |
| Function | A named, reusable block of code. Define it once, call it as many times as you need. |
| Define | Writing the function — giving it a name and writing the instructions inside it. |
| Call | Running the function — telling Swift to execute those instructions right now. |
- You’re copying and pasting code. If you find yourself writing the same block twice, that block wants to be a function.
- A block of code has one clear job. If you can describe what it does in five words or fewer, it’s a good function candidate.
- You want to give a block of code a readable name. Functions make code read like plain English:
greetUser()is much clearer than ten lines of print statements.
Look at the six-line greeting code above. Without running it, rewrite it mentally as if you had a function called greetUser that handled those two print lines. How many times would you call it? What would you pass in? You don’t know the syntax yet — just think through the logic in plain English or pseudocode. Write your answer as a comment in a new Playground file.
Think of a function like a light switch. Someone wired up all the circuitry behind the wall — that wiring is the function definition. Once it’s done, anyone can flip the switch to turn the light on — that’s calling the function. The wiring only has to happen once. The flipping can happen a thousand times.
In Swift, you define a function using the func keyword, give it a name, add parentheses, and put the code you want to run inside curly braces. Here is the simplest possible function:
func sayHello() { // Define the function — wire up the switch
print("Hello! Welcome to the app.") // The code that runs when the function is called
} // End of the function body
sayHello() // Call the function — flip the switch (first time)
sayHello() // Call it again (second time) — same instructions, zero extra code
sayHello() // And again (third time)Three calls, three outputs, and the actual instructions only lived in one place. If you want to change the greeting wording, you change it once inside the function and all three calls update automatically.
| Part | What it does |
|---|---|
func | The Swift keyword that tells the compiler you are defining a function. It must come first. |
sayHello | The name you give the function. Use camelCase. Pick a name that describes what the function does — ideally a verb or verb phrase. |
() | Parentheses after the name. Required even when the function takes no input. This is where parameters live — more on those in Lesson 4.3. |
{ } | Curly braces wrap the function body — the block of code that runs whenever the function is called. |
sayHello() | Calling the function. Write the name followed by parentheses. Swift executes the function body right now at this line. |
func sayHello() { ... } but never write sayHello(), nothing happens. The definition only sets up the instructions — calling it is what actually runs them.func showWelcomeMessage() {
print("Welcome!")
print("Tap anywhere to get started.")
}
showWelcomeMessage()func drawSeparator() {
print("──────────────")
}
print("Section One")
drawSeparator()
print("Section Two")
drawSeparator()
print("Section Three")
drawSeparator()func printTitle() {
print("My App")
}
func launchScreen() {
printTitle() // Calls another function from inside this one
print("Loading...")
}
launchScreen()Write a function called showAppInfo that prints three lines: your app’s name, a one-sentence description, and the current version number. Then call it twice in a row. Confirm that both calls produce the same three lines of output.
func showAppInfo() { and put three print() calls inside. Don’t forget the closing curly brace. Then call it after the definition.Go back to the vending machine. A basic function is like a machine with one button that always gives you the same thing — a can of cola every time, no choices. That is useful, but limited. A real vending machine lets you pick what you want by pressing a button for a specific item. The item number you press is the parameter — the information you send into the machine to customise what comes out.
Parameters let a function do slightly different work each time it is called, based on the information you pass in. That is what transforms a simple function into something genuinely powerful.
func greetUser(name: String) { // 'name' is the parameter — it has a label and a type
print("Hello, \(name)!") // Use the parameter like a regular variable inside
print("We're glad you're here.") // This line is the same every call
}
greetUser(name: "Alice") // Call 1 — pass "Alice" as the name argument
greetUser(name: "Bob") // Call 2 — same function, different input
greetUser(name: "Carol") // Call 3 — same function, different input againWe’re glad you’re here.
This is the greeting problem from Lesson 4.1 — solved. One function definition, three calls, zero repeated code, and changing the greeting wording now requires editing exactly one place.
| Part | What it does |
|---|---|
name: | The parameter label. This is the word you use at the call site to make the code read naturally: greetUser(name: "Alice") reads almost like plain English. |
String | The parameter type. Swift needs to know what kind of value this parameter holds. Here it’s a String, but it could be Int, Double, Bool, or any other type. |
name inside the body | Inside the function body, name works exactly like a constant holding whatever value was passed in at the call site. |
name: "Alice" at call site | The argument — the actual value you pass in when calling the function. The label name: must match what the function definition declared. |
name: String). An argument is the actual value you pass when calling it ("Alice"). Think: parameter is the slot, argument is what you put in the slot.func printScore(score: Int) {
print("Your score is \(score) points.")
}
printScore(score: 42)
printScore(score: 100)String for Int, Double, Bool, or any other type. The function body treats the parameter like a constant of that type.func showStatus(isLoggedIn: Bool) {
if isLoggedIn {
print("Welcome back!")
} else {
print("Please sign in.")
}
}
showStatus(isLoggedIn: true)
showStatus(isLoggedIn: false)func printLine(_ text: String) { // The _ means "no label required when calling"
print(text)
}
printLine("Hello!") // No label needed — reads cleanly
printLine("Goodbye!")_ as the external label tells Swift not to require a label at the call site. Apple uses this pattern a lot — print("hello") works this way. Use it when the label would feel redundant.Write a function called celebrateBirthday that takes a name parameter of type String and an age parameter of type Int. Inside, print a personalised birthday message using both values. Call it three times with different names and ages.
func celebrateBirthday(name: String, age: Int). String interpolation works the same way as always inside the function body.So far, functions have been a one-way street. You send information in (via parameters) and the function does something with it — but it doesn’t hand anything back to you. Return values change that. A function with a return value works like a calculator: you give it numbers, it does the maths, and it hands back the result. You can then use that result anywhere in your code.
The arrow -> after the parentheses declares what type of value the function will return. The return keyword sends that value back to whoever called the function.
func addNumbers(a: Int, b: Int) -> Int { // -> Int declares the return type
let result = a + b // Do the work
return result // Send the result back to the caller
}
let total = addNumbers(a: 10, b: 25) // The returned value is stored in 'total'
print("Total: \(total)") // Use it like any other variable
let anotherTotal = addNumbers(a: 3, b: 7)
print("Another total: \(anotherTotal)")Another total: 10
| Part | What it does |
|---|---|
-> Int | The return type declaration. The arrow followed by a type tells Swift what kind of value this function will hand back. Every path through the function must return this type. |
return result | Sends the value of result back to wherever the function was called. After return, the function stops immediately — nothing else in the body runs. |
let total = addNumbers(...) | The call site captures the returned value into a constant. The function call on the right side evaluates to the return value, just like any expression. |
return statement, the function stops. Any code written after return inside the same block will never run. This is sometimes used on purpose — for example, returning early if a condition isn’t met.func buildGreeting(name: String) -> String {
let message = "Hello, \(name)! Welcome back."
return message
}
let greeting = buildGreeting(name: "Alice")
print(greeting)String when the function’s job is to build or format text. The caller gets a ready-to-use string and doesn’t need to know how it was built.func isAdult(age: Int) -> Bool {
return age >= 18 // Returns true if age is 18 or over, false otherwise
}
if isAdult(age: 21) {
print("Access granted.")
} else {
print("Access denied.")
}Bool is perfect for validation checks. You can use the function call directly inside an if statement — no intermediate variable needed.func square(_ number: Int) -> Int {
number * number // No 'return' keyword — Swift infers it for single expressions
}
print(square(5)) // Prints 25return. Swift automatically returns the value of that expression. This is called implicit return and you’ll see it often in SwiftUI code.Write a function called celsiusToFahrenheit that takes a Double parameter called celsius and returns a Double. The formula is: fahrenheit = (celsius * 9/5) + 32. Call it with at least three values (try 0, 100, and 37) and print each result with a descriptive label.
func celsiusToFahrenheit(celsius: Double) -> Double. Make sure to use 9.0/5.0 or Double(9)/Double(5) to avoid integer division giving you a wrong answer.A coffee order has multiple details: size, type, milk preference, number of shots. The barista needs all of them to make the right drink. A function can receive multiple pieces of information in the same way — you simply list multiple parameters inside the parentheses, separated by commas. Each parameter has its own label and type.
func describeProduct(name: String, price: Double, inStock: Bool) -> String {
let availability = inStock ? "In stock" : "Out of stock" // Ternary from Stage 2
return "\(name) — $\(price) — \(availability)"
}
let desc1 = describeProduct(name: "Swift Book", price: 29.99, inStock: true)
let desc2 = describeProduct(name: "iOS Course", price: 49.00, inStock: false)
print(desc1)
print(desc2)| Part | What it does |
|---|---|
name: String, price: Double, inStock: Bool | Three parameters, each with a label and type, separated by commas. Swift needs all three when the function is called. |
| Order at the call site | Arguments must be passed in the same order as they are declared in the function signature. You can’t swap them around. |
| Each parameter is independent | Inside the function body, each parameter works like its own constant with its own name and type. They don’t interfere with each other. |
describeProduct(name: "X", price: 9.99, inStock: true) is valid. Writing the arguments in a different order, like price before name, is a compile error. The labels help you remember the order — and help the compiler catch mistakes.func greet(user name: String) { // 'user' is the external label, 'name' is used inside
print("Hello, \(name)!") // Inside the body, use 'name'
}
greet(user: "Alice") // At the call site, use 'user'greet(user: "Alice").func calculateTip(billAmount: Double, tipPercent: Double) -> Double {
return billAmount * (tipPercent / 100)
}
let tip = calculateTip(billAmount: 45.50, tipPercent: 18)
print("Tip: $\(tip)")Write two functions: rectangleArea(width: Double, height: Double) -> Double and rectanglePerimeter(width: Double, height: Double) -> Double. Call each with at least two different sets of dimensions and print both results for each. Bonus: write a third function called describeRectangle that calls both of the above and prints a formatted summary of the area and perimeter.
Think about ordering a coffee. If you just say “a latte, please” without specifying size, the barista gives you a medium. Medium is the default. If you want something different, you say so — but you don’t have to. Default parameter values work exactly the same way. You assign a value to a parameter in the function definition, and then the caller can either pass their own value or leave it out entirely to use the default.
func sendWelcomeEmail(to name: String, subject: String = "Welcome!") {
// 'subject' has a default value — the caller doesn't have to provide it
print("To: \(name)")
print("Subject: \(subject)")
print("---")
}
sendWelcomeEmail(to: "Alice") // Uses default subject
sendWelcomeEmail(to: "Bob", subject: "You're In!") // Overrides the default
sendWelcomeEmail(to: "Carol") // Uses default subject again—
| Part | What it does |
|---|---|
subject: String = "Welcome!" | Declares a parameter with a default value. The = "Welcome!" part sets what Swift uses if the caller doesn’t provide that argument. |
| Call without the argument | When you omit a parameter that has a default, Swift substitutes the default value automatically. The function behaves as if you passed it in. |
| Call with the argument | When you do provide the argument, your value replaces the default for that specific call only. |
func formatPrice(amount: Double, currency: String = "USD", showDecimals: Bool = true) {
if showDecimals {
print("\(currency) \(amount)")
} else {
print("\(currency) \(Int(amount))")
}
}
formatPrice(amount: 9.99) // USD 9.99
formatPrice(amount: 9.99, currency: "EUR") // EUR 9.99
formatPrice(amount: 9.99, currency: "GBP", showDecimals: false) // GBP 9func logMessage(_ message: String, verbose: Bool = false) {
print("[LOG] \(message)")
if verbose {
print(" → Details: message length = \(message.count)")
}
}
logMessage("App launched") // Simple — verbose defaults to false
logMessage("User tapped button", verbose: true) // Enables extra outputfalse is a classic way to add optional behaviour. Most callers get the simple path; those who need more detail opt in explicitly.Write a function called buildNotification that takes three parameters: title: String, body: String, and badge: Int with a default value of 1. It should return a formatted String like “TITLE — body (badge: 1)”. Call it four times: once with all three arguments, once omitting the badge, and twice more of your choosing. Print each result.
badge: Int = 1. When you want to test the default, just don’t include badge: in that call.Imagine two different conference rooms in an office building. A conversation happening in Room A is completely private — nobody in Room B can hear it, and nothing said in Room A automatically affects what’s happening in Room B. Both rooms exist in the same building, but they are separate spaces. This is scope: the idea that variables only exist and are only visible inside the space where they were created.
In Swift, every set of curly braces creates its own scope. A variable declared inside a function exists only for the life of that function call. The moment the function finishes, those variables disappear. Code outside the function cannot see them, and code inside the function cannot see variables from other functions.
var appName = "MyApp" // Declared at the top level (global scope)
func showAppName() {
print(appName) // Can access appName — it's in a wider scope
var localMessage = "Hello!" // Only exists inside this function
print(localMessage)
}
showAppName()
// print(localMessage) ← This would be a compile error!
// localMessage doesn't exist outside the functionfunc functionOne() {
var secret = "I'm in function one"
print(secret)
}
func functionTwo() {
// print(secret) ← Compile error — 'secret' doesn't exist here
var secret = "I'm in function two" // Totally separate variable, same name is fine
print(secret)
}
functionOne()
functionTwo()Notice that both functions have a variable called secret. They are completely separate variables that happen to share a name. Changing one has zero effect on the other. This is one of the most important and useful features of scope — it means you don’t have to worry about naming conflicts between functions.
| Concept | Plain English meaning |
|---|---|
| Scope | The region of code where a variable can be seen and used. Defined by the curly braces that contain it. |
| Local variable | A variable declared inside a function. Only visible inside that function. Disappears when the function returns. |
| Global variable | A variable declared outside all functions, at the top level. Visible everywhere in the file. Use sparingly — it creates dependencies between parts of your code. |
| Shadowing | Declaring a local variable with the same name as an outer-scope variable. The local one takes priority inside that scope. Swift allows this but it can be confusing — avoid it when possible. |
func checkScore(score: Int) {
if score >= 80 {
let grade = "Pass" // 'grade' only exists inside this if block
print("Grade: \(grade)")
}
// print(grade) ← Compile error — 'grade' is out of scope here
print("Score checked.")
}
checkScore(score: 90)if, for, while, and switch blocks all create their own scope. A variable declared inside one of those blocks cannot be accessed outside it.func doubleIt(_ number: Int) -> Int {
let result = number * 2 // 'number' here is a local copy of whatever was passed in
return result
}
var myNumber = 10
let doubled = doubleIt(myNumber) // Passes the VALUE of myNumber — not the variable itself
print(myNumber) // Still 10 — the function didn't touch the original
print(doubled) // 20In a new Playground, write three functions: functionA, functionB, and functionC. In each one, declare a local variable called count with a different value (1, 2, and 3). Print count from inside each function. Call all three. Then try adding a line after the functions that prints count — observe the compile error, then comment it out. Write a comment explaining in your own words why the error occurred.
Stage 4 Recap: Organizing Code with Functions
Functions are the most important concept you have learned so far — and the one that will change how you see code forever. Here’s what you now understand:
- A function is a named, reusable block of code. You define it once and call it as many times as you need — this is the DRY principle in action.
- You define a function with the
funckeyword, a name, parentheses, and a body wrapped in curly braces. You call it by writing its name followed by(). - Parameters let you pass information into a function. Each parameter has a label and a type. At the call site, you provide the matching argument.
- Return values let a function send a result back out. Use
-> Typeafter the parentheses and thereturnkeyword inside the body. - Functions can have multiple parameters, each with their own label and type, separated by commas.
- Default parameter values let callers skip optional arguments — use
= valuein the parameter declaration. Put defaulted parameters last. - Scope means a variable only exists where it was declared. Local variables inside a function are invisible to the outside world — and that’s a feature, not a limitation.
- When you pass a basic value (Int, String, Double, Bool) to a function, Swift passes a copy. The original is untouched.
Functions are the building blocks that everything else in Swift rests on. Every concept coming in Stages 5 and beyond — closures, structs, classes, SwiftUI views — builds directly on what you just learned. If anything in this stage felt unclear, revisit that lesson before moving on. The time you spend here pays off ten times over later.
Stage 5 covers closures — which you can think of as functions without names that can be passed around like values. They are everywhere in Swift and in SwiftUI, and now that you understand functions, closures will make much more sense.
Learn Swift Stage 5: Collections
Most real apps aren’t about one piece of data — they’re about lists of things, and this stage is where you learn to work with them.
To follow along, open Xcode and create a new Swift Playground. Each lesson takes around 30 minutes and ends with a challenge you’ll solve on your own. That challenge is where the actual learning happens, so don’t skip it.
By the end of Stage 5 you’ll understand how to create and use arrays, dictionaries, and sets — the three core collection types in Swift. These are the building blocks every real app relies on. When your app loads a list of products, stores user settings, or tracks unique tags, it’s using collections.
Imagine you’re writing a shopping list. You could write each item on a separate sticky note, but that gets messy fast. Instead you write them all on one list, in order. That’s an array.
An array is an ordered collection of values of the same type. Instead of creating five separate variables to hold five names, you put all five names into one array. Swift keeps them in the order you added them, and you can access any item by its position in the list.
That position is called an index. Array indexes always start at 0, not 1. So the first item is at index 0, the second is at index 1, and so on. This trips up almost every beginner at least once, so keep it in mind.
// Creating an array of strings
var shoppingList: [String] = ["Apples", "Bread", "Milk"]
// Swift can infer the type — this works too
var scores = [98, 74, 85, 91]
// Creating an empty array — you must declare the type
var names: [String] = []
// Accessing items by index (remember: starts at 0)
print(shoppingList[0]) // "Apples"
print(shoppingList[2]) // "Milk"
// Arrays have a count property
print(shoppingList.count) // 3| Line | What it does |
|---|---|
var shoppingList: [String] = [...] | Creates an array called shoppingList that holds String values. The square brackets around the type tell Swift this is an array. |
var scores = [98, 74, 85, 91] | Swift sees four integers and infers the type is [Int] automatically. You don’t always need to write the type yourself. |
var names: [String] = [] | Creates an empty array. When there are no values to infer the type from, you must write it explicitly. |
shoppingList[0] | Reads the item at index 0. Square brackets with a number let you access any position in the array. |
.count | A property built into every array that tells you how many items it contains. |
shoppingList[10] on a 3-item array — your app will crash at runtime. Swift won’t catch this as a compile error. Always make sure the index you’re using is within range.var tags = [String]()var tags: [String] = []. Both create an empty String array. You’ll see both styles in real Swift code, so it’s worth recognising each form.var zeros = Array(repeating: 0, count: 5)
// Result: [0, 0, 0, 0, 0]let fixedList = ["A", "B", "C"] // cannot be changed
var growingList = ["A", "B"] // can add or remove itemslet when the array’s contents won’t change after creation. Use var when you need to add, remove, or update items. This is the same rule as regular variables.Create an array called favouriteFilms that holds the titles of three films you enjoy. Then print the first film, the last film, and the total count of films in the array.
.count gives you the number of items, not the last valid index.Now that you can create an array, you need to be able to do things with it. In a real app you’re constantly adding new items, removing old ones, checking whether something is in the list, and finding the first or last entry.
Swift gives you a set of built-in methods and properties that handle all of this. They’re built right into every array, so you call them using dot syntax — the same way you call .count.
var fruits = ["Apple", "Banana", "Cherry"]
// append — adds one item to the end
fruits.append("Mango")
print(fruits) // ["Apple", "Banana", "Cherry", "Mango"]
// insert — adds an item at a specific index
fruits.insert("Kiwi", at: 1)
print(fruits) // ["Apple", "Kiwi", "Banana", "Cherry", "Mango"]
// remove — removes the item at a specific index
fruits.remove(at: 0)
print(fruits) // ["Kiwi", "Banana", "Cherry", "Mango"]
// contains — checks if an item exists, returns true or false
print(fruits.contains("Banana")) // true
print(fruits.contains("Apple")) // false
// first and last — optional properties
print(fruits.first!) // "Kiwi"
print(fruits.last!) // "Mango"
// count
print(fruits.count) // 4| Operation | What it does |
|---|---|
.append("Mango") | Adds “Mango” to the end of the array. This is the most common way to grow an array. |
.insert("Kiwi", at: 1) | Adds “Kiwi” at index 1. Everything after it shifts right by one position. |
.remove(at: 0) | Removes the item at index 0. Everything after it shifts left by one position. Also returns the removed item if you want to use it. |
.contains("Banana") | Returns true if the value is in the array, false if not. Great for checking membership without looping manually. |
.first and .last | These are Optionals — they return nil if the array is empty. The ! force-unwraps them, which is safe only if you know the array isn’t empty. |
.first on an empty array, there’s no first item — so Swift returns nil instead of crashing. In real code, you’d use if let firstFruit = fruits.first { ... } rather than force-unwrapping with !.var numbers = [1, 2, 3]
numbers += [4, 5, 6]
print(numbers) // [1, 2, 3, 4, 5, 6]+= operator lets you join two arrays together. You can also use + to combine arrays without modifying either one.var cart = ["Shirt", "Shoes", "Hat"]
cart.removeAll()
print(cart.count) // 0
print(cart.isEmpty) // true.removeAll() to empty an array completely. The .isEmpty property is a cleaner way to check if an array has no items — it’s more readable than writing count == 0.var colors = ["Red", "Green", "Blue"]
colors[1] = "Yellow"
print(colors) // ["Red", "Yellow", "Blue"]let raw = [5, 2, 8, 1]
let ascending = raw.sorted() // [1, 2, 5, 8]
let descending = raw.sorted(by: >) // [8, 5, 2, 1]
let flipped = raw.reversed() // sequence [1, 8, 2, 5].sorted() returns a new sorted array and leaves the original unchanged. .sort() (no parentheses with arguments) sorts the array in place. Use by: > for descending order.Start with an empty array called toDoList. Add three tasks to it using .append(). Then remove the second task. Finally, check whether “Buy groceries” is in the list using .contains() and print the result.
In Stage 3 you learned how to loop over a range of numbers. Now you can combine that skill with arrays — and this is where things start to feel like real programming.
One of the most common things you do with an array is go through every item and do something with each one. Maybe you print them all, filter some out, or calculate a total. Swift gives you a clean syntax for this that you’ll use in nearly every app you write.
let temperatures = [22.5, 18.0, 30.1, 25.4, 19.8]
// Basic for-in loop — reads every item one at a time
for temp in temperatures {
print("Temperature: \(temp)°C")
}
// Loop with a condition inside
print("Hot days:")
for temp in temperatures {
if temp > 25.0 {
print("\(temp)°C is a hot day")
}
}
// Calculating a total using a running variable
var total: Double = 0.0
for temp in temperatures {
total += temp // add each temperature to total
}
let average = total / Double(temperatures.count)
print("Average: \(average)°C")| Line | What it does |
|---|---|
for temp in temperatures | Each time through the loop, Swift takes the next item from the array and puts it in temp. You pick the name — it could be anything readable. |
if temp > 25.0 | A condition inside a loop. This runs for every item, but only executes the print when the condition is true. |
var total: Double = 0.0 | A running total declared before the loop. Each iteration adds to it. This is a very common pattern — sometimes called an accumulator. |
total += temp | Shorthand for total = total + temp. After the loop finishes, total holds the sum of every temperature. |
Double(temperatures.count) | Converts the count (an Int) to a Double so the division works correctly. Dividing two integers in Swift would give you a whole number result, losing any decimal. |
temperatures, name the loop variable temperature or temp. If you’re looping over products, use product. Singular vs plural is the convention and makes your loops much easier to read.let players = ["Alice", "Bob", "Charlie"]
for (index, player) in players.enumerated() {
print("Player \(index + 1): \(player)")
}
// Player 1: Alice
// Player 2: Bob
// Player 3: Charlie.enumerated() gives you both the position and the value in each iteration. Indexes start at 0, so index + 1 makes the output start at 1. This is handy whenever you need to display numbered lists.let items = ["A", "B", "C"]
for i in items.indices {
print("Index \(i): \(items[i])")
}
// Index 0: A
// Index 1: B
// Index 2: C.indices gives you a range of valid index numbers for the array. This is safer than writing 0..<items.count yourself because Swift ensures the range always matches the array’s actual size.let names = ["Zara", "Leo", "Mia"]
names.forEach { name in
print("Hello, \(name)!")
}.forEach works similarly to a for-in loop but uses closure syntax. You’ll encounter this style when working with SwiftUI and functional code. It cannot use break or continue — for that you need a regular for-in loop.Create an array of five integer test scores. Write a loop that prints each score along with its position number (starting from 1, not 0). Then write a second loop that only prints scores that are 70 or above, labelling them as “Pass”.
.enumerated() will save you some work. For the second part, a plain for-in loop with an if condition is all you need.Think about a physical contact book. You don’t look someone up by their position in the book — you look them up by their name. “Alice” maps to her phone number. “Bob” maps to his. The name is the lookup key and the phone number is the value it points to.
That’s exactly what a dictionary is in Swift. Instead of accessing data by a numbered index like an array, you access it by a meaningful key. Each key must be unique and maps to exactly one value.
Dictionaries are unordered — Swift does not guarantee the items will come back in any particular sequence. If order matters, use an array. If lookup by a meaningful label matters, use a dictionary.
// Creating a dictionary [KeyType: ValueType]
var contactBook: [String: String] = [
"Alice": "555-1234",
"Bob": "555-5678",
"Carol": "555-9012"
]
// Reading a value — returns an Optional
if let phone = contactBook["Alice"] {
print("Alice's number: \(phone)")
}
// Adding a new key/value pair
contactBook["Dan"] = "555-3456"
// Updating an existing value
contactBook["Bob"] = "555-0000"
// Removing a key/value pair
contactBook["Carol"] = nil
// Looping over a dictionary
for (name, number) in contactBook {
print("\(name): \(number)")
}
print("Total contacts: \(contactBook.count)")| Line | What it does |
|---|---|
[String: String] | The type annotation for a dictionary. The first type is the key type, the second is the value type. Keys are always the type before the colon. |
contactBook["Alice"] | Looks up the value for the key “Alice”. This returns an Optional because the key might not exist. Always unwrap safely with if let. |
contactBook["Dan"] = "555-3456" | If the key “Dan” doesn’t exist, this adds it. If it already exists, this updates its value. Adding and updating use identical syntax. |
contactBook["Carol"] = nil | Setting a key to nil removes it from the dictionary entirely. After this line, “Carol” no longer exists as a key. |
for (name, number) in contactBook | Iterates over every key/value pair. Swift gives you both the key and the value as a tuple. The order of pairs is not guaranteed. |
if let or a default value (contactBook["Alice"] ?? "Unknown") rather than force-unwrapping with !.let score = contactBook["Unknown", default: "No number"]
print(score) // "No number"default: value, the lookup returns that instead of nil if the key is missing. The result is a regular String, not an Optional — so no unwrapping needed. Useful when missing keys are expected and you have a sensible fallback.let allKeys = Array(contactBook.keys)
let allValues = Array(contactBook.values)
print(allKeys) // e.g. ["Alice", "Bob", "Dan"] (order not guaranteed)
print(allValues) // e.g. ["555-1234", "555-0000", "555-3456"].keys and .values give you collections of every key and every value. Wrapping them in Array() converts them into a regular array you can index or sort.var userProfile: [String: Any] = [
"name": "Alice",
"age": 28,
"isPremium": true
]Any as the value type lets you mix strings, integers, booleans, and more in one dictionary. You’ll see this when working with JSON data from an API. The downside is you lose type safety — use structs instead when you control the data model.Create a dictionary called capitals that maps three country names to their capital cities. Then add a fourth country, update one of the existing capitals to a different city, and safely look up a capital using if let. Print a message like “The capital of France is Paris.”
if let capital = capitals["France"] and build your print statement inside the if block using the unwrapped value.You now know about arrays and dictionaries. There’s one more collection type worth knowing: the set.
A set is like an array, but with two important differences. First, a set never contains duplicates — if you try to add a value that’s already there, nothing happens. Second, a set is unordered — like a dictionary, Swift makes no guarantees about sequence.
Think about a guest list for a party. You don’t care what order the names are in, and each person can only appear once. That’s a set.
// Creating a set — note the explicit type annotation
var tags: Set<String> = ["swift", "ios", "mobile"]
// Inserting an item
tags.insert("apple")
// Inserting a duplicate — nothing happens
tags.insert("swift")
print(tags.count) // still 4, not 5
// Checking membership — sets are fast at this
print(tags.contains("ios")) // true
print(tags.contains("android")) // false
// Removing an item
tags.remove("mobile")
// Set operations
let setA: Set = [1, 2, 3, 4]
let setB: Set = [3, 4, 5, 6]
print(setA.union(setB)) // all items from both: {1, 2, 3, 4, 5, 6}
print(setA.intersection(setB)) // items in both: {3, 4}
print(setA.subtracting(setB)) // items in setA but not setB: {1, 2}.contains() is nearly instant regardless of how many items the set holds. For large collections where you only need membership checks, sets are the better tool.Comparison: All Three Collection Types
| Feature | Array | Dictionary | Set |
|---|---|---|---|
| Ordered | Yes — items stay in the order you add them | No — order is not guaranteed | No — order is not guaranteed |
| Allows duplicates | Yes — same value can appear multiple times | Keys must be unique; values can repeat | No — duplicates are silently ignored |
| How you access items | By integer index: array[0] | By key: dict["name"] | No direct access — only iterate or check membership |
| Best for | Ordered lists where position or sequence matters | Lookup tables where you need a meaningful key | Unique membership — tags, permissions, visited IDs |
| contains() speed | Slow on large arrays (scans every item) | Fast (hash lookup by key) | Very fast (hash lookup) |
| Real iOS examples | List of messages, search results, table rows | User profile fields, JSON data, settings | Tags, seen notification IDs, selected items |
| Swift literal syntax | [1, 2, 3] | ["a": 1, "b": 2] | Set([1, 2, 3]) or Set<Int> = [1, 2, 3] |
let rawList = ["swift", "ios", "swift", "mobile", "ios"]
let unique = Array(Set(rawList))
print(unique) // ["mobile", "swift", "ios"] (order may vary)// Does order matter? → Array
let messages = ["Hello", "How are you?", "Great!"]
// Do you need lookup by name/key? → Dictionary
let config: [String: Bool] = ["darkMode": true, "notifications": false]
// Do you need unique values and fast membership checks? → Set
var viewedArticleIDs: Set<Int> = [101, 204, 309]Create three separate collections for these three scenarios:
1. A playlist of song titles in order, where the same song can appear more than once.
2. A record of which user IDs have already seen a notification — no duplicates needed, just quick membership checks.
3. An app settings store where you look up boolean values by a setting name like “soundEnabled”.
For each, write the collection with at least two real items and a short comment explaining your collection type choice.
Stage 5 Recap: Collections
You’ve added one of the most important tools in Swift to your toolkit. Here’s what you can now do:
- Create arrays with literal syntax, type annotations, and the repeating initialiser
- Use append, insert, remove, contains, first, last, count, and sorted to work with arrays
- Iterate over arrays with for-in, enumerated(), indices, and forEach
- Create and use dictionaries with String or other key types, including safe Optional lookups
- Use sets to store unique values and perform union, intersection, and subtracting operations
- Choose the right collection type for a given problem based on order, uniqueness, and lookup needs
Collections are how apps store and work with real data. Everything from a list of messages to a user’s settings to a feed of posts is built on the patterns you just learned.
The natural next stage is Stage 6 — Closures and Functional Methods, where you’ll learn to use map, filter, and reduce to transform collections in ways that are shorter, cleaner, and more expressive than manual loops.
Learn Swift Stage 6: Optionals
This is the stage most beginners find hardest — and the one that, once it clicks, makes everything else in Swift suddenly make sense.
You’ve made it through five stages of Swift. You can write functions, loop through arrays, and build conditions. You have real foundations. Stage 6 is where Swift asks something new from you: it wants you to think carefully about the possibility that a value might not exist at all. That idea — “this thing might have a value, or it might have nothing” — is what optionals are about. Budget around 30 minutes per lesson, and make sure you do each challenge before moving on.
By the end of Stage 6 you will understand what an optional is and why Swift uses them, how to safely unwrap them with if let and guard let, when to use the nil coalescing operator ??, why force unwrapping is dangerous, and how optional chaining lets you work with chains of optionals gracefully. These concepts appear in virtually every iOS app ever written. Once they are clear in your mind, reading real Swift code becomes dramatically easier.
Before we look at any code, let’s talk about a box.
Imagine you order a gift online. It arrives in a box. You shake the box — it might have something inside, or the seller might have shipped an empty box by mistake. You don’t know until you open it. That uncertainty is exactly what an optional represents in Swift.
In most of the Swift code you have written so far, your variables always had a value. You declared var name = "Chris" and name was always a String. There was never a question of whether the value existed. It always did.
But real-world data doesn’t work that way. Think about these situations:
- A user fills out a form — but the middle name field is optional. Not everyone has one.
- You ask the user to type a number, but they type “hello” instead. The conversion fails. There is no number.
- You look up a username in a database. That username might not exist.
- You try to find the first item in an array. The array might be empty.
In each of those situations, a value might be there, or it might not. Swift uses a special type to represent this uncertainty: an Optional.
An optional is like a container. The container either holds a value of the type you expect, or it holds a special placeholder called nil. nil means “nothing” — there is no value inside this container right now.
You create an optional by adding a question mark after the type name. Here is what that looks like:
// A regular String — it must always have a value
var regularName: String = "Chris"
// An optional String — it might have a value, or it might be nil
var optionalName: String? = "Taylor"
// Setting an optional to nil — this means "no value right now"
var unknownName: String? = nil
// You can also declare an optional without assigning anything
// Swift automatically sets it to nil by default
var middleName: String?
print(optionalName) // Optional("Taylor")
print(unknownName) // nilNotice that when you print an optional that has a value, Swift shows it wrapped in Optional(...). That wrapping is Swift showing you the container, not just the value inside. You will learn how to get the value out of the container in Lesson 6.3.
| Line | What it does |
|---|---|
String | A regular type. This variable will always hold a String. You cannot set it to nil. |
String? | An optional String. The question mark tells Swift this variable might hold a String, or it might hold nil. Both are valid states. |
nil | The special value that means “there is nothing here”. Only optionals can be set to nil. Regular variables cannot. |
var middleName: String? | Declaring an optional without assigning a value. Swift automatically sets it to nil. You don’t have to write = nil explicitly. |
Optional("Taylor") | What Swift prints when an optional holds a value. The value is “wrapped” inside an Optional container. You’ll learn to unwrap it soon. |
Optional("Taylor") in the console and wonder why it says that instead of just “Taylor”. The answer is that you haven’t opened the box yet — you’ve just printed the box itself. Unwrapping is the act of opening the box, and you’ll learn exactly how to do it in Lesson 6.3.var age: Int? = 25 // optional Int with a value
var score: Double? = nil // optional Double with no value
var isLoggedIn: Bool? // optional Bool — starts as nil automaticallyInt?, Double?, Bool? — they all work the same way. The question mark just means “this container might be empty”.// Int() tries to convert a String to an Int
// It returns an optional because the conversion might fail
let input = "42"
let converted = Int(input) // converted is Int? — might be nil
let bad = "hello"
let result = Int(bad) // result is nil — "hello" is not a numberInt() returns an Int? because it cannot guarantee the String can be converted. If the String is not a valid number, the result is nil instead of crashing.String? for a user’s nickname (give it a value), one Int? for their age (set it to nil), and one Double? for their account balance (leave it unassigned). Print all three and observe what appears in the console. Then try setting your nickname variable to nil and print it again.= nil explicitly, but you can if you want to.Here’s a question you might be asking yourself: why does Swift make such a big deal about nil? Other programming languages just let you have a null value anywhere and deal with the consequences later. Why does Swift care so much?
The answer is crashes. Specifically, it’s avoiding the single most common crash in programming history. In many languages, if you try to use a variable that holds null (the equivalent of nil), your program crashes immediately. These crashes are called null pointer exceptions, and they have caused more broken apps, more frustrated users, and more late-night debugging sessions than almost anything else in the history of software development.
Swift’s designers made a deliberate choice: they would make it impossible to accidentally use a nil value as if it were a real value. If you try to use an optional as though it definitely has a value, Swift will refuse to compile your code. It won’t even run. The error is caught before you ever ship the app to a user.
This is the compiler acting as your safety net. Let’s see what Swift refuses to let you do:
var username: String? = "christing"
// This will NOT compile. Swift says: Value of optional type
// 'String?' must be unwrapped to refer to member 'count'
let length = username.count // ERROR
// You also can't use an optional String directly where a String is expected
func greet(name: String) {
print("Hello, \(name)!")
}
greet(name: username) // ERROR — String? is not the same as String
// You also can't assign nil to a regular variable
var regularName: String = nil // ERROR — regular types can never be nilEvery one of those lines is a compile-time error. Swift won’t let the program run until you handle the optionals properly. That might feel annoying right now, but consider the alternative: a user is using your app, hits a nil value you didn’t expect, and the app crashes. No warning. No second chances. That’s what Swift is protecting you from.
Think of it like a car with a manual transmission. You cannot accidentally put it in drive without deliberately going through the steps. The system is designed to make accidents harder, not easier. Optionals are Swift’s manual transmission for nil values.
| What Swift prevents | Why it matters |
|---|---|
username.count on an optional | If username were nil, calling .count would have nowhere to go. In other languages this crashes the app. Swift stops you before it can happen. |
Passing String? where String is expected | Functions that take a String assume they will always receive a value. Passing nil into them breaks that assumption. Swift refuses to let this happen silently. |
var name: String = nil | A regular (non-optional) variable must always have a value. Swift enforces this by making nil impossible to assign to a regular type. |
String and String? are two completely different types. A String is guaranteed to always have a value. A String? might have a value, or it might have nil. Swift treats them as different, and it will not let you mix them up without explicitly handling the difference. This distinction is the entire foundation of optional safety.// The error message Swift gives you is actually telling you exactly what to do
var score: Int? = 95
// This gives: "Value of optional type 'Int?' must be unwrapped"
let doubled = score * 2 // ERROR
// "must be unwrapped" means: open the box first, then use what's inside
// The next lessons show you exactly how to do that// Tony Hoare, who invented null references in 1965, later called it
// "my billion-dollar mistake" — null/nil has caused so many bugs and
// crashes across the history of software that the cost is incalculable.
// Swift was designed so that nil can only exist where you explicitly
// allow it (by using the ? suffix) and must be handled before use.
// This turns what used to be a runtime crash into a compile-time error.You know what an optional is. You know why Swift forces you to handle nil. Now it’s time to learn the first and most fundamental tool for dealing with optionals: if let.
Going back to the gift box analogy: if let is the act of opening the box and checking whether something is inside. If there is something inside, you take it out and use it. If the box is empty, you handle that situation separately. Either way, you never reach into an empty box.
Here is the basic pattern:
var username: String? = "christing"
// if let opens the box and checks what's inside
if let name = username {
// Inside this block, "name" is a regular String (not optional)
// We know it has a value because we only get here if username wasn't nil
print("Welcome back, \(name)!")
} else {
// We get here if username was nil
print("No username found.")
}
// Now let's try it with a nil value
var middleName: String? = nil
if let middle = middleName {
print("Middle name: \(middle)")
} else {
print("No middle name provided.") // This runs
}| Line | What it does |
|---|---|
if let name = username | This checks whether username contains a value. If it does, Swift extracts the value and stores it in a new constant called name. That constant is only available inside the curly braces that follow. |
Inside the if let block | name is a regular String here — not an optional. You can use it directly without any question marks or special handling. |
The else block | This runs only when the optional was nil. You can handle the “no value” case here. The else is optional — you can leave it out if you don’t need to do anything special when nil. |
name vs username | username is still the optional — it still exists outside the block. name is the unwrapped value that only exists inside the if let block. |
if let block. If you try to use name after the closing curly brace, Swift will tell you it doesn’t exist. This is intentional — the safety guarantee only applies inside the block where Swift verified the value existed.var username: String? = "christing"
// Modern Swift (5.7+) lets you reuse the same name
// instead of writing "if let name = username"
if let username {
print("Welcome, \(username)!") // username is a String here
}if let username instead of if let name = username. Inside the block, username refers to the unwrapped value, not the optional. This shorthand is very common in modern Swift code — you will see it a lot in real apps.var firstName: String? = "Chris"
var lastName: String? = "Ching"
// Unwrap both in one if let — only runs if BOTH have values
if let first = firstName, let last = lastName {
print("Full name: \(first) \(last)")
} else {
print("One or both names are missing.")
}if let by separating them with a comma. The block only runs if all of the optionals have values. If any one of them is nil, the else branch runs instead. This is cleaner than nesting multiple if let blocks inside each other.var age: Int? = 17
// Unwrap AND check a condition in one step
if let userAge = age, userAge >= 18 {
print("Access granted.")
} else {
print("Access denied.") // Runs — age is 17
}if inside an if let.// User typed something — we don't know if it's a valid number
let userInput = "42"
if let number = Int(userInput) {
// userInput was a valid number — number is an Int here
print("You entered: \(number)")
print("Doubled: \(number * 2)")
} else {
print("That wasn't a valid number.")
}Int(userInput) returns Int? — an optional that is nil if the conversion fails. Wrapping it in if let lets you handle both outcomes cleanly.displayName: String?, followersCount: Int?, and bio: String?. Assign a value to displayName and followersCount, but leave bio as nil. Use a single if let with multiple bindings to unwrap displayName and followersCount together and print a profile summary. Then use a separate if let for bio — and in its else branch, print “No bio yet.” Finally, try the modern shorthand syntax (if let displayName) on one of your variables.if let uses commas to separate them. All of them must have values for the block to run.if let is great when you want to do something inside a block if a value exists. But there is a different situation that comes up constantly in real iOS apps: you are at the start of a function, and if the optional doesn’t have a value, the whole function should stop. There is nothing useful to do without that value.
Imagine a function that loads a user’s profile from a server. If there is no user ID, there is no point in making the network request. You want to bail out early. That’s what guard let is designed for.
Here is a good way to think about it: if let says “if the value exists, do something with it”. guard let says “the value must exist — if it doesn’t, stop everything right now”.
func loadProfile(userID: String?) {
// guard let: if userID is nil, we exit the function immediately
// The "else" block MUST exit — return, throw, or break
guard let id = userID else {
print("No user ID — cannot load profile.")
return // Exit the function
}
// If we reach this line, id is a regular String — guaranteed
// Notice that id is available for the REST of the function
// (unlike if let, where it was only available inside the block)
print("Loading profile for user: \(id)")
print("Profile loaded successfully.")
}
loadProfile(userID: "user_12345") // Loads profile
loadProfile(userID: nil) // Prints error, returns early| Line | What it does |
|---|---|
guard let id = userID else { | Check whether userID has a value. If it does, extract it into id and continue. If userID is nil, run the else block. |
Inside the else block | This is the failure case — what to do when the optional is nil. This block MUST exit the current scope. You have to write return, break, continue, or throw. Swift enforces this at compile time. |
id after the guard block | This is the big difference from if let. With guard let, the unwrapped constant is available for the entire rest of the function, not just inside a block. |
else block in a guard statement must exit the current scope. You are not allowed to just print a message and keep going — Swift will refuse to compile. This is intentional: guard is designed as an early exit tool. If you want to keep going even when the value is nil, use if let instead.// Use if let when you want to do something IF the value exists
// The unwrapped value is only available inside the block
if let nickname = user.nickname {
print("Hi \(nickname)!") // nickname exists here
}
// nickname does NOT exist here
// Use guard let when the function cannot proceed without the value
// The unwrapped value is available for the rest of the function
func processOrder(orderID: String?) {
guard let id = orderID else { return }
// id is available for the entire rest of the function
print("Processing order: \(id)")
}guard let. If you only need it for one short thing inside a block, use if let. In practice, guard let is the pattern you will see most often in real iOS code because functions usually need their inputs to be valid to be useful at all.func sendMessage(from sender: String?, to recipient: String?, body: String?) {
// Unwrap all required inputs at once
guard let from = sender,
let to = recipient,
let message = body else {
print("Missing required fields.")
return
}
// All three are available and guaranteed to have values here
print("\(from) → \(to): \(message)")
}guard let using newlines. If any one of them is nil, the else block runs. This pattern at the top of a function is sometimes called a “guard clause” and it keeps the main logic of your function clean by handling all the failure cases upfront.attemptLogin that takes three optional parameters: email: String?, password: String?, and deviceID: String?. Use a single guard let to unwrap all three at once. If any are nil, print a specific error message and return early. If all three have values, print a success message that includes all three values. Then call the function three times: once with all values, once with email set to nil, and once with all three set to nil.Sometimes you don’t need to do anything special when a value is nil. You just want a sensible default to fall back on. You want to say: “Use this value if it exists, otherwise use this other value instead.”
Swift has a very clean way to express that idea: the nil coalescing operator, written as two question marks: ??.
Think of it like a backup plan. You ask your first choice (the optional), and if that’s not available, you fall back to your second choice (the default value). The result is always a regular, non-optional value — so you can use it directly without any more unwrapping.
var nickname: String? = nil
let displayName = nickname ?? "Anonymous"
print(displayName) // Anonymous
var savedVolume: Int? = 75
let volume = savedVolume ?? 50
print(volume) // 75 — savedVolume had a value so the default wasn't used
var profileColor: String? = nil
print("Theme color: \(profileColor ?? "blue")") // Theme color: blue| Expression | What it does |
|---|---|
nickname ?? "Anonymous" | If nickname has a value, use it. If nickname is nil, use “Anonymous” instead. The result is always a String, never an optional. |
savedVolume ?? 50 | savedVolume was 75, so 75 is used. The default (50) is ignored because the optional had a value. |
Using ?? inside string interpolation | You can use ?? anywhere you need a non-optional value, including inside \() in a string. This avoids printing “Optional(…)” in your output. |
?? must be the same type as the value inside the optional. You cannot write someString ?? 0 — a String optional needs a String default, and an Int optional needs an Int default. Swift will give you a type error if they don’t match.var preferredName: String? = nil
var username: String? = nil
// Try preferredName first, then username, then fall back to "Guest"
let displayName = preferredName ?? username ?? "Guest"
print(displayName) // Guest — both optionals were nil?? operators. Swift evaluates them left to right and uses the first non-nil value it finds. If all of them are nil, it falls back to the final default on the right. This is a clean way to express a priority list of fallbacks.var city: String? = nil
// Without ??: prints "Optional(nil)" or "nil"
print(city) // nil
// With ??: always prints a clean String
print(city ?? "Unknown city") // Unknown city
// Also useful inside string interpolation
print("Location: \(city ?? "Not set")") // Location: Not set?? is a clean way to provide user-visible fallback text without writing a full if let block. It’s especially common in SwiftUI views where you want to display something meaningful even when optional data hasn’t loaded yet.preferredLanguage: String? (nil), fontSize: Int? (nil), darkMode: Bool? (set to true), and username: String? (nil). Use ?? to resolve each one to a final value with sensible defaults. Print a settings summary that shows all four resolved values. Then add a second optional displayName: String? that is also nil, and chain it with username using ?? so that the app first tries displayName, then username, then falls back to “Guest”.?? must match the type of the optional. For Bool? your default should be a Bool (not a String).Every optional tool you have learned so far — if let, guard let, ?? — deals with nil carefully. They check whether a value exists before using it. Swift provides one more way to get the value out of an optional, but this one is different. It doesn’t check. It just grabs.
Force unwrapping uses an exclamation mark after an optional to say: “I’m certain this optional has a value. Skip the check and give me the value right now.” It looks like this: username!
The problem is: if you are wrong — if the optional is actually nil — your app crashes immediately. Not an error message. Not a warning. A hard crash that kills the app right in front of your user.
var name: String? = "Chris"
// Force unwrap — works here because name actually has a value
print(name!) // Chris
// Now change name to nil and force unwrap again...
name = nil
print(name!) // CRASH: Fatal error: Unexpectedly found nil while unwrapping| Line | What it does |
|---|---|
name! | The exclamation mark says “I guarantee this optional has a value — give it to me directly”. Swift trusts you and doesn’t check. If you’re right, you get the value. If you’re wrong, the app crashes. |
| “Fatal error: Unexpectedly found nil” | This is the crash message you see when you force unwrap a nil optional. You will see this message in the Xcode console. It means someone used ! on an optional that turned out to be nil. |
! on an optional, you are making a promise to Swift that you cannot always keep. If the data changes, if a network request fails, if a user provides unexpected input, that promise breaks and your app crashes. Use if let, guard let, or ?? instead. They will never crash you.So when, if ever, is force unwrapping acceptable?
There is a small set of situations where experienced developers use it deliberately. For example, when loading an image that is bundled with the app — you are certain the file exists because you put it there. Or when working with @IBOutlet properties that are connected in Interface Builder. In those cases, the developer has external knowledge that the value will never be nil.
As a beginner, you do not yet have the experience to know when those situations arise. The safe rule is: never use ! unless you can explain exactly why nil is impossible in that specific case, and you have verified it is impossible in all code paths.
// An implicitly unwrapped optional is declared with ! instead of ?
// It is treated as optional for storage, but as non-optional for use
var label: UILabel! // Implicitly unwrapped — common in IBOutlets
// You can use it without unwrapping syntax
// But if it's nil when you access it, the app still crashes
// label.text = "Hello" // Crash if label is nil
// You mostly see this in generated Xcode code, not code you write yourself! in type declarations (not just after a variable name) in auto-generated Xcode code, especially with @IBOutlet. These are called implicitly unwrapped optionals. They are optionals that behave like non-optionals. You don’t need to write these yourself as a beginner — just know they exist and that they carry the same crash risk as force unwrapping.var username: String? = nil
// AVOID: force unwrap — crashes if username is nil
// let name = username! ← don't do this
// PREFER: if let — handles nil safely
if let name = username {
print(name)
}
// PREFER: ?? with a default — always safe
let name = username ?? "Guest"
print(name)!, one of the safe alternatives is the better choice. If you find yourself typing ! to make an error go away, that is the signal to use if let or ?? instead.var score: Int? = nil, let result = score! * 2, print(result). Your job is to rewrite it three times: once using if let, once using guard let inside a function, and once using ?? with a default of 0. Each version should handle the nil case without crashing. Add a comment to each version explaining why it is safer than the original.guard let version you’ll need to wrap the code in a function since guard requires an exit route.You have learned how to safely unwrap a single optional. But what about situations where you have a chain of optionals? Where to get to the thing you want, you have to go through several values that might each be nil?
Imagine a user profile. The user might not be logged in. If they are logged in, they might not have set an address. If they have an address, it might not have a postal code. To get the postal code, you have to get through three levels of “maybe”.
Without optional chaining, you would need a nested stack of if let statements — one inside the other, inside the other. It gets messy fast. Optional chaining gives you a cleaner way to navigate that chain using the ?. syntax.
// Set up some simple structs to model the data
struct Address {
var street: String
var postalCode: String
}
struct UserProfile {
var name: String
var address: Address? // Address is optional — not all users have one
}
struct App {
var currentUser: UserProfile? // User is optional — might not be logged in
}
var app = App(currentUser: nil)
// Optional chaining with ?. — each ? means "if this exists, keep going"
// If any part of the chain is nil, the whole expression returns nil
let postalCode = app.currentUser?.address?.postalCode
print(postalCode ?? "No postal code available")
// No postal code available — because currentUser was nil
// Now let's give the app a logged-in user with an address
let address = Address(street: "100 Main St", postalCode: "M5V 1A1")
let user = UserProfile(name: "Chris", address: address)
app.currentUser = user
let code = app.currentUser?.address?.postalCode
print(code ?? "No postal code available")
// M5V 1A1 — all parts of the chain had values| Line | What it does |
|---|---|
app.currentUser?.address?.postalCode | Read left to right: “Get currentUser — if it exists, get its address — if that exists, get its postalCode”. Each ?. is a safe step that stops and returns nil if the value before it is nil. |
The result type of ?. | The result of any optional chaining expression is always an optional. Even though postalCode is a regular String inside the struct, accessing it through optional chaining makes the result String?. This is why pairing it with ?? is so common. |
| If any link in the chain is nil | The entire expression short-circuits and returns nil immediately. No crash. No need to unwrap each step manually. The chain just stops and gives you nil. |
?., even once, the result of the whole expression is optional — regardless of what property you are accessing at the end. This is why app.currentUser?.name gives you String? even though UserProfile.name is a regular String. The chain could have stopped at any point, so the result must be optional.var optionalText: String? = "hello world"
// Call a method on an optional using optional chaining
let uppercased = optionalText?.uppercased()
print(uppercased ?? "no text") // HELLO WORLD
var nilText: String? = nil
let result = nilText?.uppercased()
print(result ?? "no text") // no text — nilText was nilstruct Company {
var ceo: String?
}
struct Employee {
var company: Company?
}
var employee = Employee(company: Company(ceo: "Chris"))
// Use optional chaining to navigate the chain, then unwrap the final result
if let ceoName = employee.company?.ceo {
print("CEO: \(ceoName)") // CEO: Chris
} else {
print("No CEO found")
}?. chaining with if let is extremely common. Use the chain to navigate to the value you want, then use if let to unwrap the final optional result into a usable value. The chain handles the “path”, and if let handles the “destination”.Subscription with a plan: String property, Account with an optional subscription: Subscription?, and User with an optional account: Account?. Then create a User variable and try three scenarios: user with no account (both account and subscription are nil), user with an account but no subscription, and user with an account and a subscription. For each scenario, use optional chaining to safely access the plan name and print either the plan or “No active plan” using ??.user.account?.subscription?.plan. This returns String? — pair it with ?? to resolve to a display value.Stage 6 Recap: Optionals
This was the hardest stage in the curriculum — and you made it through. That matters. Optionals are the concept that separates people who give up on Swift from people who break through. You are now in the second group.
- An optional is a container that either holds a value or holds nil — the concept of “maybe a value, maybe nothing”
- Swift prevents you from using an optional as though it definitely has a value — this is intentional safety, not an inconvenience
if letsafely unwraps an optional into a new constant that is only available inside the blockguard letsafely unwraps an optional at the top of a function and exits early if nil — the unwrapped value is available for the whole function??provides a default value when an optional is nil — the result is always non-optional- Force unwrapping (
!) skips the nil check — if the optional is nil, the app crashes immediately. Avoid it as a beginner. - Optional chaining (
?.) lets you navigate through chains of optionals safely — if any link is nil, the whole chain returns nil without crashing
Something important: now that optionals make sense to you, you will start seeing them differently in real Swift code. Code that used to look confusing — all those question marks and exclamation marks scattered through Apple’s examples — will start making sense. That is not a small thing.
Stage 7 covers closures and higher-order functions — the tools that let you write concise, powerful code using map, filter, reduce, and custom callbacks. With your optional foundations solid, you are ready for it.
Learn Swift Stage 7: Structs and Classes
This is the stage where everything clicks — you’ll learn how to bundle data and behaviour together into your own custom types, which is exactly how real Swift apps are built.
To follow along, open Xcode and create a new Swift Playground (File → New → Playground). Each lesson takes roughly 25–30 minutes including the challenge. Set aside about 2.5 hours for the full stage, or work through one lesson at a time.
By the end of Stage 7 you will know what a struct is and how to define one, how to give it properties and methods, how initializers work, why structs and classes behave differently, and how to use mutating methods. After this stage you are ready to move into SwiftUI and start building real app screens.
So far, the types you have worked with — String, Int, Bool — are all built into Swift. But what happens when you need to represent something more specific, like a user in your app, or a recipe, or a product in a store?
That is exactly what a struct is for. A struct lets you create your own custom data type by grouping related pieces of data together under one name.
Think of a struct like a blueprint. A blueprint for a house describes everything the house will have: how many rooms, what colour the walls are, whether there is a garage. The blueprint itself is not a house — but you can use it to create as many houses as you want, and each house can have its own values.
In the same way, a struct is a blueprint. You define it once, then use it to create as many instances as you need — each with its own data.
// Define a struct — this is the blueprint
struct Contact {
var name: String // stored property: the contact's name
var email: String // stored property: the contact's email
var age: Int // stored property: the contact's age
}
// Create an instance — this is a real contact made from the blueprint
var alice = Contact(name: "Alice", email: "alice@example.com", age: 28)
// Access individual properties with dot syntax
print(alice.name) // Alice
print(alice.email) // alice@example.com
print(alice.age) // 28
// Create a second instance — completely separate from alice
var bob = Contact(name: "Bob", email: "bob@example.com", age: 34)
print(bob.name) // Bob| Line | What it does |
|---|---|
struct Contact { } | Defines a new custom type called Contact. The word struct signals we are creating a blueprint. |
var name: String | Declares a stored property. Every Contact instance will have a name of type String. |
Contact(name:email:age:) | Creates a new instance of the struct. Swift automatically provides this initializer from the properties. |
alice.name | Dot syntax — accesses the name property on the alice instance specifically. |
var bob = Contact(...) | Creates a completely separate instance. Changing bob’s data does not affect alice at all. |
Contact, not contact. This is how you tell the difference between a type and a variable at a glance. Property names start with a lowercase letter, just like regular variables.struct Product {
let id: String // cannot be changed after creation
var name: String // can be updated
var price: Double // can be updated
}
var item = Product(id: "SKU-001", name: "Notebook", price: 12.99)
item.name = "Premium Notebook" // fine — name is var
// item.id = "SKU-002" // error — id is letlet for properties that identify or define the item and should never change, and var for properties that are allowed to be updated later.struct Address {
var city: String
var country: String
}
struct User {
var name: String
var address: Address // property whose type is another struct
}
let user = User(
name: "Maria",
address: Address(city: "Toronto", country: "Canada")
)
print(user.address.city) // TorontoCreate a struct called Movie with these properties: a title (String), a director (String), a releaseYear (Int), and an isAvailable Bool that tracks whether the movie is currently streaming.
Create two different Movie instances. Print the title and availability of each one. Then change the isAvailable value on one of them and print again to confirm it updated.
myMovie.isAvailable = true. Remember isAvailable needs to be declared with var so it can change.A struct does not just hold data — it can also do things. The data inside a struct is called properties. The actions a struct can perform are called methods. Together, they give your custom type both a description and a set of behaviours.
If properties are the nouns of a struct — the things it has — then methods are the verbs. Think of a bank account: it has a balance and an owner (properties), but it can also deposit money, withdraw money, and display a summary (methods).
struct BankAccount {
// Properties — the data this struct holds
let owner: String
var balance: Double
// Computed property — calculated from other properties, not stored
var formattedBalance: String {
"$\(balance)" // returns a formatted string
}
// Method — a function defined inside the struct
func printSummary() {
print("\(owner)'s balance: \(formattedBalance)")
}
// Method that takes a parameter
func canAfford(amount: Double) -> Bool {
return balance >= amount // returns true if balance covers the amount
}
}
var account = BankAccount(owner: "Sam", balance: 500.0)
account.printSummary() // call a method with dot syntax
print(account.canAfford(amount: 200.0)) // true
print(account.canAfford(amount: 750.0)) // false
print(account.formattedBalance) // $500.0| Line | What it does |
|---|---|
var balance: Double | A stored property — its value is saved in memory and can be read or changed. |
var formattedBalance: String { ... } | A computed property — it looks like a property but is calculated each time it is accessed. No value is stored. |
func printSummary() | A method — a function that belongs to the struct. It can access the struct’s properties using their names directly. |
func canAfford(amount:) -> Bool | A method that takes a parameter and returns a value. Methods work exactly like regular functions — they just live inside a struct. |
account.printSummary() | Calling a method uses the same dot syntax as accessing a property — just add parentheses at the end. |
struct Rectangle {
var width: Double
var height: Double
// computed — calculated from width and height, not stored
var area: Double {
width * height
}
var perimeter: Double {
2 * (width + height)
}
}
let rect = Rectangle(width: 10, height: 5)
print(rect.area) // 50.0
print(rect.perimeter) // 30.0area when width changes, because it is always calculated on the fly.struct TemperatureConverter {
var celsius: Double
func toFahrenheit() -> Double {
(celsius * 9 / 5) + 32
}
func isFreezing() -> Bool {
celsius <= 0
}
}
let temp = TemperatureConverter(celsius: 22)
print(temp.toFahrenheit()) // 71.6
print(temp.isFreezing()) // falseCreate a struct called Podcast with stored properties for title (String), episodeCount (Int), and averageMinutesPerEpisode (Int).
Add a computed property called totalHours that returns the total listening time in hours as a Double. Add a method called printDescription() that prints a friendly sentence like “The Daily has 300 episodes — about 150.0 hours of content.”
episodeCount * averageMinutesPerEpisode. Divide by 60 to get hours — remember to convert to Double first so you get a decimal result. Inside your method, access the computed property the same way you would from outside: just use its name.Every time you create an instance of a struct, Swift needs to set up all of its properties before you can use it. The code that does that setup is called an initializer.
You have already been using one without thinking about it. When you write Contact(name: "Alice", email: "alice@example.com", age: 28), that is calling an initializer. Swift generated it automatically from your properties. This is called the memberwise initializer, and you get it for free with every struct.
But sometimes you want more control over how instances are created. Maybe some properties have sensible defaults. Maybe you want to accept simpler inputs and do some calculation before setting a property. That is when you write a custom initializer.
struct AppUser {
var username: String
var email: String
var isPremium: Bool
var joinedMessage: String
// Custom initializer — we control what parameters are required
init(username: String, email: String) {
self.username = username // self.username = the property, username = the parameter
self.email = email
self.isPremium = false // default value — new users are never premium
self.joinedMessage = "Welcome, \(username)!" // calculated from the username
}
}
// Notice: we only pass username and email — isPremium and joinedMessage are set automatically
let user = AppUser(username: "sarah_codes", email: "sarah@example.com")
print(user.joinedMessage) // Welcome, sarah_codes!
print(user.isPremium) // false| Line | What it does |
|---|---|
init(username:email:) | Declares a custom initializer. The keyword is init — no func needed, and no return type. |
self.username = username | self refers to the current instance being created. It distinguishes the property (self.username) from the parameter (username) when they share the same name. |
self.isPremium = false | Sets a default value inside the initializer. The caller does not need to provide this — the initializer handles it. |
self.joinedMessage = "Welcome, \(username)!" | You can do work inside an initializer before setting a property — here, building a greeting string from the username parameter. |
extension instead. For now, just know that if you define an init, you are taking over that job from Swift.struct NotificationSettings {
var soundEnabled: Bool = true // default value right on the property
var badgeEnabled: Bool = true
var alertStyle: String = "banner"
}
// No arguments needed — all defaults apply
let defaults = NotificationSettings()
// Override just the ones you need
let custom = NotificationSettings(soundEnabled: false, badgeEnabled: true, alertStyle: "alert")
print(defaults.alertStyle) // banner
print(custom.alertStyle) // alertinit just to set defaults.struct Color {
var red: Double
var green: Double
var blue: Double
// Init from individual RGB values
init(red: Double, green: Double, blue: Double) {
self.red = red
self.green = green
self.blue = blue
}
// Convenience init for white (all channels equal)
init(white: Double) {
self.red = white
self.green = white
self.blue = white
}
}
let red = Color(red: 1.0, green: 0.0, blue: 0.0)
let gray = Color(white: 0.5)Create a struct called CalendarEvent with properties: title (String), durationMinutes (Int), isAllDay (Bool), and reminder (String).
Write a custom initializer that takes only title and durationMinutes. Inside the initializer, set isAllDay to false by default. Set reminder to a string like "Reminder: 15 minutes before [title]" so it is automatically generated from the title.
self.reminder = "Reminder: 15 minutes before \(title)" — you can use the parameter value in string interpolation inside the initializer before the property is set. Make sure you set every property before the initializer ends, or Swift will give you an error.This is one of the most important concepts in Swift — and one that catches a lot of beginners off guard. The key question is: when you copy a piece of data and change the copy, does the original change too?
The answer depends on whether you are working with a value type (like a struct) or a reference type (like a class).
Think of it like this. Imagine you have a recipe written on a piece of paper. If you photocopy it and hand the copy to a friend who scribbles on it, your original is untouched — they changed their copy, not yours. That is a value type. Now imagine you and a friend are both looking at the same document on Google Docs. If they change it, you see the change too — you are both working with the same thing. That is a reference type.
// STRUCT — value type (each copy is independent)
struct PointStruct {
var x: Int
var y: Int
}
var pointA = PointStruct(x: 1, y: 2)
var pointB = pointA // Swift makes a full copy of pointA
pointB.x = 99 // changing pointB does NOT affect pointA
print(pointA.x) // 1 — unchanged
print(pointB.x) // 99 — only pointB changed
// ─────────────────────────────────────────────
// CLASS — reference type (copies share the same object)
class PointClass {
var x: Int
var y: Int
init(x: Int, y: Int) {
self.x = x
self.y = y
}
}
var classA = PointClass(x: 1, y: 2)
var classB = classA // classB points to the SAME object as classA
classB.x = 99 // changing classB ALSO changes classA
print(classA.x) // 99 — classA changed too!
print(classB.x) // 99| Concept | What it means |
|---|---|
var pointB = pointA (struct) | Swift creates a completely independent copy. pointA and pointB are two separate pieces of data in memory. |
var classB = classA (class) | Swift does not copy — it creates a second reference pointing at the same object. Both variables share one object in memory. |
| Value type | Structs, enums, and all Swift basic types (String, Int, Array, etc.) are value types. Assignment always copies. |
| Reference type | Classes are reference types. Assignment shares the same object. You only get a copy if you explicitly write code to create one. |
struct Score {
var points: Int
}
var playerOne = Score(points: 100)
var playerTwo = playerOne // independent copy
playerTwo.points = 250
print(playerOne.points) // 100 — not affected
print(playerTwo.points) // 250
// Great for: most data models in SwiftUI apps
// Safe, predictable — no surprising side effectsclass GameSession {
var score: Int = 0
var level: Int = 1
}
let sessionA = GameSession()
let sessionB = sessionA // same object — NOT a copy
sessionA.score = 500 // changes the shared object
print(sessionB.score) // 500 — sessionB sees the change
// Great for: objects shared across many parts of the app
// Use with care — changes anywhere affect everyone@Observable or ObservableObject for exactly this reason.Write a struct called ShoppingCart with a single property itemCount (Int). Then write a class called SharedCart with the same property.
For the struct: create an instance, copy it into a second variable, change the second one’s itemCount, and print both to prove they are independent. For the class: do the same thing and print both to show they share the same data. Add a comment above each print explaining what you expect to see before you run it.
class instead. Remember that classes require you to write your own init — Swift does not provide a memberwise initializer for classes automatically.You have learned that structs are value types. Because of this, Swift treats a struct instance as if it is a snapshot — it expects the struct to stay the same unless you explicitly say otherwise. This creates a rule you will run into quickly: a regular method inside a struct cannot change the struct’s own properties.
When you do want a method to modify a property, you mark it with the keyword mutating. This tells Swift: yes, this method is allowed to change this struct’s data. Swift will then make sure you only call it on a variable (var), not a constant (let).
struct StepCounter {
var steps: Int = 0
var goal: Int = 10_000
// Regular method — reads data, does not change anything
func progressMessage() -> String {
"\(steps) of \(goal) steps"
}
// mutating — this method is allowed to modify properties
mutating func addSteps(_ count: Int) {
steps += count // modifies the steps property
}
// mutating — can change multiple properties
mutating func reset() {
steps = 0 // resets steps back to zero
}
// computed property — reads goal but does not change anything, so not mutating
var isGoalReached: Bool {
steps >= goal
}
}
var counter = StepCounter() // must be var — we plan to mutate it
counter.addSteps(3_500)
counter.addSteps(4_200)
print(counter.progressMessage()) // 7700 of 10000 steps
print(counter.isGoalReached) // false
counter.addSteps(3_000)
print(counter.isGoalReached) // true
counter.reset()
print(counter.steps) // 0| Line | What it does |
|---|---|
func progressMessage() | A regular method — it only reads properties, never changes them. No mutating needed. |
mutating func addSteps(_ count: Int) | The mutating keyword gives this method permission to modify the struct’s properties. |
steps += count | This is the line that changes the struct. Without mutating, Swift would refuse to compile this. |
var counter = StepCounter() | You can only call mutating methods on var instances. If you declared counter with let, the compiler would block the call. |
var isGoalReached: Bool { ... } | A computed property that reads data — no mutating needed. Only methods that write to properties require it. |
mutating makes it obvious which methods can change data and which ones are safe to call on a copy without side effects. It is a feature, not a limitation.struct TrafficLight {
var color: String
mutating func next() {
switch color {
case "red": color = "green"
case "green": color = "yellow"
default: color = "red"
}
}
}
var light = TrafficLight(color: "red")
light.next()
print(light.color) // green
light.next()
print(light.color) // yellowself = TrafficLight(color: "red"). As long as the method is marked mutating, Swift allows it.struct Counter {
var count: Int = 0
mutating func increment() { count += 1 }
}
var mutableCounter = Counter()
mutableCounter.increment() // fine — mutableCounter is var
print(mutableCounter.count) // 1
let fixedCounter = Counter()
// fixedCounter.increment() // error — cannot mutate a let constantlet constant, Swift will refuse to compile. This is intentional — let means the value will not change, so calling a mutating method would contradict that promise.Create a struct called Habit with properties: name (String), currentStreak (Int), and bestStreak (Int).
Add a mutating method called completedToday() that increments currentStreak by 1. Inside that method, also check if currentStreak is now greater than bestStreak — if so, update bestStreak to match. Add a mutating method called missedToday() that resets currentStreak to 0. Add a non-mutating method that prints a status message showing both streaks.
completedToday() and missedToday() modify properties so they both need mutating. The status print method only reads — no mutating needed. Remember to declare your habit instance with var.Stage 7 Recap
Here is everything you learned in Stage 7:
- A struct is a custom data type that groups related properties together — like a blueprint you can use to create instances
- Properties are the data a struct holds (stored or computed), and methods are the actions it can perform
- A custom initializer uses
initto control how instances are set up, including setting defaults and doing calculations before storing values - Structs are value types — copying a struct creates an independent copy. Classes are reference types — copying a class gives you another reference to the same object
- Methods that modify a struct’s properties must be marked
mutating, and can only be called onvarinstances
These are the building blocks of every model in a SwiftUI app. Every piece of data you display — a user profile, a to-do item, a product, a message — will be represented using these ideas.
Up next: SwiftUI. You will take everything you have built here and start creating real visual interfaces — buttons, lists, screens, and navigation — running on a real device.
Learn Swift Stage 8: Protocols and Extensions
The most powerful Swift code you’ll ever read isn’t built from fancy tricks — it’s built from protocols and extensions, and Stage 8 is where they finally click.
All you need to follow along is Xcode and a Swift Playground. No new setup required. Each of the five lessons in this stage takes about 25 to 35 minutes and ends with a hands-on challenge you solve yourself. Don’t skip the challenges — protocols are one of those topics that feel clear when you’re reading but fuzzy until you actually write the code.
By the end of Stage 8, you’ll understand what a protocol is and how to define one, how to conform your own types to protocols, what the four most common built-in protocols do and when to add them, how extensions let you add behavior to any type including ones you didn’t write, and what some View actually means every time you write a SwiftUI view. These are the concepts that separate code you copy from code you truly own.
Think about a job listing. Before anyone gets hired, the company publishes a list of requirements: “You must be able to write reports, attend weekly meetings, and respond to email within 24 hours.” The company doesn’t care how you do those things — it just needs the guarantee that you can. A protocol in Swift works exactly the same way. It’s a list of requirements, not an implementation. It says “anyone who uses this protocol must provide these properties and methods.” It doesn’t say how.
You’ve already been using protocols without realizing it. Every time you conformed a struct to Identifiable in SwiftUI, or used Codable to decode JSON, you were working with protocols. This lesson pulls back the curtain and shows you what a protocol actually is at the Swift language level, so those familiar names finally make complete sense.
By the end of this lesson you’ll understand what a protocol is, how to define one yourself, and why they exist. You’ll also understand the key mental model: a protocol is a contract, not an implementation.
// Define a protocol — a contract that any conforming type must fulfill
protocol Describable {
var description: String { get } // must have a readable description property
func describe() // must have a describe() method
}
// A struct that fulfills the contract
struct Book: Describable {
var title: String
var description: String { "A book called \(title)" } // fulfills the property
func describe() { // fulfills the method
print(description)
}
}
let swift = Book(title: "Swift Programming")
swift.describe() // A book called Swift Programming| Line | What it does |
|---|---|
protocol Describable { } | Defines a new protocol named Describable. Everything inside is a requirement — not actual code that runs. |
var description: String { get } | Declares that conforming types must have a readable description property of type String. The { get } means it just needs to be readable — it can be a stored or computed property. |
func describe() | Declares that conforming types must have a describe() method. No implementation here — just the signature. |
struct Book: Describable | The colon after Book followed by Describable means “Book promises to fulfill the Describable contract.” This is called conformance. |
var description: String { ... } | The struct fulfills the protocol’s property requirement using a computed property. Swift just needs the name, type, and { get } access — this satisfies it. |
func describe() { ... } | The actual implementation of the required method. This is where the real code lives — not in the protocol. |
What Can a Protocol Require?
protocol Named {
var name: String { get } // read-only — just needs to be readable
var nickname: String { get set } // read-write — must be changeable too
}{ get } when you just need to read the value. Use { get set } when the protocol also requires the value to be writable. A type can always fulfill a { get } requirement with a stored property — that’s fine.protocol Greetable {
func greet() -> String // must return a String
func greetPerson(named: String) -> String // with a parameter
}protocol Configurable {
init(name: String) // conforming type must have this initializer
}
struct Widget: Configurable {
var name: String
init(name: String) { // fulfills the init requirement
self.name = name
}
}init requirement must mark that init with the required keyword.protocol Flyable {
func fly()
}
protocol Swimmable {
func swim()
}
// Duck conforms to both — separated by commas
struct Duck: Flyable, Swimmable {
func fly() { print("Flap flap") }
func swim() { print("Splash splash") }
}| Syntax | What It Does |
|---|---|
| protocol Name { } | Defines a new protocol with the given name |
| var x: Type { get } | Requires a readable property named x |
| var x: Type { get set } | Requires a readable and writable property named x |
| func methodName() | Requires a method with the given signature |
| struct Foo: ProtocolName | Declares that Foo conforms to ProtocolName |
| struct Foo: A, B, C | Foo conforms to protocols A, B, and C |
Define a protocol called Playable that requires a title property (read-only String) and a play() method. Then create two structs — Song and Podcast — that both conform to Playable. Each should implement play() differently. Call play() on an instance of each.
Imagine you want to apply for a position that requires a driver’s license, a first aid certificate, and fluent Spanish. You don’t just say “I can do those things” — you go get each one and provide proof. Protocol conformance in Swift works the same way. You claim conformance by adding the protocol name after the colon, and Swift immediately checks whether you’ve actually fulfilled every single requirement. If you’re missing anything, it won’t compile.
You’ve already written conformance dozens of times. Every struct you wrote in SwiftUI that had an id property was conforming to Identifiable. Every time you wrote struct Model: Codable, you were declaring conformance. This lesson explains exactly what’s happening under the hood when you do that.
By the end of this lesson you’ll understand how to fulfill protocol requirements correctly, what the compiler checks for, and how to split conformance into an extension to keep your code tidy.
protocol Vehicle {
var numberOfWheels: Int { get } // read-only property requirement
var color: String { get set } // read-write property requirement
func describe() -> String // method requirement
}
struct Bicycle: Vehicle {
var numberOfWheels: Int = 2 // stored property — fulfills { get }
var color: String // stored property — fulfills { get set }
func describe() -> String { // fulfills the method requirement
return "A \(color) bicycle with \(numberOfWheels) wheels"
}
}
var myBike = Bicycle(color: "red")
print(myBike.describe()) // A red bicycle with 2 wheels| Line | What it does |
|---|---|
struct Bicycle: Vehicle | Declares that Bicycle conforms to the Vehicle protocol. Swift will now check that every requirement is fulfilled. |
var numberOfWheels: Int = 2 | A stored property with a default value. A stored property always satisfies a { get } requirement because it can be read. |
var color: String | A stored property with no default. It satisfies { get set } because stored properties are both readable and writable by default. |
func describe() -> String { ... } | The full implementation of the required method. The signature must match the protocol exactly — same name, same parameters, same return type. |
{ get }, you can use a stored property, a computed property with a getter, or even a let constant. All three satisfy a read-only requirement.Conformance Patterns
struct Car: Vehicle { // declare conformance here
var numberOfWheels: Int = 4
var color: String
func describe() -> String {
return "A \(color) car"
}
}struct Truck { // core type defined here
var color: String
var payload: Double
}
extension Truck: Vehicle { // conformance added separately
var numberOfWheels: Int { 18 } // computed property fulfills { get }
func describe() -> String {
return "A \(color) truck carrying \(payload)kg"
}
}let vehicles: [any Vehicle] = [
Bicycle(color: "blue"),
Car(color: "red"),
Truck(color: "black", payload: 5000)
]
// Call describe() on each — Swift knows they all have it
for vehicle in vehicles {
print(vehicle.describe())
}Vehicle, you can store them in the same array and call protocol methods on all of them. Swift guarantees describe() exists because the protocol contract says so.protocol Resettable {
mutating func reset() // protocol marks it mutating
}
struct Counter: Resettable {
var count = 0
mutating func reset() { // struct also marks it mutating
count = 0
}
}mutating. Classes don’t need mutating since they’re reference types, but they can still conform — they just omit the keyword in their implementation.| Syntax | What It Does |
|---|---|
| struct Foo: Protocol { } | Declares Foo conforms to Protocol inline |
| extension Foo: Protocol { } | Adds conformance to Foo in an extension |
| var x: Protocol | A variable that can hold any conforming type |
| [any Protocol] | An array that can hold any conforming type |
| mutating func in protocol | Allows struct conformers to modify their own properties |
Define a protocol called Shape with a computed property area: Double { get } and a method describe() -> String. Create two structs — Circle (with a radius) and Rectangle (with width and height) — that both conform to Shape using extensions. Then create an array of type [any Shape], add one of each, and loop through printing each shape’s description.
Double.pi for the value of π.When you buy furniture from a flat-pack store, they include an instruction sheet. But they don’t include instructions for how to breathe while you assemble it — that’s so obvious it goes without saying. Swift’s standard library protocols work similarly: many of them come with sensible default behavior that Swift synthesizes automatically. You just declare conformance and Swift figures out the details.
You’ve bumped into these four protocols already, even if you didn’t know it. Used == to compare two values? That’s Equatable. Used a struct as a dictionary key? That required Hashable. Decoded a JSON response? That was Codable. These aren’t obscure advanced topics — they’re the everyday protocols you’ll add to almost every model struct you write.
By the end of this lesson you’ll know what each of these four protocols does, when to add it, and what Swift handles for you automatically versus what you need to implement yourself.
// Add Equatable so we can use == to compare two Temperatures
struct Temperature: Equatable, Comparable, Hashable {
var celsius: Double
// Comparable requires this one method — Swift synthesizes the rest
static func <(lhs: Temperature, rhs: Temperature) -> Bool {
return lhs.celsius < rhs.celsius
}
}
let boiling = Temperature(celsius: 100)
let freezing = Temperature(celsius: 0)
let body = Temperature(celsius: 37)
print(boiling == freezing) // false — Equatable
print(freezing < boiling) // true — Comparable
print([boiling, freezing, body].sorted().first!.celsius) // 0.0| Line | What it does |
|---|---|
: Equatable, Comparable, Hashable | Declares conformance to three protocols at once. Swift synthesizes Equatable and Hashable automatically since all stored properties are themselves Equatable/Hashable. |
static func <(lhs:rhs:) -> Bool | Comparable requires you implement this one operator. Swift synthesizes >, >=, and <= for free based on it. |
boiling == freezing | Works because Temperature is Equatable. Swift synthesized the == implementation by comparing all stored properties. |
.sorted() | Works because Temperature is Comparable. Any collection of Comparable values can be sorted without passing a custom comparator. |
Equatable or Hashable, just declaring conformance is enough — Swift writes the implementation for you. This only works automatically for structs and enums, not classes.The Four Protocols Every Beginner Needs
struct Color: Equatable { // Swift synthesizes == automatically
var red: Int
var green: Int
var blue: Int
}
let red = Color(red: 255, green: 0, blue: 0)
let red2 = Color(red: 255, green: 0, blue: 0)
let blue = Color(red: 0, green: 0, blue: 255)
print(red == red2) // true — all properties match
print(red == blue) // falseEquatable when you need to compare two instances with ==. It's also required if you want to use .contains() on an array of your custom type. Swift synthesizes the implementation as long as all stored properties are themselves Equatable — which includes String, Int, Double, Bool, and most standard types.struct Player: Comparable {
var name: String
var score: Int
// Required: define what "less than" means for your type
static func <(lhs: Player, rhs: Player) -> Bool {
return lhs.score < rhs.score // sort by score
}
}
var players = [
Player(name: "Alice", score: 92),
Player(name: "Bob", score: 78),
Player(name: "Chen", score: 88)
]
print(players.sorted().last!.name) // Alice — highest scoreEquatable, Comparable is not automatically synthesized for structs — you must implement static func < yourself, because Swift doesn't know which property to sort by. Implement it once and you get .sorted(), .min(), .max(), and all comparison operators for free.struct Point: Hashable { // Hashable implies Equatable
var x: Int
var y: Int
}
// Can now be used as a Set element
var visited: Set<Point> = []
visited.insert(Point(x: 1, y: 2))
visited.insert(Point(x: 1, y: 2)) // duplicate, won't be added
print(visited.count) // 1
// Can also be a dictionary key
var labels: [Point: String] = [:]
labels[Point(x: 0, y: 0)] = "Origin"Hashable includes Equatable — you don't need to list both. Swift synthesizes the hash function automatically for structs where all stored properties are Hashable. You'll need this any time you want your custom type in a Set or as a Dictionary key.struct User: Codable { // Codable = Encodable + Decodable
var name: String
var age: Int
}
// Encode to JSON
let user = User(name: "Alice", age: 28)
let encoder = JSONEncoder()
if let data = try? encoder.encode(user) {
print(String(data: data, encoding: .utf8)!) // {"name":"Alice","age":28}
}
// Decode from JSON
let json = """{"name":"Bob","age":35}""".data(using: .utf8)!
let decoded = try? JSONDecoder().decode(User.self, from: json)
print(decoded?.name ?? "nil") // BobCodable is actually a type alias for Encodable & Decodable. Swift synthesizes everything automatically as long as all stored properties are themselves Codable — which covers String, Int, Double, Bool, arrays and dictionaries of Codable types, and optionals of those. This is how you decode every API response in a real iOS app.first_name) but your Swift property is camelCase (firstName), set decoder.keyDecodingStrategy = .convertFromSnakeCase — no custom code needed.| Protocol | What It Enables |
|---|---|
| Equatable | Compare with == and != · use .contains() on arrays |
| Comparable | Compare with < > · use .sorted() .min() .max() |
| Hashable | Use as Set element or Dictionary key (includes Equatable) |
| Codable | Encode/decode to JSON and other formats (= Encodable + Decodable) |
| Identifiable | Use in SwiftUI List/ForEach — requires var id property |
Create a struct called Movie with properties title: String, year: Int, and rating: Double. Conform it to Equatable, Comparable (sort by rating), Hashable, and Codable. Then: create an array of 3 movies, sort them by rating, check if two are equal, put them in a Set, and encode one to JSON and print the result.
static func < for Comparable. Swift synthesizes the rest.Picture a house that's already built. You can't tear it down and rebuild it from scratch every time you want a new room — but you can add an extension. You're not changing the original structure; you're building onto it. Swift extensions work the same way. You can add new methods, computed properties, and even protocol conformance to any existing type — including types you didn't write, like Apple's own String, Int, or Array.
You've actually already used extensions — in Lesson 8.2 you saw how conformance can be added to a type in an extension block. But extensions can do more than just add protocol conformance. They're a powerful organizational tool and one of the reasons real Swift code stays readable even as it grows large.
By the end of this lesson you'll know how to write extensions on your own types and on Swift's built-in types, what you can and can't add in an extension, and the most common patterns you'll see in real codebases.
// Extend Swift's built-in String type with a new computed property
extension String {
var wordCount: Int {
// split by whitespace and count non-empty components
return self.split(separator: " ").count
}
func shout() -> String {
return self.uppercased() + "!!!"
}
}
let sentence = "Swift is really fun"
print(sentence.wordCount) // 4
print(sentence.shout()) // SWIFT IS REALLY FUN!!!| Line | What it does |
|---|---|
extension String { } | Opens an extension on String. Everything inside is added to every String in your app. You don't own the String type, but you can still extend it. |
var wordCount: Int { ... } | A computed property added via extension. Extensions can add computed properties but not stored properties — stored properties would change the type's memory layout, which isn't allowed. |
self | Inside an extension, self refers to the instance the method is being called on — exactly like inside a struct or class method. |
func shout() -> String { ... } | A new method added to String. After this extension, every string in your project has a .shout() method. |
Extension Patterns
struct Rectangle {
var width: Double
var height: Double
}
// Add geometry helpers in a separate extension
extension Rectangle {
var area: Double { width * height }
var perimeter: Double { 2 * (width + height) }
var isSquare: Bool { width == height }
}extension Int {
var isEven: Bool { self % 2 == 0 }
var squared: Int { self * self }
func times(_ action: () -> Void) {
for _ in 0..self { action() }
}
}
print(7.isEven) // false
print(5.squared) // 25
3.times { print("hello") } // hello hello helloInt, Double, and String with domain-specific helpers is very common in real codebases. Use this power thoughtfully — adding too many extensions on built-in types can make code confusing for other developers to read.struct Product {
var name: String
var price: Double
}
// Add Codable conformance later, in an extension
extension Product: Codable { } // empty — Swift synthesizes everything
// Add a custom description in another extension
extension Product: CustomStringConvertible {
var description: String {
"\(name) — $\(String(format: "%.2f", price))"
}
}CLLocationCoordinate2D (Apple's type) conform to one of your own protocols. This is called retroactive conformance and it's one of the most flexible features of the Swift protocol system.struct ProfileView: View {
var username: String
var body: some View {
VStack { avatarSection; statsSection }
}
}
// Keep sub-views in extensions for readability
extension ProfileView {
var avatarSection: some View {
Image(systemName: "person.circle")
}
var statsSection: some View {
Text(username)
}
}body property becomes a clear table of contents, and each sub-view is its own neat block.| Syntax | What It Does |
|---|---|
| extension TypeName { } | Adds new capabilities to TypeName |
| extension TypeName: Protocol { } | Adds protocol conformance to TypeName |
| var computed: Type { expr } | Adds a computed property (stored properties not allowed) |
| func newMethod() { } | Adds a new method to the type |
| self | Refers to the current instance inside an extension method |
Write an extension on Double that adds three computed properties: asCurrency: String (formatted with 2 decimal places and a $ sign), asPercentage: String (multiplied by 100 with a % sign), and rounded2: Double (rounded to 2 decimal places). Test all three with a few sample values.
String(format: "%.2f", self) formats a Double to 2 decimal places. rounded() is already on Double — think about how to use it for 2 decimal places.some KeywordImagine you're the manager of a team of specialists. Every specialist has their own contract (a protocol). But some things are the same for everyone on the team — like how to write a status report. Instead of putting "write a status report" in every individual contract, you write it once in a shared team handbook that everyone on the team automatically gets. That's a protocol extension: you write default behavior once, and every type that conforms to the protocol gets it automatically.
You've already seen some View in every SwiftUI view you've written. You've probably typed it dozens of times without fully knowing what it means. This lesson finally explains it — at just enough depth to stop the confusion, without getting into advanced generic theory you don't need yet.
By the end of this lesson you'll know how to add default implementations to protocols using protocol extensions, and you'll understand what some means so that var body: some View makes complete sense to you.
protocol Greetable {
var name: String { get }
func greet() -> String // required — but we'll provide a default
}
// Protocol extension — adds a default implementation
extension Greetable {
func greet() -> String {
return "Hello, I'm \(name)!" // default implementation
}
}
// Uses the default greet() — no need to implement it
struct Robot: Greetable {
var name: String
}
// Overrides the default with its own implementation
struct Pirate: Greetable {
var name: String
func greet() -> String {
return "Arrr, the name's \(name)!"
}
}
let r = Robot(name: "R2-D2")
let p = Pirate(name: "Blackbeard")
print(r.greet()) // Hello, I'm R2-D2! ← uses default
print(p.greet()) // Arrr, the name's Blackbeard! ← overrides| Line | What it does |
|---|---|
extension Greetable { } | Opens a protocol extension. Everything added here becomes default behavior for all types that conform to Greetable. |
func greet() -> String { ... } | A default implementation of the protocol's required method. Types that don't provide their own implementation will use this one automatically. |
struct Robot: Greetable | Robot only provides name. It doesn't implement greet() — but that's fine because the protocol extension provides a default. |
func greet() in Pirate | Pirate provides its own greet(), which overrides the default. Conforming types can always replace a default implementation with their own. |
The some Keyword Demystified
// This would be a problem — "any View" loses type information
// func makeView() -> any View { Text("hello") }
// some View says: "I return ONE specific View type, I just won't say which"
func makeView() -> some View {
Text("Hello from a function") // Swift knows the return type is Text
}
// Every SwiftUI view uses this exact pattern
struct WelcomeView: View {
var body: some View { // "body returns some specific View type"
Text("Welcome!") // specifically it's a Text — Swift knows
}
}some View means "this returns one specific concrete type that conforms to View, and I promise it will always be the same type." Swift uses this guarantee to optimize the code. You don't have to write the full type — Text or VStack<TupleView<...>> — you just say some View and let Swift figure it out.// any: "could be any conforming type — unknown at compile time"
let vehicles: [any Vehicle] = [Car(), Bicycle(), Truck()]
// some: "one specific type — Swift knows which at compile time"
func makeVehicle() -> some Vehicle {
return Car() // always a Car — never changes
}any when you need a collection of mixed types that all conform to a protocol — the type can vary. Use some when returning a single value of a specific type that you don't want to write out — the type is fixed, just hidden. In SwiftUI, some View is always the right choice for body.protocol Summarizable {
var title: String { get }
var content: String { get }
}
// Add a bonus method not listed in the protocol
extension Summarizable {
func preview() -> String {
let snippet = String(content.prefix(80))
return "\(title): \(snippet)..."
}
}
struct Article: Summarizable {
var title: String
var content: String
}
let a = Article(title: "Swift Tips", content: "Extensions are powerful...")
print(a.preview()) // Swift Tips: Extensions are powerful.....sorted(), .map(), and .filter() on every collection type.// Only add this method to arrays of Equatable elements
extension Array where Element: Equatable {
func countOccurrences(of item: Element) -> Int {
return self.filter { $0 == item }.count
}
}
let scores = [95, 78, 95, 88, 95]
print(scores.countOccurrences(of: 95)) // 3where clause adds a condition to the extension — this behavior only appears on arrays whose Element type is Equatable. This is an intermediate-level pattern, but you'll start recognizing it in Swift documentation and your IDE autocomplete. You don't need to master it now — just know it exists.some View in SwiftUI, it means "there is exactly one concrete View type here, and Swift knows what it is, even if you don't write it out." That's why you can't return two different view types from the same body property without wrapping them — some means one specific type.| Syntax | What It Does |
|---|---|
| extension Protocol { func x() { } } | Adds a default implementation of x() to all conforming types |
| some Protocol | Return type: one specific concrete type conforming to Protocol |
| any Protocol | Variable/param type: any conforming type, unknown until runtime |
| var body: some View | The standard SwiftUI pattern — body returns a specific View type |
| extension X where T: Y { } | Adds behavior only when a type parameter also meets condition Y |
Define a protocol called Printable with a required property summary: String { get }. Write a protocol extension that adds a default printSummary() method that prints the summary with a prefix like "Summary: ...". Create two structs that conform to Printable — one that uses the default printSummary() and one that overrides it with custom behavior. Call printSummary() on both.
self.summary to access the required property — because any conforming type is guaranteed to have it.Stage 8 Recap: Protocols and Extensions
Protocols and extensions are two of Swift's most important building blocks. Together they let you write flexible, organized code that scales cleanly as your apps grow. Here's what you covered in this stage:
- Lesson 8.1 — What a Protocol Is: A protocol is a contract that defines requirements — properties and methods — without providing implementations. Any type that conforms to the protocol must fulfill those requirements.
- Lesson 8.2 — Conforming to a Protocol: Conformance is declared with a colon, just like with a struct. Swift verifies at compile time that every requirement is fulfilled. You can add conformance inline or in a separate extension block.
- Lesson 8.3 — Built-in Protocols: Equatable lets you use ==, Comparable enables sorting, Hashable allows use in Sets and as Dictionary keys, and Codable powers JSON encode/decode. Swift synthesizes most of these automatically for structs.
- Lesson 8.4 — Extensions: Extensions let you add computed properties, methods, and protocol conformance to any existing type — including types you don't own. They're a core tool for keeping code organized as it grows.
- Lesson 8.5 — Protocol Extensions and some: Protocol extensions add default implementations shared by all conforming types. The
somekeyword means "one specific type conforming to this protocol" — and it's the reasonvar body: some Viewworks in every SwiftUI view you write.
If you skipped any of the challenges, now is a great time to go back and complete them. Protocols and extensions are concepts that really click through practice, not just reading.
Up next is Stage 9: Closures — the powerful functions-as-values concept that powers .map(), .filter(), .forEach(), and SwiftUI animations. Everything you just learned about protocols will make closures easier to understand, because many protocol requirements are fulfilled using closures under the hood.
Learn Swift Stage 9: Closures
You already know how to write functions. Closures are what happens when functions grow up — they can be stored in variables, passed as arguments, and returned from other functions, all without needing a name.
All you need for this stage is Xcode and a Swift Playground — no project setup required. There are 6 lessons, each ranging from 20 to 35 minutes. Don't skip the challenge at the end of each lesson. Reading through the examples builds familiarity, but actually writing the code yourself is where the concept stops feeling abstract and starts feeling real.
By the end of Stage 9 you'll be able to write closures using both full and shorthand syntax, explain why trailing closure syntax exists and recognize it in SwiftUI code, understand how closures capture values from their surrounding context, know what @escaping means and why the compiler asks for it, and use map, filter, and reduce to transform collections. Closures are the concept that trips up more beginners than almost anything else in Swift. Finishing this stage is a genuine milestone.
Think about a sticky note. You can write instructions on it, hand it to someone else, stick it to the fridge, or save it for later. The instructions don't disappear just because you wrote them down — they travel with the note. A closure works the same way: it's a chunk of code you can write once, store, pass around, and execute whenever you need it.
In Stage 4 you learned to write functions with names. A closure is essentially the same thing — a block of code with parameters and a return value — except it doesn't need a name. You've actually been using closures without realizing it. Every time you've seen code inside curly braces being passed to something (like a button action in SwiftUI), that was a closure.
By the end of this lesson you'll understand what a closure actually is at a conceptual level, how it relates to the named functions you already know, and how to store a simple closure in a variable so you can call it later.
// A regular named function you already know
func greet(name: String) -> String {
return "Hello, \(name)!"
}
// The same logic written as a closure stored in a variable
let greetClosure: (String) -> String = { name in
return "Hello, \(name)!"
}
// Calling them looks almost identical
print(greet(name: "Mia")) // Hello, Mia!
print(greetClosure("Mia")) // Hello, Mia!| Line | What it does |
|---|---|
func greet(name:) -> String | A regular named function — you've written these since Stage 4. It has a name, a parameter, and a return type. |
let greetClosure: (String) -> String | A constant whose type is "a closure that takes a String and returns a String". The type annotation describes the shape of the closure. |
= { name in | The opening of the closure. The curly braces wrap the code, and name in names the incoming parameter. The keyword in separates the parameter list from the body. |
return "Hello, \(name)!" | The body of the closure — same as it would be inside a regular function. |
greetClosure("Mia") | Calling a closure stored in a variable. Notice there are no argument labels — closures don't use them the way named functions do. |
greet(name: "Mia"). When you call a closure you don't: greetClosure("Mia"). Closures don't have external argument labels. This catches beginners off guard the first time.Ways to Think About Closures
let sayHello: () -> Void = {
print("Hello!")
}
sayHello() // Hello!() -> Void means "takes no parameters and returns nothing". This is the simplest possible closure type. You'll see this pattern a lot for button actions and callbacks.let doubleIt: (Int) -> Int = { number in
return number * 2
}
let result = doubleIt(5)
print(result) // 10doubleIt. You can call it as many times as you want after that, just like a regular function.// A function that accepts a closure as a parameter
func runTwice(action: () -> Void) {
action()
action()
}
// Passing a closure in — code to run twice
runTwice(action: {
print("Swift is fun!")
})
// Swift is fun!
// Swift is fun!runTwice doesn't know or care what code it runs — the caller decides by passing in a closure. This is how SwiftUI's Button works with its action parameter.func makeMultiplier(factor: Int) -> (Int) -> Int {
return { number in
return number * factor
}
}
let triple = makeMultiplier(factor: 3)
print(triple(7)) // 21(Int) -> Int means this function returns a closure. You'll understand the factor capture in Lesson 9.4 — for now just notice that the returned closure remembers the factor value it was created with.| Syntax | What It Does |
|---|---|
| { } | The curly braces that wrap a closure's body |
| { name in ... } | A closure with one parameter called name |
| () -> Void | Type for a closure that takes nothing and returns nothing |
| (String) -> String | Type for a closure that takes a String and returns a String |
| let x: (Int) -> Int = { ... } | Storing a closure in a constant |
| x(5) | Calling a closure stored in variable x — no argument labels |
Create a constant called square that stores a closure. The closure should take an Int, multiply it by itself, and return the result. Call it with a few different numbers and print each result. Then write a function called applyTwice that takes an Int and a closure of type (Int) -> Int, applies the closure to the number twice (using the output of the first call as the input to the second), and returns the final result.
applyTwice, call the closure once and store the result in a variable, then call the closure again with that variable.Imagine receiving a set of instructions that starts out very formal and verbose — every step labeled, every term defined — and then over time you and the person you're working with develop a shorthand. You both know what you mean, so you drop the obvious parts. Swift's closure syntax works exactly like that. The full form says everything explicitly. Each shorthand step drops something that Swift can already figure out on its own.
This is the most important lesson in Stage 9. The reason closures look confusing in real code is that you're usually seeing the final shorthand form, with most of the syntax stripped away. By working through each reduction one step at a time — starting from the full explicit version — the shorthand stops looking like magic and starts looking like a logical compression.
By the end of this lesson you'll be able to write a closure in its full form and explain exactly why each shorthand version is valid. You'll finally understand what $0 and $1 mean, and you'll be able to read the compact closure syntax you see throughout Swift's standard library.
We'll use the same example throughout all five steps: a closure that takes two integers and returns their sum. Let's start with the most explicit version possible.
// Step 0: A named function for comparison — the baseline
func add(a: Int, b: Int) -> Int {
return a + b
}
// Step 1: Full explicit closure — nothing omitted
let addFull: (Int, Int) -> Int = { (a: Int, b: Int) -> Int in
return a + b
}
print(addFull(3, 4)) // 7| Part | What it does |
|---|---|
let addFull: (Int, Int) -> Int | The type annotation on the variable. Tells Swift: this constant holds a closure that takes two Ints and returns an Int. |
{ (a: Int, b: Int) -> Int in | The opening of the closure. The parameter names and types are declared inside the braces, then in marks where the body begins. |
return a + b | The body of the closure. Same as a function body. |
: (Int, Int) -> Int) and again inside the closure itself (a: Int, b: Int). That's redundant. The next step removes that redundancy.The Five-Step Reduction
// Swift already knows the types from the annotation on the left
// So we can drop them from inside the closure
let addStep2: (Int, Int) -> Int = { (a, b) in
return a + b
}
print(addStep2(3, 4)) // 7(Int, Int) -> Int, Swift can infer the types of a and b. Writing them inside the closure was redundant, so we removed them. The parentheses around (a, b) are optional at this point and often dropped too.// When the closure body is a single expression,
// Swift returns it automatically — no "return" needed
let addStep3: (Int, Int) -> Int = { a, b in
a + b
}
print(addStep3(3, 4)) // 7return.// Swift provides automatic names $0, $1, $2 for each parameter
// When you use them, you drop the parameter list and "in" entirely
let addStep4: (Int, Int) -> Int = { $0 + $1 }
print(addStep4(3, 4)) // 7$0 means "the first argument", $1 means "the second argument". When you use shorthand names, you can drop the a, b in part entirely. This is the form you'll see most often in Swift standard library calls.// For very simple closures like adding two Ints,
// Swift accepts an operator as the entire closure
let numbers = [3, 1, 4, 1, 5]
let sorted = numbers.sorted(by: <) // [1, 1, 3, 4, 5]
print(sorted)< operator is a function that takes two values and returns a Bool — exactly what sorted(by:) expects. So you can pass it directly. This is the most extreme shorthand and only works in specific contexts, but it's worth recognizing when you see it.// Step 1 — Full explicit
{ (a: Int, b: Int) -> Int in return a + b }
// Step 2 — Types inferred
{ (a, b) in return a + b }
// Step 3 — Implicit return
{ a, b in a + b }
// Step 4 — Shorthand argument names
{ $0 + $1 }
// Step 5 — Operator shorthand (only works in specific contexts)
+| Syntax | What It Does |
|---|---|
| { (a: Int) -> Int in return a } | Full explicit form — nothing omitted |
| { (a, b) in return a + b } | Types inferred from context |
| { a, b in a + b } | Implicit return (single expression) |
| { $0 + $1 } | Shorthand argument names, no parameter list |
| $0 | First argument to a closure |
| $1 | Second argument to a closure |
Write a closure that takes two String values and returns the longer one (use .count to compare lengths — if they're equal, return the first one). Write this closure five times, once at each level of verbosity from this lesson: full explicit, types inferred, implicit return, shorthand argument names, and — if you can figure out a way — using an operator or method shorthand. Confirm all five produce the same result.
$0.count >= $1.count ? $0 : $1. For Step 5, think about whether sorted(by:) with a string comparison method could apply here.Picture a recipe that ends with a long set of finishing instructions. Instead of cramming those instructions into the middle of the recipe card, you'd write "see back of card" and put them separately where they're easier to read. Trailing closure syntax is Swift's version of that. When a closure is the last argument to a function, Swift lets you pull it out of the parentheses and write it after them — making the code easier to read.
You've already seen trailing closure syntax in action. Every time you've written a SwiftUI Button with its action in a separate set of braces, or used ForEach with content in braces — that's trailing closure syntax. It wasn't a special SwiftUI feature. It's a core Swift language rule that SwiftUI takes full advantage of.
By the end of this lesson you'll understand exactly why trailing closure syntax exists, how to write it, what happens when a function has multiple trailing closure parameters, and why so much SwiftUI code looks the way it does.
// A function that takes a closure as its last argument
func repeat(times: Int, action: () -> Void) {
for _ in 0..times {
action()
}
}
// Standard syntax — closure is inside the parentheses
repeat(times: 3, action: {
print("Standard")
})
// Trailing closure syntax — closure moves outside the parentheses
repeat(times: 3) {
print("Trailing")
}| Part | What it does |
|---|---|
action: () -> Void | The last parameter is a closure. This is the condition that makes trailing syntax available. |
repeat(times: 3, action: { ... }) | Standard syntax — the closure sits inside the parentheses as a labeled argument. |
repeat(times: 3) { ... } | Trailing syntax — the closure moves after the closing parenthesis. The label action: is dropped. Both versions are identical to Swift. |
Button("Tap me") { handleTap() } is trailing closure syntax. The button's action closure is the last parameter, so it moves outside the parentheses. VStack { ... } is the same thing — the content is a closure that's the last (and only) parameter.Trailing Closure Patterns
func doSomething(action: () -> Void) {
action()
}
// When the closure is the only argument, omit () entirely
doSomething {
print("Just a trailing closure")
}VStack { }, Group { }, and many others work exactly this way.func loadData(onSuccess: () -> Void, onFailure: () -> Void) {
// simulated success
onSuccess()
}
// Multiple trailing closures — first one has no label,
// subsequent ones use their parameter labels
loadData {
print("Data loaded!")
} onFailure: {
print("Something went wrong.")
}Alert and animation APIs.func transform(value: Int, using: (Int) -> Int) -> Int {
return using(value)
}
let result = transform(value: 10) { $0 * 3 }
print(result) // 30| Syntax | What It Does |
|---|---|
| func f(action: () -> Void) | Declares a function whose last parameter is a closure |
| f(action: { ... }) | Standard call — closure inside parentheses with label |
| f { ... } | Trailing syntax — closure outside parentheses, no label |
| f(x: 5) { ... } | Trailing syntax when there are other non-closure parameters |
| f { } label: { } | Multiple trailing closures — first unlabeled, rest labeled |
Write a function called buildGreeting that takes a name as a String and a closure of type (String) -> String. The function should call the closure with the name and print the result. Call it twice: once using standard syntax with the closure inside parentheses, and once using trailing closure syntax. Then write a second function called attempt that takes two closures — an onSuccess and an onFailure, both of type () -> Void — and calls the first one. Call it using multiple trailing closure syntax.
onFailure: { } right after the first closing brace.Imagine giving someone a polaroid photo before they leave on a trip. That photo is a snapshot of a moment in time — even after the scene changes, the person has a copy of what it looked like when they left. A closure that captures a value works the same way: it grabs a copy of (or a reference to) a variable from its surrounding context and keeps it, even after the code that created that variable has finished running.
You actually saw this in Lesson 9.1 when makeMultiplier(factor:) returned a closure that used factor — a variable from the outer function. That's value capture in action. The closure "closes over" its environment, which is where the term "closure" comes from.
By the end of this lesson you'll understand what a capture list is, why closures capture values, what [weak self] means and when you need it, and how to recognize when capturing might cause problems you need to think about.
// A counter using value capture
func makeCounter() -> () -> Int {
var count = 0 // local variable in makeCounter
let increment: () -> Int = { // closure captures count
count += 1 // modifies the captured variable
return count // returns its current value
}
return increment // returns the closure itself
}
let counter = makeCounter() // makeCounter finishes, but count lives on
print(counter()) // 1
print(counter()) // 2
print(counter()) // 3
let counter2 = makeCounter() // new closure, its own independent count
print(counter2()) // 1 — starts fresh| Concept | What it means |
|---|---|
| Capture | The closure "grabs" the count variable from its surrounding scope and keeps a reference to it. Even after makeCounter() returns, count lives on inside the closure. |
| Reference capture | By default, closures capture variables by reference. When the closure modifies count, it modifies the same variable — not a copy. That's why it increments persistently. |
| Independent instances | counter2 is a new closure with its own capture of a new count variable. Calling counter2() starts at 1, independently. |
Capture Lists
var score = 100
// Capture by reference — sees future changes to score
let byRef = { print(score) }
// Capture by value — takes a snapshot of score right now
let byVal = { [score] in print(score) }
score = 200 // change score after closures are created
byRef() // 200 — sees the updated value
byVal() // 100 — sees the value from when it was createdin are a capture list. Writing [score] tells Swift to make a copy of score at the moment the closure is created. Later changes to the original variable don't affect the captured copy.class DataLoader {
var results: [String] = []
func load() {
// [weak self] prevents a retain cycle
// self becomes Optional inside the closure
someAsyncOperation { [weak self] in
self?.results.append("new item")
}
}
}self, it creates a strong reference by default. If the object also holds a reference to the closure, neither can be freed — a retain cycle. [weak self] makes self Optional inside the closure, breaking the cycle. That's why you see self?. rather than self. inside closures with [weak self].class ViewController {
func setup() {
// unowned: self will definitely exist when closure runs
someOperation { [unowned self] in
print(self) // no optional needed — but crashes if self is gone
}
}
}[unowned self] avoids retain cycles like [weak self], but doesn't make self Optional. This means you don't need self?., but it will crash if the object has been deallocated when the closure runs. When in doubt, prefer [weak self] — it's safer.self, add [weak self]. It's a good habit that prevents a common class of memory bugs.| Syntax | What It Does |
|---|---|
| { print(x) } | Captures x by reference — sees future changes |
| { [x] in print(x) } | Captures x by value — snapshot at creation time |
| { [weak self] in self?.method() } | Weak capture of self — prevents retain cycles, self is Optional |
| { [unowned self] in self.method() } | Unowned capture — no retain cycle, not Optional, can crash |
| [x, y] in | Capture list with multiple values |
Declare a variable var multiplier = 2. Create two closures that each take an Int and return an Int — the first captures multiplier by reference, the second captures it by value using a capture list. Change multiplier to 10 after both closures are created. Call both closures with the same input and print the results. Explain in a comment why the results differ.
{ [multiplier] in ... }. The by-reference closure will use the updated value of 10; the by-value closure will still use 2.Picture a valet taking your car keys when you arrive at a restaurant. Most of the time, the valet holds the keys temporarily — they're used while you're at dinner and given back when you leave. That's like a normal closure: it's used and released within the function's lifetime. But sometimes a valet parks your car overnight and uses the keys long after you've gone home. That's an escaping closure: it "escapes" the function it was passed to and is used after that function has already returned.
This comes up most often with asynchronous code. When you make a network request, you pass a completion handler — a closure that runs when the data arrives, which might be seconds after the function that started the request has already finished. That completion handler needs to be marked @escaping because it escapes the function's scope.
By the end of this lesson you'll understand what @escaping means, why the Swift compiler requires it, where you'll encounter it in real iOS development, and why it matters for how you use self inside those closures.
// Non-escaping: closure is called immediately, within the function
func doNow(action: () -> Void) {
action() // called here, inline, function is still alive
}
// Escaping: closure is stored and called later, after function returns
var savedActions: [() -> Void] = []
func saveForLater(action: @escaping () -> Void) {
savedActions.append(action) // stored — escapes the function
}
saveForLater { print("Running later!") }
saveForLater { print("Also later!") }
// Run them all at some point after saveForLater has returned
savedActions.forEach { $0() }
// Running later!
// Also later!| Part | What it does |
|---|---|
@escaping () -> Void | The @escaping attribute marks this closure as one that will outlive the function. Swift requires this annotation whenever a closure is stored or passed to another async operation. |
savedActions.append(action) | This is the reason @escaping is needed. Storing the closure in an external array means it will still be alive after saveForLater returns — it has "escaped". |
| Non-escaping (no annotation) | When a closure is NOT escaping, Swift can optimize it. It's called within the function's lifetime and then discarded. This is the default and the most common case. |
@escaping forces you to acknowledge this explicitly and prompts you to think about capture semantics like [weak self].@escaping in Real iOS Code
// A function that fetches data and calls a completion handler when done
func fetchUser(id: Int, completion: @escaping (String) -> Void) {
// Simulating async work — imagine a real network call here
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
completion("User #\(id)") // called 1 second later
}
}
// Usage — the closure runs after fetchUser has already returned
fetchUser(id: 42) { userName in
print("Got user: \(userName)")
}fetchUser returns immediately, but the completion handler runs a second later when the data arrives. The closure must be @escaping because it's being passed to DispatchQueue.main.asyncAfter, which will call it in the future.class ProfileViewModel {
var userName: String = ""
func loadProfile() {
// @escaping + [weak self] is the standard pattern
fetchUser(id: 1) { [weak self] user in
self?.userName = user // self? because it might be nil
}
}
}ProfileViewModel has been deallocated, you use [weak self] to avoid a retain cycle and prevent a crash. This @escaping + [weak self] combination is one of the most common patterns in UIKit-based iOS code.// No @escaping needed — closure is called synchronously and discarded
func applyToAll(_ items: [Int], transform: (Int) -> Int) -> [Int] {
return items.map(transform) // used right here, synchronously
}
// Closures passed to map, filter, sort are non-escapingmap, filter, sorted, and similar higher-order functions — are non-escaping. You only need to think about @escaping when the closure is being stored or passed to an asynchronous operation. The compiler will tell you if you forget.| Syntax | What It Does |
|---|---|
| action: () -> Void | Non-escaping (default) — used and released within the function |
| action: @escaping () -> Void | Escaping — closure may be stored or called after the function returns |
| @escaping + stored in property | Common case: closure is saved for later use |
| @escaping + async operation | Common case: passed to DispatchQueue, Timer, URLSession, etc. |
| [weak self] in @escaping closure | Standard pattern to avoid retain cycles in class-based code |
Create a struct called EventBus with a property var listeners: [() -> Void] = []. Add a mutating func register(listener:) method that accepts an @escaping closure and appends it to listeners. Add a func fire() method that calls every listener. Register three different closures that print different messages, then call fire() and confirm all three run. Pay attention to why @escaping is required here.
@escaping because you're appending it to an array — storing it beyond the lifetime of the register function. If you omit @escaping, the compiler will tell you to add it.Think of a production line in a factory. map is the machine that stamps every item as it passes through — transforming each one. filter is the quality inspector who waves some items through and sends others back. reduce is the final step that takes the whole batch and combines it into one thing — a total count, a sum, a single string. Three operations, all working on collections, each using a closure to describe what to do.
You've used arrays since Stage 5. You've written for loops to process them. Now that closures make sense, you can replace most of those loops with map, filter, and reduce — and the code will often be shorter, clearer, and easier to read at a glance.
By the end of this lesson you'll be able to transform an array using map, select elements from an array using filter, combine an array into a single value using reduce, and chain all three together when needed.
let scores = [72, 88, 45, 95, 61, 90]
// map — transform every element
let grades = scores.map { score in
score >= 90 ? "A" : score >= 80 ? "B" : score >= 70 ? "C" : "F"
}
// ["C", "B", "F", "A", "F", "A"]
// filter — keep only elements that pass the test
let passing = scores.filter { $0 >= 70 }
// [72, 88, 95, 90]
// reduce — combine all elements into one value
let total = scores.reduce(0) { $0 + $1 }
// 451
print(grades) // ["C", "B", "F", "A", "F", "A"]
print(passing) // [72, 88, 95, 90]
print(total) // 451| Function | What it does |
|---|---|
.map { } | Creates a new array of the same length by transforming each element. The closure takes one element and returns the transformed version. The result can be a different type than the original. |
.filter { } | Creates a new array containing only elements for which the closure returns true. The result is the same type as the original, but shorter. |
.reduce(0) { $0 + $1 } | Combines all elements into a single value. The first argument is the starting value. The closure takes the running total and the next element, and returns the new running total. |
map/filter/reduce tends to be clearer for simple transformations because the intent is stated upfront: "transform this", "filter this", "reduce this to a single value".Each Function in Depth
let names = ["alice", "bob", "charlie"]
// Transform each String to uppercase
let uppercased = names.map { $0.uppercased() }
// ["ALICE", "BOB", "CHARLIE"]
// Transform to character counts — output type changes to Int
let lengths = names.map { $0.count }
// [5, 3, 7]map always produces an array with the same number of elements as the input. The output type can be anything — converting an array of Strings to an array of Ints is perfectly valid. Think of it as applying the same operation to every element in one step.let words = ["swift", "apple", "ios", "swiftui", "xcode"]
// Keep only words that contain "swift"
let swiftWords = words.filter { $0.contains("swift") }
// ["swift", "swiftui"]
// Keep only long words
let longWords = words.filter { $0.count > 4 }
// ["apple", "swiftui", "xcode"]filter must return a Bool. Elements where the closure returns true are included in the result; elements where it returns false are dropped. The resulting array may be shorter than the original.let prices = [9.99, 14.99, 4.99, 29.99]
// Sum all prices — start at 0.0, add each price to the running total
let total = prices.reduce(0.0) { runningTotal, price in
runningTotal + price
}
// 59.96
// Build a sentence from an array of words
let sentence = ["Swift", "is", "great"].reduce("") { $0 + ($0.isEmpty ? "" : " ") + $1 }
// "Swift is great"reduce takes two arguments: an initial value (the starting point) and a closure that takes the running result and the next element, returning the new running result. It can produce any type — a sum, a string, even another array.let transactions = [50, -20, 200, -5, 75, -100]
// Step 1 — filter: keep only positive transactions
// Step 2 — map: apply a 10% bonus
// Step 3 — reduce: sum them all up
let bonusTotal = transactions
.filter { $0 > 0 }
.map { Double($0) * 1.1 }
.reduce(0, +)
print(bonusTotal) // 357.5+ passed to reduce is Step 5 of the syntax reduction from Lesson 9.2 — the operator itself as a closure.let strings = ["1", "two", "3", "four", "5"]
// Int("two") returns nil — compactMap removes those
let numbers = strings.compactMap { Int($0) }
// [1, 3, 5]
print(numbers)compactMap is like map but the closure returns an optional. Any element where the closure returns nil is automatically removed. This is extremely useful when converting strings to typed values where some conversions might fail.| Function | What It Does |
|---|---|
| array.map { transform($0) } | Returns a new array of the same count with each element transformed |
| array.filter { $0 > 0 } | Returns a new array containing only elements where closure is true |
| array.reduce(0) { $0 + $1 } | Combines all elements into a single value starting from initial value |
| array.reduce(0, +) | Shorthand reduce using operator as closure |
| array.compactMap { Int($0) } | Like map, but removes nil results from the output |
| array.filter{}.map{}.reduce() | Chain operations into a readable data pipeline |
Start with this array: let scores = [84, 37, 92, 61, 78, 55, 99, 44, 72, 88]. Use filter to keep only passing scores (60 and above). Use map to convert each passing score to a letter grade: 90+ is "A", 80+ is "B", 70+ is "C", 60+ is "D". Use reduce to count how many "A" grades there are. Finally, chain all three operations together in a single expression. Print the grade array and the A count.
reduce(0) { $1 == "A" ? $0 + 1 : $0 } is one approach. Alternatively, after getting the grades array, call .filter { $0 == "A" }.count.Stage 9 Recap: Closures
You just finished one of the most important stages in the entire Learn Swift curriculum. Closures are the concept that separates beginners who are "following along" from developers who genuinely understand the language — and you've now done the hard work of building that understanding one step at a time.
- Lesson 9.1 — What a Closure Is: Closures are unnamed, storable functions. They can be stored in variables, passed as arguments to other functions, and returned from functions — giving you flexible, reusable blocks of behavior.
- Lesson 9.2 — Closure Syntax Step by Step: Swift closure syntax can be progressively reduced from its full explicit form all the way down to shorthand argument names like
$0and$1. Each reduction is valid because Swift infers the missing information from context. - Lesson 9.3 — Trailing Closure Syntax: When a closure is the last argument to a function, you can move it outside the parentheses. This is how most SwiftUI code is structured —
Button,VStack,ForEach, and more all use trailing closures. - Lesson 9.4 — Capturing Values: Closures capture variables from their surrounding scope, either by reference (the default) or by value using a capture list. The
[weak self]capture pattern prevents retain cycles in class-based code. - Lesson 9.5 — @escaping Closures: A closure that will be stored or called after its enclosing function returns must be marked
@escaping. This pattern shows up constantly in completion handlers for networking and asynchronous operations. - Lesson 9.6 — map, filter, and reduce: These three higher-order functions use closures to transform collections cleanly and expressively.
maptransforms,filterselects, andreducecombines — and they can be chained together into readable data pipelines.
If you skipped any challenges, go back and do them. The challenges are where the concepts actually land — reading about closures and writing closures are very different experiences. The discomfort of figuring it out yourself is the learning.
Stage 10 covers error handling — throws, try, catch, and Result. Closures come up directly in Stage 10: completion handlers that return a Result type are one of the most common patterns in real iOS networking code, and understanding the closure side of that pattern is exactly what you just built here.
Learn Swift Stage 10: Error Handling
Every app you've ever loved has code running behind the scenes that assumes something might go wrong — and Stage 10 is where you learn to write code that's ready for it.
All you need for this stage is Xcode and a Swift Playground. There are 5 lessons with an estimated time of 20 to 35 minutes each, totalling about 2 hours. Each lesson ends with a challenge — don't skip it. That's where the concept actually moves from "I think I get it" to "I can do this myself."
By the end of Stage 10, you'll be able to mark a function as throws and explain why, call a throwing function using try inside a do/catch block, define your own custom error types using an enum that conforms to the Error protocol, choose confidently between try? and try!, and decide when a function should throw versus return an optional.
Imagine you ask a friend to look up a contact in a phone book and they tell you "it might not be there." That's an optional. Now imagine you ask them to go to a store, buy milk, and come back — but the store might be closed, they might not have the right milk, or the card might be declined. Returning nil doesn't tell you which thing went wrong. That's the problem error handling solves.
You already know optionals from Stage 6. Optionals are perfect when the only thing that can go wrong is "there's no value." But real apps do things that can fail in multiple distinct ways: loading a file, parsing JSON, converting user input. When any of those fail, you want to know why — not just that they did.
By the end of this lesson, you'll understand the difference between the two failure models, when each is appropriate, and why Swift's error handling system is designed to force you to acknowledge failures instead of quietly ignoring them.
The Problem with Returning nil
Consider a function that converts a string the user typed into an age:
// Returns nil if conversion fails — but WHY did it fail?
func parseAge(from input: String) -> Int? {
guard let age = Int(input) else {
return nil // Could be non-numeric, negative, or over 150 — same nil for all
}
guard age >= 0 else { return nil }
guard age <= 150 else { return nil }
return age
}
let result = parseAge(from: "abc")
// result is nil — but we don't know if it was non-numeric, negative, or too high| Line | What it does |
|---|---|
-> Int? | The return type is an optional Int — it can be a number or nil. |
guard let age = Int(input) | Tries to convert the string to an Int. If it can't, returns nil. |
guard age >= 0 | Checks the age is not negative. If it is, returns nil again. |
guard age <= 150 | Checks the age isn't unrealistically high. Returns nil if so. |
The caller gets nil | All three failure cases look identical to the caller. There's no way to know which one triggered. |
nil communicates "it failed" but not "here's why." The caller can't make a smart decision — should it show "please enter a number" or "age cannot be negative"? It can't tell. Error handling lets you pass that information along.What Happens at Runtime vs Compile Time
Xcode catches many problems before you even run your app — type mismatches, missing arguments, using a variable before it's defined. These are compile-time errors. But some things can only go wrong while the app is actually running. A file might not exist. The network might be down. A date string might be in the wrong format. Swift's error handling system deals with these runtime failures.
let name: Int = "hello" // Error: cannot assign String to Int// Looks fine to the compiler — but what if the file doesn't exist?
let data = try Data(contentsOf: someURL)// Optional: one failure mode, no explanation needed
func findUser(id: Int) -> User? { /* returns nil if not found */ }
// Throwing: multiple failure modes, explanation matters
func loadUserProfile(id: Int) throws -> UserProfile { /* can fail in 3 ways */ }Quick Reference: Optionals vs Errors
| Situation | Use |
|---|---|
| Value might simply not exist | Optional (Int?, String?) |
| Operation can fail in one known way | Optional is fine |
| Operation can fail in multiple distinct ways | Throwing function |
| Caller needs to know why it failed | Throwing function |
| Failure should not be silently ignored | Throwing function (Swift forces you to handle it) |
You have a function that takes a username string and validates it for an app's sign-up form. It should reject usernames that are empty, shorter than 3 characters, longer than 20 characters, or contain spaces.
Write it first using an optional return type. Then write it a second time as a throwing function. Which version gives better feedback to the caller? Write a comment explaining which approach you'd use in a real app and why.
throws syntax yet — use a placeholder comment like // would throw a "too short" error here. The goal is just to think through the two models.Think about ordering at a restaurant. You make a request (a function call), the kitchen either prepares your meal (success) or comes back to tell you the problem (an error is thrown). You can't just ignore the waiter if they come back — you have to respond. That's exactly how throws, try, and do/catch work together.
This builds directly on your knowledge of functions from Stage 4. A throwing function is just a regular function with one extra ability: it can stop mid-execution and signal to the caller that something went wrong. The caller uses try to say "I know this might fail" and wraps the call in a do/catch block to handle both outcomes.
By the end of this lesson, you'll be able to write a function that throws an error, call it correctly using try, and catch the failure with a do/catch block — the complete pattern you'll use everywhere in real iOS apps.
The Basic Pattern
import Foundation
// Step 1: Define an error type (a simple enum for now)
enum AgeError: Error {
case notANumber // input wasn't numeric
case tooYoung // age was negative
case unrealistic // age was over 150
}
// Step 2: Mark the function with "throws" after parameters
func parseAge(from input: String) throws -> Int {
guard let age = Int(input) else {
throw AgeError.notANumber // throw stops the function and passes the error up
}
guard age >= 0 else {
throw AgeError.tooYoung
}
guard age <= 150 else {
throw AgeError.unrealistic
}
return age // only reached if all checks pass
}
// Step 3: Call it with "try" inside a do/catch block
do {
let age = try parseAge(from: "25")
print("Valid age: \(age)") // prints "Valid age: 25"
} catch AgeError.notANumber {
print("Please enter a number")
} catch AgeError.tooYoung {
print("Age cannot be negative")
} catch AgeError.unrealistic {
print("That doesn't seem right")
} catch {
print("Something went wrong: \(error)") // catch-all for anything unexpected
}| Keyword / Line | What it does |
|---|---|
enum AgeError: Error | Defines possible error cases. The : Error part tells Swift this enum represents error types. |
throws in the signature | Marks the function as one that might throw. This is a promise to the caller: "you'll need to handle my failure." |
throw AgeError.notANumber | Immediately stops the function and sends this error to the caller. Nothing after this line runs. |
try parseAge(from:) | Calls a throwing function. The try keyword is required — Swift forces you to acknowledge the call can fail. |
do { } | Wraps the risky code. If the try line throws, execution jumps to the matching catch. |
catch AgeError.notANumber { } | Handles one specific error case. You can have multiple catch clauses for different errors. |
catch { } | A catch-all that handles anything not caught by the specific clauses above. Good practice to always include one. |
try, Xcode shows an error: "Expression is not 'try' and the called function throws." And if you use try without a do/catch block, Xcode asks you to handle the error. Swift doesn't let you silently ignore throwing functions.Variations on the do/catch Pattern
do {
let age = try parseAge(from: userInput)
print("Got age: \(age)")
} catch {
// "error" is automatically available — it's the thrown value
print("Failed: \(error)")
}catch handles everything. The implicit error constant is always available inside a bare catch block.do {
let age = try parseAge(from: userInput)
print("Got age: \(age)")
} catch let e as AgeError {
// Only catches AgeError types, binds to "e"
print("Age error: \(e)")
} catch {
print("Other error: \(error)")
}catch let e as SomeError. Useful when you're calling functions that can throw multiple different error types.do {
let age = try parseAge(from: ageInput) // first risky call
let name = try validateName(nameInput) // second risky call
print("Name: \(name), Age: \(age)")
} catch {
print("Validation failed: \(error)")
}do block can contain multiple try calls. If any one of them throws, execution jumps to catch immediately — the rest of the do block doesn't run.// rethrows means: "I only throw if the closure passed to me throws"
func processItems(_ items: [String], using transform: (String) throws -> Int) rethrows -> [Int] {
return try items.map(transform)
}
// You've already used rethrows — map, filter, and forEach all use it!rethrows is a modifier for higher-order functions. It means the function itself doesn't throw, but if the closure argument throws, the error is passed through. You've been using this already with map and filter from Stage 9.Quick Reference: The Three Keywords
| Keyword | What It Does |
|---|---|
throws | Added to a function signature — declares the function can throw an error |
throw someError | Inside a function — stops execution and sends the error to the caller |
try | Required when calling a throwing function — acknowledges it might fail |
do { } | Wraps one or more try calls — execution jumps to catch if anything throws |
catch specificError { } | Handles one particular error case |
catch { } | Catch-all — handles any error not matched by specific catch clauses above |
rethrows | Function passes through errors from a closure argument rather than throwing its own |
A game app receives a score as a string from a server response. Write a function called parseScore(from:) that converts it to an Int and throws errors for three cases: the string isn't a number, the score is negative, or the score is higher than 10,000 (the maximum allowed).
Then call it three times — once with a valid score, once with "abc", and once with "50000" — and print a different message for each failure case.
ScoreError enum first. Then write the function with throws in the signature. Call it inside a do/catch block with at least three specific catch clauses.When a smoke alarm goes off, you want to know more than "something's wrong." Is it smoke, carbon monoxide, a low battery, or a false alarm? Each one requires a different response. In Swift, generic errors are like an alarm with no display — you know it failed but not why. Custom error types give every failure a name and, when needed, extra context.
This connects directly to the enum knowledge you built in Stage 7. A custom error type is just a Swift enum that conforms to the Error protocol. You already know how to define enums with cases and associated values — adding : Error is the only new piece.
By the end of this lesson, you'll be able to define your own error enums with meaningful case names, add associated values to carry extra context alongside an error, and conform to LocalizedError so your errors can display readable messages to users.
Basic Custom Error Enum
// A custom error type is an enum that conforms to Error
enum NetworkError: Error {
case noConnection // device is offline
case timeout // server took too long
case serverError // server returned a 5xx status
case invalidResponse // couldn't parse what the server sent back
}
func fetchData(from url: String) throws -> String {
// In a real app this would make a network request
let isOnline = false
guard isOnline else {
throw NetworkError.noConnection // throw the named case
}
return "{ \"data\": true }"
}
do {
let result = try fetchData(from: "https://api.example.com/user")
print(result)
} catch NetworkError.noConnection {
print("Please check your internet connection.")
} catch NetworkError.timeout {
print("The request timed out. Try again.")
} catch {
print("Unexpected error: \(error)")
}| Part | What it does |
|---|---|
enum NetworkError: Error | Declares an error type. The : Error conformance is what makes Swift treat these cases as throwable errors. |
Each case | Represents one specific failure mode. Names should describe what went wrong, not what the code did. |
throw NetworkError.noConnection | Throws a specific case. The caller knows exactly which kind of failure occurred. |
catch NetworkError.noConnection | Matches against the specific case. Pattern matching works the same way it does in a switch statement. |
Adding Associated Values
// Associated values let you attach info to an error case
enum NetworkError: Error {
case noConnection
case serverError(statusCode: Int) // carries the HTTP status code
case invalidResponse(reason: String) // carries a description
}
func makeRequest() throws {
throw NetworkError.serverError(statusCode: 503) // attach the code when throwing
}
do {
try makeRequest()
} catch NetworkError.serverError(let code) {
print("Server returned \(code)") // unwrap it in the catch clause
} catch {
print("Error: \(error)")
}// Conforming to LocalizedError gives your errors a .localizedDescription
enum NetworkError: Error, LocalizedError {
case noConnection
case serverError(statusCode: Int)
// errorDescription is the property LocalizedError requires you to implement
var errorDescription: String? {
switch self {
case .noConnection:
return "No internet connection. Please check your network."
case .serverError(let code):
return "Server error (code \(code)). Please try again later."
}
}
}
// Now you can use .localizedDescription to get the user-friendly message
let error = NetworkError.noConnection
print(error.localizedDescription)
// "No internet connection. Please check your network."LocalizedError is a protocol (you know protocols from Stage 8) that adds a errorDescription property to your error type. This lets you display human-readable messages in alerts or UI labels — standard practice in real iOS apps.// Keep error types scoped to their domain — don't put everything in one enum
enum ValidationError: Error {
case emptyField(name: String)
case invalidEmail
case passwordTooShort(minimum: Int)
}
enum DatabaseError: Error {
case notFound(id: Int)
case duplicateEntry
case corruptData
}: Error to your enum doesn't change how enums work — it just registers it with Swift's error system so it can be thrown and caught. All your existing enum knowledge applies exactly as before.Quick Reference: Custom Error Patterns
| Pattern | What It Does |
|---|---|
enum E: Error { case x } | Basic custom error type with simple cases |
case serverError(code: Int) | Error case with associated value carrying context |
enum E: Error, LocalizedError | Adds user-readable errorDescription property |
var errorDescription: String? | Required by LocalizedError — return a human-readable message |
catch MyError.case(let value) | Catch and unwrap an error case with an associated value |
Build a LoginError enum with at least four cases: emptyEmail, invalidEmail, emptyPassword, and passwordTooShort (with an associated value carrying the minimum required length).
Conform it to LocalizedError and provide a clear user-facing message for each case. Then write a validateLogin(email:password:) function that throws the appropriate error, and call it with a few test inputs to verify your error messages appear correctly.
invalidEmail check, a simple rule is fine — check that the string contains "@". You don't need real email validation logic.Sometimes you call a throwing function and you genuinely don't care about the specific error — you just need to know if it worked. Other times, you're absolutely certain it cannot fail, and a full do/catch feels like unnecessary ceremony. Swift has shorthand for both situations: try? and try!.
These will feel familiar because they mirror the optional concepts you already know from Stage 6. try? is like using optional chaining — it gracefully turns a failure into nil. try! is like force unwrapping — it's a promise to the compiler that you know what you're doing, and it crashes if you're wrong.
By the end of this lesson, you'll know when try? is genuinely the right tool, understand exactly why try! is dangerous, and be able to make the right call in real code.
try? — Turning Errors into Optional nil
// Full do/catch when you need to handle the specific error
do {
let age = try parseAge(from: "25")
print("Age: \(age)")
} catch {
print("Error: \(error)")
}
// try? when you just need nil on failure — same result, less code
let age = try? parseAge(from: "25") // age is Int? (25 if success, nil if throws)
// You can use it with optional binding — the familiar if let pattern
if let validAge = try? parseAge(from: userInput) {
print("Valid age: \(validAge)")
} else {
print("Invalid input") // we don't know why — and in this context we don't care
}| Expression | What it does |
|---|---|
try? parseAge(...) | Returns an optional: the success value if no error was thrown, or nil if any error was thrown. The error itself is discarded. |
let age = try? ... | age is now Int? instead of Int — it wraps the result in an optional automatically. |
if let validAge = try? ... | Combines try? with optional binding — the pattern you already know works perfectly here. |
try! — Dangerous but Sometimes Justified
// try! says "I know this won't fail — crash if I'm wrong"
let url = URL(string: "https://api.example.com")! // force unwrap — same danger
let data = try! Data(contentsOf: Bundle.main.url(forResource: "seed", withExtension: "json")!)
// This is only acceptable because:
// 1. seed.json is bundled in the app — it WILL exist
// 2. We control its content — it WON'T be corrupt
// 3. A crash on startup is better than silent wrong behavior
// What happens if it does throw:
// CRASH — "Fatal error: expression unexpectedly raised an error"try! is the ! force-unwrap operator applied to a throwing call. Just like force unwrapping, it's a promise: "this will not fail." If it does fail, the app crashes immediately. Use only for resources you bundle with your app, where failure means something is catastrophically wrong with the build itself — not user data or network responses.// ?? works with try? the same way it works with any optional
let age = (try? parseAge(from: userInput)) ?? 0
// age is 0 if parsing fails — no do/catch needed
// Common in SwiftUI for loading data with a fallback
let config = (try? loadConfig()) ?? Config.defaults?? pairs naturally with try?. Wrap the whole try? expression in parentheses first, then apply ??. This gives you a clean one-liner with a default fallback.let inputs = ["25", "abc", "30", "-5", "40"]
// compactMap + try? filters out nil results — invalid inputs disappear silently
let validAges = inputs.compactMap { try? parseAge(from: $0) }
// validAges is [25, 30, 40] — "abc" and "-5" were thrown and become nil, then filteredcompactMap filters out nil values, so combining it with try? gives you a clean way to transform arrays while silently discarding invalid items.try! in production code is loading bundled JSON seed files or other resources that ship with the app. Do not use it on network calls, user-provided data, file paths from user input, or anything that could vary at runtime. If you're not sure, use try? or a full do/catch.Quick Reference: The Three try Forms
| Form | Returns | On Error | Use When |
|---|---|---|---|
try | The value | Jumps to catch | You need to handle specific error cases |
try? | Optional (value or nil) | Returns nil silently | You only care about success/failure, not the reason |
try! | Unwrapped value | Crashes the app | Bundled resources that will always exist |
You're building a weather app that receives an array of temperature strings from an API: ["22", "broken", "19", "-300", "28", "abc", "31"]. The valid range is -90°C to 60°C.
Write a parseTemperature(from:) function that throws for non-numeric input and out-of-range values. Then use compactMap with try? to filter the array down to only valid temperatures. Print the result.
func parseTemperature(from input: String) throws -> Double. Use Double(input) to convert — it returns an optional, so you'll need guard let first.Imagine asking someone for directions. If you're one block away, they just point. But if the destination is complicated, you'd want them to explain the route and flag any issues — "the bridge is closed on Tuesdays." Optionals are the point. Throwing functions are the explanation with warnings.
You've now seen both tools clearly. The harder question isn't how to use them — it's when. This lesson gives you a practical mental model for making that decision, so when you're designing a function, you reach for the right one the first time.
By the end of this lesson, you'll have a clear framework for choosing between optionals and throwing functions, plus a sense of how Apple's own APIs make this choice — so you can read the Swift standard library and understand the design decisions behind it.
The Decision Framework
// OPTIONAL: absence is the only possible outcome, no explanation needed
func findUser(id: Int) -> User? {
return users.first { $0.id == id } // nil means "not found" — clear and sufficient
}
// OPTIONAL: string-to-type conversion — absence is the only meaningful result
let number = Int("abc") // nil — this is how Apple designed it
// THROWING: multiple failure modes, each meaningful
func loadFile(at path: String) throws -> Data {
// Could fail because: file missing, no read permission, corrupted
return try Data(contentsOf: URL(fileURLWithPath: path))
}
// THROWING: failure should never be silently ignored
func savePurchase(_ purchase: Purchase) throws {
// If this fails, the user needs to know. Returning nil would be wrong.
}| Question to Ask | Answer → Use |
|---|---|
| Can the function fail in more than one meaningful way? | Yes → throwing function |
| Does the caller need to know why it failed? | Yes → throwing function |
| Would it be acceptable to silently ignore a failure? | No → throwing function |
| Is "not found" or "doesn't exist" the only failure mode? | Yes → optional |
| Is this a simple type conversion? | Yes → optional (following Swift convention) |
How Apple Makes This Choice
// Int("123") returns Optional — failure mode is simple and singular
let n = Int("123") // Int? — either it worked or it didn't
// URL(string:) returns Optional — nil if the string is malformed
let url = URL(string: "not a url") // URL? — simple yes/no
// Data(contentsOf:) throws — file operations can fail in many distinct ways
let data = try Data(contentsOf: someURL) // throws — gives you the reasonInt() and URL() return optionals because there's only one failure outcome and it needs no explanation. Data(contentsOf:) throws because file-system operations fail in many specific ways the caller needs to respond to.// This function throws on real errors, but returns Optional for "not found"
func loadUserIfExists(id: Int) throws -> User? {
let data = try fetchFromDatabase(id: id) // can throw DatabaseError
guard let user = decode(data) else {
return nil // no user found — nil is appropriate here
}
return user
}
do {
if let user = try loadUserIfExists(id: 42) {
print("Found: \(user.name)")
} else {
print("No user with that ID") // nil result — not an error
}
} catch {
print("Database error: \(error)") // thrown error — real failure
}// Result is a built-in enum with two cases
func fetchUser(id: Int, completion: (Result<User, NetworkError>) -> Void) {
// Used with completion handlers (older async pattern)
// Modern code uses throws with async/await instead
completion(.success(User(name: "Alex")))
// or: completion(.failure(.noConnection))
}
// Handling a Result
fetchUser(id: 1) { result in
switch result {
case .success(let user): print("Got \(user.name)")
case .failure(let error): print("Failed: \(error)")
}
}Result is a built-in Swift type for older async code using completion handlers. You'll encounter it in existing codebases and some third-party libraries. In modern Swift, throwing functions with async/await are preferred — but knowing Result exists helps you read code you didn't write.try? by the caller if they don't need the error. But if you return an optional and the caller later discovers they needed error details, refactoring is harder.Quick Reference: Choosing the Right Tool
| Situation | Use |
|---|---|
| Simple type conversion (String → Int) | Optional initializer (Int?) |
| Looking up a value that might not exist | Optional return (User?) |
| Operation with multiple failure modes | Throwing function |
| Failure that should never be silently ignored | Throwing function |
| Callback/completion handler (older async pattern) | Result<T, Error> |
| Modern async operation | async throws (coming in Stage 12) |
| Operation throws AND result might be absent | throws -> T? (combine both) |
You're designing functions for a note-taking app. For each of the following, decide whether to use an optional return type, a throwing function, or both — and write the function signature (just the signature, not the implementation). Then write a one-line comment explaining your choice.
Functions to design: (1) Find a note by its ID in an array. (2) Save a note to disk. (3) Convert a date string like "2025-01-15" into a Date object. (4) Load all notes from a file (file might be missing, corrupted, or in the wrong format). (5) Check if a tag name is already taken in the user's tag list.
Stage 10 Recap: Error Handling
You've now learned one of Swift's most important — and most practical — systems. Error handling is the difference between apps that crash mysteriously and apps that respond gracefully to things going wrong. Here's what you covered:
- Lesson 10.1 — Why Error Handling Exists: Optionals tell you something failed; throwing functions tell you why. Use throwing when the failure has multiple meaningful causes the caller needs to distinguish.
- Lesson 10.2 — throws, try, and do/catch: The core pattern. Mark a function with
throws, usethrowinside it to stop and report failure, call it withtryinside ado/catchblock, and handle specific cases with separatecatchclauses. - Lesson 10.3 — Custom Error Types with Enums: A custom error is just an enum that conforms to
Error. Add associated values for context. Conform toLocalizedErrorto provide user-readable messages in your UI. - Lesson 10.4 — try? and try!:
try?converts a throwing call into an optional — perfect for when you don't need the error detail.try!crashes on failure — only use it for bundled resources you're certain will always exist. - Lesson 10.5 — When to Throw vs Return an Optional: Return an optional when there's one natural failure mode and nil is self-explanatory. Use a throwing function when failure has multiple causes, needs explanation, or should never be silently ignored. You can combine both.
Don't skip the challenges. Writing the patterns yourself — even in a Playground — is the fastest way to move from "I understand this" to "I can actually use this." Each one is designed around the kind of problem you'll hit in a real app.
Stage 11 takes you deeper into enums — including indirect enums and how to model complex state with them. You'll notice that the custom error enum patterns from Stage 10 are a natural bridge into that material. The tools are the same; the use cases get richer.
Learn Swift Stage 11: Enums in Depth
You've been naming things in code for a while now — but Stage 11 is where you learn to define exactly what something can be, and attach real data to each possibility.
All you need for this stage is Xcode and a Swift Playground. Stage 11 has six lessons, each designed to take around 20 to 35 minutes. Don't rush through the code examples — read the comments, run the snippets, and actually do the challenge at the end of each lesson. The challenge is not optional busywork. It's where the concept clicks.
By the end of this stage you'll be able to define enums with associated values that carry different data per case, use raw values to connect enum cases to underlying types, add computed properties and methods directly to an enum, model the loading states of a real feature using an enum, and work with Swift's built-in Result type for handling success and failure. This is the stage where Swift enums start to feel genuinely different from any other language you may have encountered — in the best possible way.
Think about a traffic light. It isn't just any color — it can only ever be red, yellow, or green. There's no "sort of green" or "bluish red." A finite, fixed set of possibilities. That's exactly the kind of thing an enum is made for.
You got a quick introduction to enums back in Stage 7 when we were looking at structs. But at that point, enums were just a footnote. Now they get the full treatment — because once you understand what enums can actually do in Swift, you'll reach for them constantly.
By the end of this lesson you'll understand what an enum is, why you'd use one instead of a plain string or integer, how switch works with enums, and how to define your own enum from scratch.
enum TrafficLight {
case red
case yellow
case green
}
let currentLight: TrafficLight = .red
switch currentLight {
case .red:
print("Stop")
case .yellow:
print("Slow down")
case .green:
print("Go")
}| Line | What it does |
|---|---|
enum TrafficLight { } | Declares a new enum type called TrafficLight. The name follows the same capitalized convention as structs and classes. |
case red | Defines one possible value. An enum can have as many cases as you need — but each one must be explicitly listed. |
let currentLight: TrafficLight = .red | Creates a constant of type TrafficLight and assigns it the value .red. The dot syntax is Swift's way of referring to an enum case. |
switch currentLight { } | Switch and enums are a natural pair. Swift requires you to handle every case, so you can't accidentally miss one. |
case .red: | Each arm of the switch matches one possible enum value. Notice the dot prefix — same syntax as when you assigned the value. |
var light = "red", nothing stops you from accidentally assigning "redd" or "RED" later. An enum makes invalid states impossible — the compiler will reject anything that isn't a defined case.Ways to Write Enum Cases
enum Direction {
case north
case south
case east
case west
}enum Direction {
case north, south, east, west
}var heading: Direction = .north
heading = .east // no need to write Direction.east.red instead of TrafficLight.red in most code.switch heading {
case .north: print("Going north")
case .south: print("Going south")
case .east: print("Going east")
case .west: print("Going west")
// No default needed — Swift knows all cases are covered
}default case if you handle every case explicitly. And if you add a new case to the enum later, Swift will flag every switch that doesn't handle it yet.Quick Reference
| Syntax | What It Does |
|---|---|
| enum Name { } | Declares a new enum type |
| case caseName | Defines one possible value |
| let x: Name = .caseName | Creates a variable/constant of the enum type |
| x = .caseName | Assigns a new value using dot syntax (type already known) |
| switch x { case .a: } | Branches on each possible case — must be exhaustive |
Create an enum called CompassDirection with four cases: north, south, east, west. Write a function called describe that takes a CompassDirection and uses a switch statement to print a sentence describing what traveling in that direction means. Call it with each case and confirm all four messages print correctly.
func describe(_ direction: CompassDirection). Remember, you don't need a default case if you handle all four directions explicitly.Think about a parcel tracking system. A package can be in one of several states: waiting to ship, in transit, delivered, or failed to deliver. But here's the thing — each state carries different information. When it's in transit you want to know the current city. When it's delivered, you want a timestamp. When it failed, you want an error message. Each state has a different shape of data attached to it.
Until now, an enum case was just a label — it told you what something was, but it couldn't carry any information. Associated values change that completely. This is the feature that makes Swift enums genuinely different from what you've seen in other languages, and it's one of the most useful patterns in all of Swift.
By the end of this lesson you'll understand how to attach data to individual enum cases, how to extract that data using switch and pattern matching, and why this pattern is so powerful for modeling real-world situations where different states have different data.
enum ParcelStatus {
case waitingToShip
case inTransit(currentCity: String)
case delivered(at: Date)
case failed(reason: String)
}
let status = ParcelStatus.inTransit(currentCity: "Chicago")
switch status {
case .waitingToShip:
print("Your parcel is being prepared.")
case .inTransit(let city):
print("Your parcel is in \(city).")
case .delivered(let date):
print("Delivered on \(date).")
case .failed(let reason):
print("Delivery failed: \(reason)")
}| Line | What it does |
|---|---|
case waitingToShip | A plain case with no associated value — same as you've seen before. |
case inTransit(currentCity: String) | Declares an associated value. The parentheses define what data this case carries. Here: a String labelled currentCity. |
case delivered(at: Date) | A different case with different associated data — a Date this time. Each case can have its own type and shape of data. |
ParcelStatus.inTransit(currentCity: "Chicago") | Creates a value of the enum. Notice you provide the associated value right here, just like calling a function with a labelled argument. |
case .inTransit(let city): | Extracts the associated value during pattern matching. The let binds the value to a local name you can use in that branch. |
ParcelStatus holds one case and the data for that case. The other cases' data simply doesn't exist until you switch to them.Associated Value Patterns
enum Location {
case coordinates(lat: Double, lon: Double)
case cityName(String)
}
let loc = Location.coordinates(lat: 51.5, lon: -0.1)
switch loc {
case .coordinates(let lat, let lon):
print("Lat: \(lat), Lon: \(lon)")
case .cityName(let name):
print("City: \(name)")
}let.if case .inTransit(let city) = status {
print("Currently in \(city)")
}
// Only runs if status is the .inTransit caseif case let lets you pattern match and extract in one line without a full switch block.enum Shape {
case circle(Double) // radius
case rectangle(Double, Double) // width, height
}
let s = Shape.circle(5.0)
switch s {
case .circle(let r):
print("Area: \(3.14159 * r * r)")
case .rectangle(let w, let h):
print("Area: \(w * h)")
}currentCity:) make code more readable and are generally preferred, but you'll see unlabelled values in many Swift APIs.func handleDelivery(_ status: ParcelStatus) {
guard case .delivered(let date) = status else {
print("Not delivered yet")
return
}
print("Delivered at \(date)")
}guard case let is the early-exit version. If the status isn't the .delivered case, the function returns immediately. This keeps the happy path un-indented.Quick Reference
| Syntax | What It Does |
|---|---|
| case name(Type) | Case with one associated value |
| case name(label: Type) | Case with a labelled associated value |
| case name(Type, Type) | Case with multiple associated values |
| case .name(let x): | Extract associated value in a switch |
| if case .name(let x) = val | Match one case without a full switch |
| guard case .name(let x) = val else { } | Early exit if case doesn't match |
Create an enum called PaymentMethod with three cases: cash with an associated Double for the amount, creditCard with an associated String for the last four digits, and applePay with no associated value. Write a function called describePayment that takes a PaymentMethod and prints a human-readable description of the payment. Test it with all three cases.
case cash(amount: Double). When you extract it in the switch, use case .cash(let amount): to get the value.case .inTransit(let city): is doing in plain English. If you can describe it out loud to yourself, you've got it.Think about the number keys on a keyboard. Each key has a label you see — "1", "2", "3" — but behind the scenes, each key also corresponds to a specific numeric code. The label is what you work with; the underlying value is what the computer stores. Raw values work exactly the same way for enum cases.
You've seen associated values, which let each case carry different, unique data at runtime. Raw values are different: they're fixed at compile time. Every case gets one predetermined underlying value — a String, an Int, or another simple type — and that mapping never changes.
By the end of this lesson you'll know how to declare an enum with raw values, how to initialize an enum from a raw value (and why that might fail), and when to reach for raw values versus associated values.
enum Planet: Int {
case mercury = 1
case venus = 2
case earth = 3
case mars = 4
}
// Access the raw value
let position = Planet.earth.rawValue // 3
// Initialize from a raw value — returns an Optional
let maybePlanet = Planet(rawValue: 4) // Planet.mars
let notAPlanet = Planet(rawValue: 99) // nil
print(position) // 3
print(maybePlanet ?? "unknown")| Line | What it does |
|---|---|
enum Planet: Int | The colon followed by a type declares the raw value type. Every case must have a raw value of this type. |
case mercury = 1 | Assigns the fixed raw value 1 to the mercury case. This mapping is baked in at compile time. |
.earth.rawValue | The rawValue property returns the underlying value for any case. Here it returns 3. |
Planet(rawValue: 4) | Creates an enum value from a raw value. This returns an Optional because the raw value might not match any case. |
Planet(rawValue: 99) | Returns nil because 99 doesn't match any defined case. Always handle this Optional safely. |
Raw Value Patterns
enum HTTPMethod: String {
case get = "GET"
case post = "POST"
case delete = "DELETE"
}
var request = URLRequest(url: someURL)
request.httpMethod = HTTPMethod.post.rawValue // "POST"// Int: starts at 0, increments automatically
enum Level: Int {
case beginner, intermediate, advanced
// rawValues: 0, 1, 2
}
// String: raw value matches the case name
enum Color: String {
case red, green, blue
// rawValues: "red", "green", "blue"
}enum Weekday: String, CaseIterable {
case monday, tuesday, wednesday
case thursday, friday
}
for day in Weekday.allCases {
print(day.rawValue)
}
// monday, tuesday, wednesday, thursday, fridayCaseIterable gives your enum an allCases property — an array of every case in the order they were defined. Extremely useful for building pickers, menus, or iterating over every option.let code = 3
if let planet = Planet(rawValue: code) {
print("Found planet: \(planet)")
} else {
print("No planet with that number")
}if let or guard let to safely unwrap it. Never force-unwrap with ! unless you are 100% certain the value maps to a valid case.Quick Reference
| Syntax | What It Does |
|---|---|
| enum Name: Int { } | Enum with Int raw values |
| enum Name: String { } | Enum with String raw values |
| case name = value | Explicitly assigns a raw value to a case |
| .caseName.rawValue | Reads the raw value of a case |
| Name(rawValue: x) | Creates an enum from a raw value — returns Optional |
| enum Name: String, CaseIterable | Adds allCases property for iterating every case |
Create an enum called HTTPStatus with an Int raw value. Add cases for ok (200), notFound (404), serverError (500), and unauthorized (401). Write a function that takes an Int, tries to initialize an HTTPStatus from it, and prints a description if it succeeds or "Unknown status code" if it doesn't. Also make it conform to CaseIterable and print all cases at the end.
if let status = HTTPStatus(rawValue: code) inside your function to safely handle the Optional.Think about a vending machine. It knows what state it's in — idle, dispensing, or out of stock — but it also knows how to do things based on that state: display a message, accept a coin, or refuse a selection. The state and the behavior belong together. That's the same idea when you add methods and properties to an enum.
This surprises a lot of developers coming from other languages, where enums are glorified integers and nothing more. In Swift, an enum can have computed properties and methods just like a struct. The enum case is always available inside those methods as self.
By the end of this lesson you'll know how to add computed properties to an enum, how to write methods that switch on self, and why keeping behavior close to the type that owns it makes your code cleaner.
enum Planet: Int {
case mercury = 1, venus, earth, mars
// Computed property
var distanceFromSunDescription: String {
switch self {
case .mercury: return "Closest to the sun"
case .venus: return "Second from the sun"
case .earth: return "Third from the sun"
case .mars: return "Fourth from the sun"
}
}
// Method
func hasLiquidWater() -> Bool {
return self == .earth
}
}
let home = Planet.earth
print(home.distanceFromSunDescription)
print(home.hasLiquidWater())| Line | What it does |
|---|---|
var distanceFromSunDescription: String { } | A computed property on the enum. The syntax is identical to a computed property on a struct — it returns a value based on some logic. |
switch self | Inside an enum method or property, self refers to the current case. Switching on self is the most common pattern for providing different behavior per case. |
func hasLiquidWater() -> Bool | A method on the enum. Works exactly like a struct method — takes parameters, returns values, has access to self. |
self == .earth | Compares the current case to .earth. Enums with no associated values are automatically Equatable in Swift. |
switch planet { } scattered throughout your codebase. With methods, the logic lives on the type itself — one place to read, one place to change.Method and Property Patterns
enum Season {
case spring, summer, autumn, winter
var emoji: String {
switch self {
case .spring: return "🌸"
case .summer: return "☀️"
case .autumn: return "🍂"
case .winter: return "❄️"
}
}
}
print(Season.summer.emoji) // ☀️enum Discount {
case none
case percentage(Double)
case fixed(Double)
func apply(to price: Double) -> Double {
switch self {
case .none: return price
case .percentage(let pct): return price * (1 - pct)
case .fixed(let amount): return price - amount
}
}
}
let sale = Discount.percentage(0.2) // 20% off
print(sale.apply(to: 100.0)) // 80.0enum TrafficLight {
case red, yellow, green
mutating func next() {
switch self {
case .red: self = .green
case .green: self = .yellow
case .yellow: self = .red
}
}
}
var light = TrafficLight.red
light.next()
print(light) // greenself must be marked mutating — same rule as structs. This means you must use var, not let, for the variable you call it on.Quick Reference
| Syntax | What It Does |
|---|---|
| var name: Type { switch self { } } | Computed property that branches on the current case |
| func name() -> Type { switch self { } } | Method that returns different values per case |
| mutating func name() { self = .otherCase } | Method that changes the case — requires var, not let |
| switch self { case .x(let v): } | Pattern match and extract associated value inside a method |
Create an enum called VendingMachineState with three cases: idle, dispensing(item: String), and outOfStock. Add a computed property called statusMessage that returns a user-facing String for each state. Add a method called canAcceptPayment that returns a Bool — true only when idle. Create a few test values and verify your property and method work correctly.
statusMessage property, when you handle the .dispensing case, use case .dispensing(let item): to extract the item name and include it in the returned string.Think about a recipe loading screen in an app. The screen can be in one of four states: it's fetching data from the network, it has loaded recipes successfully, it hit an error, or the user hasn't searched for anything yet so there's nothing to show. Each state requires completely different UI. Each state might need completely different data.
This is where everything you've learned in this stage comes together. You now know enums can have associated values, methods, and properties. And the single most important real-world application of all that in iOS development is modeling the possible states of a feature. This is a pattern you will use in almost every app you build.
By the end of this lesson you'll have a working mental model for state-driven UI — how an enum drives what the user sees — and you'll understand why this pattern makes apps easier to reason about than using a pile of Bool flags and optional variables.
// The four possible states of a recipe list feature
enum RecipeListState {
case idle // nothing searched yet
case loading // network request in flight
case loaded([String]) // recipes arrived
case failed(message: String) // something went wrong
}
// In a SwiftUI view model, you'd have a @Published property like this:
var state: RecipeListState = .idle
// And in your SwiftUI view body you'd switch on it:
func describeCurrentState(_ state: RecipeListState) -> String {
switch state {
case .idle:
return "Search for a recipe above."
case .loading:
return "Loading..."
case .loaded(let recipes):
return "Found \(recipes.count) recipes."
case .failed(let message):
return "Error: \(message)"
}
}
print(describeCurrentState(.loaded(["Pasta", "Pizza", "Salad"])))| Line | What it does |
|---|---|
case idle | Represents the initial state. No data needed — the user just opened the screen. |
case loading | A network request is in progress. No data yet. The UI should show a spinner. |
case loaded([String]) | Associated value carries the actual data — here, an array of recipe names. The UI renders the list from this. |
case failed(message: String) | Something went wrong. The associated value is the error message to show the user. |
switch state { } | The view decides what to show by switching on state. Every possible state is handled — no edge case can slip through. |
@Published var state: RecipeListState = .idle. Your SwiftUI view would switch on it to decide which child view to show. Change the state, and SwiftUI automatically re-renders the right UI. This is state-driven development in practice.isLoading: Bool, hasError: Bool, and recipes: [String]?." The problem is that combinations like isLoading = true and hasError = true at the same time are nonsensical but nothing stops them from happening. An enum makes illegal combinations impossible.State Modeling Patterns
// A generic version you can reuse across your app
enum LoadingState<T> {
case idle
case loading
case loaded(T)
case failed(Error)
}
// Use it with any data type
var recipesState: LoadingState<[String]> = .idle
var userState: LoadingState<User> = .idle<T>) lets you define this pattern once and reuse it everywhere in your app. You'll see this exact pattern in real codebases — often called ViewState or AsyncState.extension RecipeListState {
var isLoading: Bool {
if case .loading = self { return true }
return false
}
var recipes: [String] {
if case .loaded(let items) = self { return items }
return []
}
}
// Now you can do clean checks in your UI
if state.isLoading { /* show spinner */ }state.isLoading instead of writing a full if case .loading = state every time.func fetchRecipes() async {
state = .loading
do {
let results = try await networkCall()
state = .loaded(results)
} catch {
state = .failed(message: error.localizedDescription)
}
}@Published.Quick Reference
| Pattern | What It Does |
|---|---|
| enum State { case idle, loading, loaded(T), failed(Error) } | The standard four-case loading state pattern |
| @Published var state: MyState = .idle | Observable state in a SwiftUI view model |
| switch state { case .loaded(let data): } | Drive UI from state in a SwiftUI view body |
| if case .loading = self { return true } | Convenience Boolean check on a specific case |
| state = .loading / .loaded(data) / .failed(error) | Transition state during a network call |
Create an enum called WeatherState that models a weather app screen. It should have cases for: idle (user hasn't entered a city), loading, loaded with associated values for temperature (Double) and condition (String), and failed with an associated error message. Add a computed property called displayMessage that returns a human-readable String for each state. Write a function that simulates fetching weather by accepting a Bool for success, and transitions state from loading to loaded or failed accordingly.
case loaded(temperature: Double, condition: String). The function should set state to .loading, then immediately set it to either .loaded(temperature: 22.5, condition: "Sunny") or .failed(message: "City not found") based on the Bool parameter.Think about a coin flip: it can only come up heads or tails. There's no third option. When you call a function that might succeed or might fail, the outcome is structurally the same — either you got what you asked for, or something went wrong. Swift's built-in Result type captures that binary outcome in a clean, explicit way.
Back in Stage 10 you learned about error handling with throws, try, and do/catch. Result is a related tool — and now that you understand associated values, you can see exactly how it works under the hood. Result<Success, Failure> is just an enum with two cases: .success(value) and .failure(error).
By the end of this lesson you'll know how to return a Result from a function, how to switch on it, how to use its built-in helper methods, and when you'd choose it over throwing functions.
enum NetworkError: Error {
case badURL
case noData
case decodingFailed
}
// A function that returns a Result instead of throwing
func fetchUsername(for id: Int) -> Result<String, NetworkError> {
guard id > 0 else {
return .failure(.badURL)
}
// Simulating a successful response
return .success("chris_ching")
}
// Calling the function and handling the result
let result = fetchUsername(for: 42)
switch result {
case .success(let username):
print("Hello, \(username)!")
case .failure(let error):
print("Error: \(error)")
}| Line | What it does |
|---|---|
Result<String, NetworkError> | The return type. The first type parameter is the success value type; the second is the error type. Both are specified at compile time. |
return .failure(.badURL) | Returns the failure case with an associated error value. No need to throw — you return normally, just with a failure result. |
return .success("chris_ching") | Returns the success case with the actual value wrapped inside it. |
switch result { case .success(let username): } | Pattern matching on Result — same as any other enum with associated values. Extract the value or the error and handle each case. |
Result is literally just an enum defined in the Swift standard library. It has two cases with associated values — case success(Success) and case failure(Failure). Now that you understand associated values, you could define it yourself.Working with Result
do {
let username = try result.get()
print("Got: \(username)")
} catch {
print("Failed: \(error)")
}.get() is a bridge between Result and throwing functions. If the result is a success it returns the value; if it's a failure it throws the error. This lets you use Result with existing do/catch code.let uppercased = result.map { $0.uppercased() }
// If result was .success("chris_ching"), uppercased is .success("CHRIS_CHING")
// If result was .failure(error), uppercased is still .failure(error) — unchanged.map() applies a transformation to the success value only, leaving failures untouched. This is the same map concept you used with arrays and optionals — consistent across Swift's types.// Use throws when the caller should handle the error immediately
func loadFile() throws -> String { /* ... */ }
// Use Result when you need to store or pass along the outcome
func fetchData(completion: (Result<Data, Error>) -> Void) { /* ... */ }
// In async/await code you'll mostly use throws
func fetchDataAsync() async throws -> Data { /* ... */ }throws. Result is especially useful when working with callback-based APIs (completion handlers), or when you want to store the success/failure outcome in a variable to inspect later.let fileResult = Result { try loadFile() }
// fileResult is Result<String, Error>
// Success if loadFile() returned, failure if it threwResult { try ... } to capture the outcome as a Result value. This is a clean bridge when you have a throwing function but need a Result.Quick Reference
| Syntax | What It Does |
|---|---|
| Result<Success, Failure> | The Result type — Failure must conform to Error |
| return .success(value) | Return a successful result with an associated value |
| return .failure(error) | Return a failure result with an associated error |
| switch result { case .success(let v): } | Pattern match on the result — same as any enum |
| try result.get() | Throw on failure, return value on success |
| result.map { transform($0) } | Transform the success value, pass failures through unchanged |
| Result { try throwingCall() } | Capture a throwing expression as a Result |
Create an enum called MathError that conforms to Error and has one case: divisionByZero. Write a function called safeDivide that takes two Doubles and returns a Result<Double, MathError>. If the divisor is zero, return a failure with .divisionByZero. Otherwise return the quotient as a success. Test it with both a valid division and a division by zero, printing an appropriate message for each result.
func safeDivide(_ a: Double, by b: Double) -> Result<Double, MathError>. Use guard b != 0 else { return .failure(.divisionByZero) } at the top.Result is the bridge between enums and error handling, and now you understand both sides.Stage 11 Recap: Enums in Depth
Six lessons in, and Swift enums have gone from a simple labelling tool to one of the most expressive features in the language. Here's what you covered:
- Lesson 11.1 — Basic Enums Revisited: Enums define a fixed set of possible values, work naturally with switch exhaustiveness, and eliminate whole categories of bugs that string and integer comparisons can't prevent.
- Lesson 11.2 — Associated Values: Each enum case can carry different data at runtime — the feature that separates Swift enums from what you've seen in other languages, and the foundation for nearly every pattern in this stage.
- Lesson 11.3 — Raw Values: Fixed underlying types (String, Int, etc.) let you map enum cases to external values like API keys and numeric codes, with CaseIterable giving you a free allCases collection.
- Lesson 11.4 — Enums with Methods and Properties: Computed properties and methods live directly on the enum, keeping behavior close to the type that owns it and eliminating scattered switch statements across your codebase.
- Lesson 11.5 — Using Enums to Model State: The idle/loading/loaded/failed pattern is the professional standard for state-driven SwiftUI development — you now have the vocabulary to read and write real app code that uses it.
- Lesson 11.6 — The Result Type: Swift's built-in two-case enum for success and failure bridges the gap between error handling and associated values, and is especially common in completion handler and callback-based APIs.
If you skipped any of the challenges, go back and do them now. The concepts in this stage build on each other and the challenges are where the patterns become muscle memory. Associated values especially — they're much stickier after you've written them yourself.
Stage 12 is Concurrency — async/await, structured concurrency, and actors. It's the last major Swift language concept before you move into SwiftUI full time, and the state patterns you learned in Lesson 11.5 will connect directly to how async/await drives UI updates.

