Tutorials Ultimate Swift Series Chapter 15

Methods

SwiftChapter 15 of the Ultimate Swift Series32 minMay 16, 2026Beginner

A struct's properties hold its data. Its methods decide what it can do. You met methods briefly in Chapter 13 -- a function defined inside a struct -- but there's a lot of nuance once you start using them in real apps: the implicit self, custom initializers, mutating methods, type-level methods, and the extension keyword that lets you add behavior to types you don't own.

This chapter walks through all of that.

Method Refresher

You've been calling methods all along. When you write myArray.append(7) or myString.uppercased(), you're calling a method on an instance:

var apps = ["Tally", "Notes"]
apps.append("Shipper")
apps   // ["Tally", "Notes", "Shipper"]

A method is just a function -- but defined inside a type. You access it with dot syntax on an instance.

Method vs Computed Property

In Chapter 14 you saw computed properties: blocks of code that look like properties but run on every read. That overlaps with methods. So when should something be a method, and when a computed property?

Two simple rules:

shoppingCart.itemCount -- a property. Cheap, returns a fact. shoppingCart.checkout() -- a method. Triggers work, may fail, side-effecting.

When in doubt, pick whichever reads better at the call site.

The Implicit self

Imagine a shopping cart for in-app purchases. Each item has a name and a price; the cart holds a list of items.

struct Item {
    let name: String
    let priceCents: Int
}
 
struct Cart {
    var items: [Item] = []
 
    func subtotalCents() -> Int {
        var total = 0
        for item in items {
            total += item.priceCents
        }
        return total
    }
}

Inside subtotalCents(), the method refers to items directly -- not cart.items, not self.items, just items. Swift secretly passes the instance into every method as a hidden parameter named self. When you say items, the compiler reads it as self.items.

You can write self.items explicitly. Most of the time it's noise:

func subtotalCents() -> Int {
    self.items.reduce(0) { $0 + $1.priceCents }   // works, but `items` is enough
}

The one place self. is essential is when a parameter has the same name as a property. We'll see that in a moment.

Self (capital S) vs self (lowercase s)

These are different. self (lowercase) is the instance -- this particular cart. Self (capital S) is the type -- the Cart blueprint itself. You use Self to access type-level (static) members from inside an instance method. Same word, different worlds.

Initializers, Revisited

You met initializers in Chapter 13: every struct gets a free memberwise initializer that takes one argument per stored property. With the Cart above, that's Cart(items: [Item]). Default values shrink the call site -- since items defaults to [], Cart() works too.

Sometimes you need an initializer that transforms its inputs rather than just storing them. Maybe you want to seed a cart from a saved CSV string, or from a single bundle ID. Write a custom initializer with the init keyword:

struct Cart {
    var items: [Item] = []
 
    init(items: [Item] = []) {
        self.items = items
    }
 
    init(singleProduct name: String, priceCents: Int) {
        self.items = [Item(name: name, priceCents: priceCents)]
    }
}

Notice:

Now you can build a cart either way:

let empty = Cart()
let oneShot = Cart(singleProduct: "Pro Upgrade", priceCents: 999)

The "custom init swallows the memberwise init" gotcha

There's a catch. The moment you write a custom init inside the struct body, Swift stops generating the memberwise one. It assumes you have a reason to take control. So this fails:

struct Cart {
    var items: [Item] = []
 
    init(singleProduct name: String, priceCents: Int) {
        self.items = [Item(name: name, priceCents: priceCents)]
    }
}
 
let c = Cart(items: [])   // ⛔ no longer exists

The clean fix: put your custom initializer in an extension instead of in the main body. Extensions don't disable the synthesized memberwise init:

struct Cart {
    var items: [Item] = []
}
 
extension Cart {
    init(singleProduct name: String, priceCents: Int) {
        self.items = [Item(name: name, priceCents: priceCents)]
    }
}
 
let a = Cart(items: [])                                  // ✅ memberwise, still here
let b = Cart(singleProduct: "Pro", priceCents: 999)      // ✅ custom, also here

This idiom is worth memorizing. Whenever you find yourself adding a convenience initializer to a struct, default to writing it in an extension.

Mutating Methods

Structs are value types. By default, methods on a struct cannot change the struct's properties -- because doing so would mean mutating the copy you'd just been handed, which is almost always a bug. Swift makes you opt in.

To write a method that changes a property, mark it mutating:

extension Cart {
    mutating func add(_ item: Item) {
        items.append(item)
    }
 
    mutating func clear() {
        items.removeAll()
    }
}
 
var cart = Cart()
cart.add(Item(name: "Pro Upgrade", priceCents: 999))
cart.add(Item(name: "Credit Pack", priceCents: 499))
cart.items.count   // 2

Two rules follow from the mutating annotation:

If you don't need to mutate, don't mark it. Most methods on most structs are not mutating.

Mini-exercise

Add a mutating func remove(at index: Int) to Cart that removes the item at the given index. (Don't worry about index validation for this exercise -- assume the caller passes a valid index.)

Type Methods: Functions on the Type Itself

Most methods belong to instances -- cart.add(item) only makes sense if you have a cart. But sometimes the work doesn't need any particular instance. It belongs to the type.

Mark these with static:

struct PriceFormatter {
    static func usd(_ cents: Int) -> String {
        let dollars = Double(cents) / 100
        return String(format: "$%.2f", dollars)
    }
 
    static func usdOrFree(_ cents: Int) -> String {
        cents == 0 ? "Free" : usd(cents)
    }
}
 
PriceFormatter.usd(999)         // "$9.99"
PriceFormatter.usdOrFree(0)     // "Free"
PriceFormatter.usdOrFree(499)   // "$4.99"

PriceFormatter has no instance properties and you never write PriceFormatter(). It exists purely as a namespace -- a folder that groups related functions under one name. This pattern is everywhere in real Swift code: Date.now, URL.cachesDirectory, Color.accentColor. They're all static members.

Inside one static method, you can call another with no prefix (notice how usdOrFree calls usd directly). From an instance method, you'd refer to type members via Self.:

extension Cart {
    func formattedTotal() -> String {
        Self.priceFormatter(subtotalCents())   // assumes a static `priceFormatter`
    }
}

Mini-exercise

Add a static func mbDisplay(_ bytes: Int) -> String to PriceFormatter (or to a new ByteFormatter namespace) that returns "4.2 MB" for 4_400_000. Use Double(bytes) / 1_000_000 and String(format: "%.1f MB", ...).

Extensions: Adding to Types You Don't Own

You've already used extension to keep the memberwise init alive. But extensions can do far more: they can add methods and computed properties to any type, including the ones Swift's standard library ships -- String, Int, Array, even Bool.

Say you want a clean way to truncate long strings for previews. Rather than write a free function truncate(string, length), add a method right on String:

extension String {
    func truncated(to limit: Int, ellipsis: String = "…") -> String {
        if count <= limit { return self }
        return prefix(limit) + ellipsis
    }
}
 
"Pattern matching with Swift regex literals".truncated(to: 20)
// "Pattern matching wit…"

Now every String in your codebase has .truncated(to:). The original String type didn't change -- you've added behavior the compiler attaches at call sites.

You can extend your own structs the same way to group related behavior:

extension Cart {
    var itemCount: Int { items.count }
    var isEmpty: Bool { items.isEmpty }
}

There's only one hard limit: an extension can't add stored properties, because that would change the type's memory layout. Computed properties, methods, initializers, and protocol conformances are all fair game.

Be polite with stdlib extensions

Adding methods to String or Int is genuinely useful inside your own project, but it's a global change in that module -- anyone reading code that uses "foo".truncated(to: 10) has to know it's from your extension. Save these for things that read better as instance methods than as free functions, and resist the temptation to add five-character convenience accessors that obscure where the behavior comes from.

Challenges

Challenge 1: cart receipt

Add an instance method receipt() -> String to Cart that returns a multi-line string formatted like a printed sales slip. Example output for a 2-item cart:

Pro Upgrade .......... $9.99
Credit Pack .......... $4.99
─────────────────────────────
Total ............... $14.98

Hints: build a single String and append to it inside a for loop; use PriceFormatter.usd(_:) from earlier for the prices; you don't have to match the dot leaders exactly -- just produce a readable multi-line block.

Challenge 2: a ByteFormatter namespace

Build a new ByteFormatter struct with three static methods that return human-readable size strings:

Use String(format: "%.1f KB", ...) style formatting (import Foundation if you need it).

Challenge 3: extend String with a bundle-ID validator

The App Store wants bundle IDs in reverse-DNS format -- at least one dot, no spaces, all lowercase alphanumerics plus dots and hyphens. Examples:

Write an extension String that adds a computed property isValidBundleID: Bool. Test it on all five examples above. You can implement the check with String methods (contains, allSatisfy, etc.) or with a regex from Chapter 12 -- your call.

Key Points

You now know enough Swift to model a real domain with confidence: pick the right type, give it properties, give it behavior, and grow it through extensions as the codebase evolves. In Chapter 16: Classes, you'll meet the other side of the named-type coin -- reference types, shared mutation, object identity, and when to reach for a class instead of the struct you've been using until now.

Ch 14: PropertiesCh 16: Classes
SwiftUIUltimate SwiftUI SeriesSwiftUI tutorials for building native app screens, layouts, navigation, and state-driven interfaces.Ship iOSShip iOS Apps SeriesShipping workflows for iOS apps: signing, TestFlight, App Store Connect, CI, and release hygiene.DeliveryModern Delivery PipelineCI/CD, review, runner, and deploy workflows for teams shipping apps and websites safely.

Ship your apps faster

When you're ready to publish your Swift app to the App Store, Simple App Shipper handles metadata, screenshots, TestFlight, and submissions — all in one place.

Try Simple App Shipper
5 free articles remainingSubscribe for unlimited access