Tutorials Ultimate Swift Series Chapter 14

Properties

SwiftChapter 14 of the Ultimate Swift Series30 minMay 15, 2026Beginner

In the previous chapter, every property of a struct was a plain stored value -- one slot in memory, set once at init, optionally mutable afterwards. That's the simplest kind of property, and the kind you'll use most often.

But Swift's property system goes further. Some properties don't store anything at all and instead compute their value on the fly. Some live on the type itself rather than on each instance. Some let you react when their value changes. And some delay their initialization until the moment they're first read. This chapter is a tour of all of those.

Stored Properties: the Baseline

A stored property holds a value. You've seen them already:

struct AppMetadata {
    var displayName: String
    let bundleID: String
    var locale: String
}

bundleID is let, so it's fixed at init. displayName and locale are var, so they can change after init -- if the surrounding instance is also a var. (Recall from Chapter 13 that mutability is gated in two places.)

var app = AppMetadata(
    displayName: "Simple App Shipper",
    bundleID: "com.example.shipper",
    locale: "en-US"
)
 
app.displayName = "Shipper"   // ✅
app.bundleID = "com.other"     // ⛔ Cannot assign to 'let' constant

Default values

When you have a sensible fallback, give a property a default value right in the declaration:

struct AppMetadata {
    var displayName: String
    let bundleID: String
    var locale: String = "en-US"
    var primaryCategory: String = "Productivity"
}

Swift updates the synthesized initializer accordingly. Parameters with defaults can be omitted at the call site:

let a = AppMetadata(displayName: "Notes", bundleID: "com.x")
// locale = "en-US", primaryCategory = "Productivity"
 
let b = AppMetadata(
    displayName: "Tally",
    bundleID: "com.y",
    primaryCategory: "Finance"
)
// locale still defaults; primaryCategory overridden

You can override any subset; the rest default. Use this to keep your initializer call sites short for the common case.

Computed Properties

Not every property has to store its value. Some can compute it from other properties every time they're read. These are called computed properties, and they're declared like methods returning a single value -- with braces instead of =:

struct ImageAsset {
    var widthPx: Int
    var heightPx: Int
 
    var aspectRatio: Double {
        Double(widthPx) / Double(heightPx)
    }
}
 
let icon = ImageAsset(widthPx: 1024, heightPx: 1024)
icon.aspectRatio       // 1.0
 
var hero = ImageAsset(widthPx: 1920, heightPx: 1080)
hero.aspectRatio       // 1.777...
 
hero.widthPx = 2560
hero.aspectRatio       // 2.370... -- recalculated automatically

A few rules to internalize:

From the caller's perspective hero.aspectRatio and hero.widthPx look identical -- both are dot-syntax reads. That's the point: the user doesn't need to know which slots are stored and which are derived.

Mini-exercise

Add a computed property megapixels: Double to ImageAsset that returns the total pixel count divided by one million. Test it on a 1920 × 1080 asset (you should get ~2.07).

Getters and Setters

The computed aspectRatio above is a read-only computed property -- you can read it, but assigning to it doesn't make sense. Sometimes, though, "writing" to a computed property does make sense: you write a value, and the property updates other stored properties to reflect that intent.

When you need both directions, split the body into an explicit get and set:

struct ImageAsset {
    var widthPx: Int
    var heightPx: Int
 
    var aspectRatio: Double {
        get {
            Double(widthPx) / Double(heightPx)
        }
        set {
            // newValue is the value being assigned in.
            // Keep height the same; resize width to match.
            widthPx = Int(Double(heightPx) * newValue)
        }
    }
}
 
var asset = ImageAsset(widthPx: 1080, heightPx: 1080)
asset.aspectRatio        // 1.0 (square)
 
asset.aspectRatio = 16.0 / 9.0
asset.widthPx            // 1920 -- updated automatically
asset.heightPx           // 1080 -- unchanged

Three things to know about setters:

This pattern is great for surfacing a friendlier API on top of multiple underlying properties: the caller assigns to one thing, and the struct quietly keeps the related fields in sync.

Type Properties

Every property you've defined so far has been an instance property -- each value of the struct has its own copy. But sometimes the property belongs to the type itself, not to any one instance.

Use static for that:

struct SubmissionQuota {
    static let dailyLimit = 50
    static var totalSubmissionsToday = 0
 
    let appID: String
}

dailyLimit and totalSubmissionsToday aren't on instances of SubmissionQuota -- they're on the type. Access them via the type name:

SubmissionQuota.dailyLimit            // 50
SubmissionQuota.totalSubmissionsToday // 0
 
SubmissionQuota.totalSubmissionsToday += 1

Type properties are perfect for:

Self vs SubmissionQuota

Inside an instance method, you can refer to a type property using either the type's name (SubmissionQuota.dailyLimit) or the implicit Self (Self.dailyLimit). Self (capital S, the type) is different from self (lowercase, the instance). Prefer Self -- if you rename the struct later, your code keeps working.

Property Observers: willSet and didSet

Sometimes you want to react when a stored property changes -- to update a related flag, log the change, or revert an out-of-range value. Swift gives you two hooks for that:

Inside willSet, the constant newValue holds the about-to-be-assigned value. Inside didSet, the constant oldValue holds the previous value -- and the new value is already in the property.

A common use case: clamp a slider value into a legal range.

struct VolumeSlider {
    var level: Int = 50 {
        didSet {
            if level < 0 { level = 0 }
            if level > 100 { level = 100 }
        }
    }
}
 
var volume = VolumeSlider()
volume.level = 150
volume.level   // 100 -- clamped
volume.level = -20
volume.level   // 0 -- clamped

Assigning to level inside didSet does not re-trigger didSet (Swift breaks the recursion for you), so this clamping is safe.

A didSet is also great for keeping derived state in sync. Here's a counter that flips an isPopular flag once it crosses a threshold:

struct DownloadCounter {
    static let popularThreshold = 10_000
 
    var count: Int = 0 {
        didSet {
            isPopular = count >= Self.popularThreshold
        }
    }
    private(set) var isPopular = false
}

(Don't worry about private(set) for now -- it just means outside code can read isPopular but only the struct itself can write to it.)

A few subtleties:

Mini-exercise

In the VolumeSlider above, an assignment of 150 is silently clamped to 100. Rewrite the struct so that before the assignment is committed, the code prints "Clamping volume from 150 to 100", then commits the clamped value. Hint: use willSet for the diagnostic, but you'll still need didSet to do the actual clamping. (willSet can't change newValue.)

Lazy Properties

Some properties are expensive to compute. If you don't always need them, computing them at init wastes work.

A lazy stored property defers its initialization until the first time someone reads it. The body runs at most once -- the result is then stored like any other property.

struct Project {
    let filePath: String
 
    lazy var wordCount: Int = {
        // Pretend this opens the file and counts words.
        // Could take hundreds of milliseconds.
        print("Scanning \(filePath)...")
        return computeWordCount(at: filePath)
    }()
}
 
var draft = Project(filePath: "chapter.md")
// "Scanning chapter.md..." has NOT printed yet.
 
print(draft.wordCount)
// → prints "Scanning chapter.md..."
// → then the number
 
print(draft.wordCount)
// → prints just the number; no rescan.

A few requirements:

Lazy properties are most useful when:

Challenges

Challenge 1: a credit-pack price book

Define a CreditPack struct with two stored properties: credits: Int and priceUSD: Double. Add a read-only computed property costPerCredit: Double that returns priceUSD / Double(credits).

Test it on:

Which pack is the better deal?

Challenge 2: temperature with get/set

Define a Thermometer struct with one stored property: celsius: Double. Add a computed property fahrenheit: Double with both a getter and a setter, so callers can read or write either unit and the struct keeps them in sync. Conversion formulas:

Verify: starting with celsius = 100, fahrenheit should be 212.0. Then assign fahrenheit = 32, and celsius should become 0.0.

Challenge 3: TestFlight slot enforcement

Apple caps external TestFlight builds at 10,000 testers. Model that with type properties and observers:

Key Points

These tools are how a plain data bag becomes an actual API. In Chapter 15: Methods, you'll round out the picture -- the implicit self, custom initializers (and the gotcha where they swallow the memberwise init), mutating methods for value types, type-level methods that act as namespaces, and the extension keyword that lets you add behavior to types you don't own.

Ch 13: StructuresCh 15: Methods
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