Lesson 1: Property wrappers
You will build@Clamped, a small version of @AppStorage called @Stored, and a small version of Binding called Ref.
TimeAbout 80 minutes
WhySwiftUI is full of things like @State, @Binding and $text. They look like special syntax. They are not: they are an ordinary Swift feature called property wrappers. Today you write three of your own. After that, the SwiftUI ones will not be mysterious, because you will have built the same thing.
Today's project is a command-line program: no window and no simulator. You type code, run it, and read what it prints in Xcode's console. It is the quickest way to try out a language feature. Set it up from scratch:
Open Xcode and choose File ▸ New ▸ Project… (⇧⌘N). If you are looking at the Welcome to Xcode window instead, its New Project menu offers the same choices.
Xcode 27 shows a short list of project kinds. Click Choose Template.
A sheet titled Choose a template for your new project appears. In the row of platforms at the top, click macOS. Under Application, select Command Line Tool. Click Next.
Fill in the options. Product Name: Wrappers. Team: leave it as it is — a command-line program does not need one. Organization Identifier: the one you normally use, such as com.yourname. Language: Swift. Click Next.
Choose the folder to keep the project in — your Development folder, for example. Tick Create Git repository on my Mac or not, as you prefer. Click Create.
The project window opens. In the Project navigator on the left, open the Wrappers folder and click main.swift. Below a comment that gives the file's name and today's date, Xcode has written:
main.swift, as Xcode made itimport Foundation
print("Hello, World!")Check that the run destination in the toolbar says My Mac, then choose Product ▸ Run (⌘R). The debug area opens at the bottom of the window, and its console shows:
Hello, World!
Program ended with exit code: 0The last line comes from Xcode, not from the program: it says the program has finished, and 0 means it finished without an error. If you cannot see the console, choose View ▸ Debug Area ▸ Activate Console (⇧⌘C).
Now add a second file. With main.swift still selected, choose File ▸ New ▸ File from Template… (⌘N). Under Source, select Swift File. Click Next.
Name it Wrappers, leave the other settings as they are, and click Create. The file appears beside main.swift and opens with a comment and one line of code: import Foundation. Leave that line where it is.
From here on, every step works the same way. The types you build go in Wrappers.swift. The code that tries them out goes in main.swift, below import Foundation. A file with that exact name is special: the code in it runs from top to bottom when the program starts.
The list in step 2 also offers Command Line Tool itself, described as "Terminal program for macOS". One click on it opens an untitled project straight away, with the same two lines in main.swift and no questions asked. That is the one to use when you only want to try a few lines — in an interview, for example. These tutorials use Choose Template so that every project has a name and a folder from the start.
Imagine a volume control whose level must stay between 0 and 10. Without any new features, you would hide the stored value and check it in a setter:
Wrappers.swiftstruct PlainVolume {
private var storedLevel = 5
var level: Int {
get { storedLevel }
set { storedLevel = min(max(newValue, 0), 10) } // keep it within 0...10
}
}
main.swift, replace everything below import Foundation withvar volume = PlainVolume()
volume.level = 42
print(volume.level)
Run it (⌘R). The console shows
10
Program ended with exit code: 0It works. But every property that needs clamping needs the same six lines, with only the names changed. Add a bass and a treble and you have written it three times. A property wrapper lets you write it once.
A property wrapper is a type marked @propertyWrapper that has a property named wrappedValue. Put the clamping logic there:
Wrappers.swift@propertyWrapper
struct ClampedToTen {
private var value = 0
var wrappedValue: Int {
get { value }
set { value = min(max(newValue, 0), 10) }
}
}
Now use it. Writing @ClampedToTen in front of a property attaches the wrapper to it:
Wrappers.swiftstruct SimpleVolume {
@ClampedToTen var level: Int
@ClampedToTen var bass: Int
}
main.swift, replace everything below import Foundation withvar volume = SimpleVolume()
volume.level = 42
volume.bass = -7
print(volume.level, volume.bass)
Run it (⌘R). The console shows
10 0
Program ended with exit code: 0Two clamped properties, and the clamping logic exists in one place.
There is no magic here. When you write @ClampedToTen var level: Int, the compiler stores a ClampedToTen value in a hidden property named _level, and turns level into a computed property that reads and writes _level.wrappedValue. You could have written that by hand:
struct HandWrittenVolume {
private var _level = ClampedToTen() // the hidden storage: the wrapper itself
var level: Int {
get { _level.wrappedValue }
set { _level.wrappedValue = newValue }
}
}
That hand-written version behaves identically. A property wrapper is a shorter way to write it.
ClampedToTen only knows one range. A useful wrapper takes the range as an argument, and works for any type that can be compared. Add a generic version (leave ClampedToTen where it is, so the earlier code still compiles):
Wrappers.swift@propertyWrapper
struct Clamped<Value: Comparable> {
private var value: Value
private let range: ClosedRange<Value>
init(wrappedValue: Value, _ range: ClosedRange<Value>) {
self.range = range
self.value = min(max(wrappedValue, range.lowerBound), range.upperBound)
}
var wrappedValue: Value {
get { value }
set { value = min(max(newValue, range.lowerBound), range.upperBound) }
}
}
The initializer is the important part. When you attach a wrapper like this —
Wrappers.swiftstruct Mixer {
@Clamped(0...10) var level = 5
@Clamped(0.0...1.0) var balance = 0.5
}
— the compiler calls init(wrappedValue:_:). The value after = arrives as wrappedValue. The arguments in parentheses arrive after it. That is why the first parameter has to be named exactly wrappedValue.
main.swift, replace everything below import Foundation withvar mixer = Mixer()
mixer.level = 42
mixer.balance = -3
print(mixer.level, mixer.balance)
Run it (⌘R). The console shows
10 0.0
Program ended with exit code: 0One wrapper now clamps an Int to 0…10 and a Double to 0…1.
$ prefixA wrapper can expose a second value besides the wrapped one. It is called the projected value, and you reach it by putting $ in front of the property's name. You decide what it is. Here, make it report whether the last assignment had to be clamped. Replace Clamped with this version — the changed lines are the projectedValue property and the setter:
Wrappers.swift, replace Clamped with this@propertyWrapper
struct Clamped<Value: Comparable> {
private var value: Value
private let range: ClosedRange<Value>
private(set) var projectedValue = false // was the last write out of range?
init(wrappedValue: Value, _ range: ClosedRange<Value>) {
self.range = range
self.value = min(max(wrappedValue, range.lowerBound), range.upperBound)
}
var wrappedValue: Value {
get { value }
set {
value = min(max(newValue, range.lowerBound), range.upperBound)
projectedValue = (value != newValue)
}
}
}
main.swift, replace everything below import Foundation withvar mixer = Mixer()
mixer.level = 7
print(mixer.level, mixer.$level)
mixer.level = 42
print(mixer.level, mixer.$level)
Run it (⌘R). The console shows
7 false
10 true
Program ended with exit code: 0mixer.level is the wrapped value. mixer.$level is the projected value. That is all $ means, anywhere in Swift.
When you see TextField("Name", text: $name) in SwiftUI, the $ is doing exactly this. name is declared with @State, and the projected value of @State happens to be a Binding. You will build your own Binding in step 7.
@AppStorageWrappers are not limited to checking values. They can decide where a value is stored. This one keeps its value in UserDefaults, so it survives between runs of the program:
Wrappers.swift@propertyWrapper
struct Stored<Value> {
private let key: String
private let defaultValue: Value
init(wrappedValue: Value, _ key: String) {
self.key = key
self.defaultValue = wrappedValue
}
var wrappedValue: Value {
get { UserDefaults.standard.object(forKey: key) as? Value ?? defaultValue }
nonmutating set { UserDefaults.standard.set(newValue, forKey: key) }
}
}
Wrappers.swiftstruct Preferences {
@Stored("username") var username = "guest"
}
main.swift, replace everything below import Foundation withUserDefaults.standard.removeObject(forKey: "username") // start fresh each run
let preferences = Preferences() // note: `let`, not `var`
print(preferences.username)
preferences.username = "andrew" // allowed on a `let` — the setter is nonmutating
print(Preferences().username) // a brand-new instance sees the saved value
Run it (⌘R). The console shows
guest
andrew
Program ended with exit code: 0Look closely at two things in that output. A brand-new Preferences() saw the value that the first one saved — because the value is not stored in the struct at all. And preferences was declared with let, yet you assigned to preferences.username.
Normally, setting a property changes the struct, so the struct must be a var. The word nonmutating tells the compiler that this setter does not change the struct — it changes something outside it. That is how a SwiftUI view, which is an immutable struct, can still change its @State: the state is stored outside the view, and the setter is nonmutating.
BindingSuppose another piece of code needs to change the username. If you pass it preferences.username, it receives a copy of the string — changing the copy does nothing. What you want to pass is access: a way to read the value and a way to write it. Two closures do that:
Wrappers.swiftstruct Ref<Value> {
let get: () -> Value
let set: (Value) -> Void
var value: Value {
get { get() }
nonmutating set { set(newValue) }
}
}
Now make Stored hand one out as its projected value. Replace Stored with this version — the addition is the projectedValue property at the end:
Wrappers.swift, replace Stored with this@propertyWrapper
struct Stored<Value> {
private let key: String
private let defaultValue: Value
init(wrappedValue: Value, _ key: String) {
self.key = key
self.defaultValue = wrappedValue
}
var wrappedValue: Value {
get { UserDefaults.standard.object(forKey: key) as? Value ?? defaultValue }
nonmutating set { UserDefaults.standard.set(newValue, forKey: key) }
}
var projectedValue: Ref<Value> {
Ref(get: { wrappedValue }, set: { wrappedValue = $0 })
}
}
And write a function that changes a string it does not own:
Wrappers.swiftfunc shout(_ name: Ref<String>) {
name.value = name.value.uppercased()
}
main.swift, replace everything below import Foundation withUserDefaults.standard.removeObject(forKey: "username")
let preferences = Preferences()
preferences.username = "andrew"
shout(preferences.$username) // hand over read-write access, not a copy
print(preferences.username)
Run it (⌘R). The console shows
ANDREW
Program ended with exit code: 0shout changed the stored username without knowing anything about Preferences or UserDefaults. It was handed $username — the ability to read and write — and that was enough.
You have just built Binding. SwiftUI's version holds a getter and a setter, exactly like your Ref, and a parent view passes $something to a child view for exactly this reason.
You can read Apple's Binding and see that it has the same parts. Make one more file — File ▸ New ▸ File from Template… (⌘N), Swift File, named Peek — and replace everything in it with this small SwiftUI view:
Peek.swift — the whole fileimport SwiftUI
struct Counter: View {
@State private var count = 0
@Binding var name: String
var body: some View {
Text("\(name): \(count)")
}
}
Choose Product ▸ Build (⌘B) to check that it compiles. Now click on the word Binding and choose Navigate ▸ Jump to Definition (⌃⌘J). Xcode opens SwiftUI's interface at this declaration (shortened here):
@frozen @propertyWrapper @dynamicMemberLookup public struct Binding<Value> {
public var wrappedValue: Value {
get
nonmutating set
}
public var projectedValue: Binding<Value> {
get
}
}
Every part of it is something you wrote today: @propertyWrapper, a wrappedValue with a nonmutating set, and a projectedValue. (A binding's projected value is itself, which is why you can write $name on a @Binding property and pass it further down.)
One more thing to look at. In Xcode 27, @State is a macro: a piece of code that writes code. Xcode can show you what it wrote. Go back to Peek.swift, click on @State, and choose Editor ▸ Expand Macro. Among the generated code you will find these lines:
// the hidden storage:
private var __count = SwiftUICore.State._makeStorage({ 0 })
// `count` itself is given these:
get { __count.wrappedValue }
nonmutating set { __count.wrappedValue = newValue }
// and `$count` is given this:
get { __count.projectedValue }
It is the hand-written version from step 3 again. There is hidden storage, here named __count. count has become a computed property that reads and writes the storage's wrappedValue, and its setter is nonmutating — the same trick as your Stored in step 6. And $count returns the storage's projectedValue.
Clamped — a generic wrapper with an argument, a starting value, and a projected value.Stored — a wrapper that keeps its value in UserDefaults, with a nonmutating setter. A small @AppStorage.Ref — a getter and a setter passed around together. A small Binding.Tomorrow you start on TinyUI, a miniature SwiftUI that draws with text: things that can draw themselves, and the difference between some and any.
Stuck, or want to compare with your own? This is the project as it stands at the end of today, built and run before it was put here.
Wrappers.xcodeproj in Xcode.13 cards for today, each labelled with its topic. 7 ask how you would write something; the other 6 are trick questions, written to catch a misunderstanding. Answer in your head first, then tap the card.
@propertyWrapper
struct Uppercased {
private var value = ""
var wrappedValue: String {
get { value }
set { value = newValue.uppercased() }
}
}Mark the type @propertyWrapper and give it a wrappedValue property. That is the minimum.
struct Mixer {
@Clamped(0...10) var level = 5
}The argument goes in parentheses after the wrapper's name. The starting value goes after =, as usual.
@AtLeast(1) var quantity = 3 work.@propertyWrapper
struct AtLeast {
private var value: Int
private let minimum: Int
init(wrappedValue: Int, _ minimum: Int) {
self.minimum = minimum
self.value = max(wrappedValue, minimum)
}
var wrappedValue: Int {
get { value }
set { value = max(newValue, minimum) }
}
}
struct Cart {
@AtLeast(1) var quantity = 3
}The first parameter must be named wrappedValue: it receives the = 3. The arguments in parentheses come after it.
var mixer = Mixer()
mixer.level = 42
let wasClamped = mixer.$level
print(wasClamped)Put $ in front of the property name. You get the wrapper's projectedValue — here a Bool, and it prints true.
@propertyWrapper
struct Remembered {
var wrappedValue: Int {
get { UserDefaults.standard.integer(forKey: "remembered") }
nonmutating set { UserDefaults.standard.set(newValue, forKey: "remembered") }
}
}Write nonmutating set. Use it when the value is stored somewhere outside the struct.
struct Mixer {
@Clamped(0...10) var level = 5
func describe() -> String {
"level \(level), clamped: \(_level.projectedValue)"
}
}The wrapper is stored in a property with a leading underscore: _level. It is private to the type that declares it.
func reset(_ name: Ref<String>) {
name.value = "guest"
}
func signOut(_ preferences: Preferences) {
reset(preferences.$username)
}Pass the projected value, $username. The function receives a way to read and write the original, not a copy of it. In SwiftUI the same idea is called Binding.
@Clamped(0...10) var level = 50 — right after the struct is created, is level 50 or 10?10. The starting value does not skip the wrapper. It is passed to init(wrappedValue:), and the initializer you wrote clamps it.
$level always give you a Binding?No. $ gives you whatever the wrapper's projectedValue is. For your Clamped it is a Bool; for SwiftUI's @State it is a Binding. A wrapper with no projectedValue has no $ form at all. Using one on ClampedToTen does not compile: value of type 'SimpleVolume' has no member '$level'
let preferences = Preferences() is a constant. Does preferences.username = "andrew" compile?Yes — and it prints andrew. The setter is nonmutating: the value lives in UserDefaults, not in the struct, so nothing inside the constant changes. It is the same reason a SwiftUI view, which is an immutable struct, can change its @State.
Mixer values each use @Clamped. Two Preferences values each use @Stored("username"). Which pair shares its data?Only the Preferences pair. Each Mixer holds its own wrapper with its own value, so the two are independent (they print 9 5). Both Preferences read and write the same UserDefaults key, so they see the same value (andrew andrew). A wrapper shares data only when it stores that data somewhere outside itself.
let?No. property wrapper can only be applied to a 'var' A wrapped property is really a computed property backed by hidden storage, and a computed property cannot be a let.
mixer.level = 42, then mixer.level = 7. What is mixer.$level now?false. Your projected value describes only the most recent write, and 7 was in range. A projected value is ordinary state that you design: it means whatever you make it mean.