SwiftUI and Swift concurrency are full of syntax that stands for something longer: a property wrapper for a hidden property and an accessor, await for a function cut in two, @concurrent for a hop to the thread pool and back. The expansion is the definition. This sheet collects every one the course teaches, each shown twice — as you write it, and as the compiler makes it — so that there is one place to come back to. Where the expansion is Xcode's own, it was read from the compiler's macro expansion; where it is written out by hand, a flash card runs the hand-written version against the sugar and checks they do the same thing.
Written out: the wrapper is stored in a hidden property named with an underscore, and the property you declared becomes a computed property over it.
What the compiler makes of it
structHandWrittenPet {
privatevar_food = ClampedToTen() // the hidden storage: the wrapper itselfvarfood: Int {
get {
_food.wrappedValue
}
set {
_food.wrappedValue = newValue
}
}
}
@ClampedToTen var foodis_food plus food. $food, where a wrapper offers one, is _food.projectedValue. Lesson 2 builds the wrapper, and this expansion, by hand.
Since Xcode 27 @State is a macro, so Xcode can show you what it writes (Editor ▸ Expand Macro). Shortened:
Glance.swift — from Xcode’s expansion of the macro (shortened), not code to type
structCounter: View {
@Stateprivatevarcount = 0
{
get {
__count.wrappedValue
}
nonmutatingset {
__count.wrappedValue = newValue
}
}
privatevar__count = SwiftUICore.State._makeStorage({ 0 })
@SwiftUICore._StateProjectedValueprivatevar $count = SwiftUICore.State(initialValue: 0).projectedValue
{
get {
__count.projectedValue
}
}
// …and `_count`, and a private static that holds the starting value.@Bindingvarname: Stringvarbody: someView {
Text("\(name): \(count)")
}
}
The same shape as the hand-written wrapper above: hidden storage, a computed property over it with a nonmutating setter (that is how a view, an immutable struct, can change its state — the state lives outside it), and $count as the storage's projected value, which is a Binding. What the macro adds, and ClampedToTen could not, is that the storage tells SwiftUI when the value changes.
What the compiler makes of it: what $name and name = … do in Child
// $name is a Ref: a getter and a setter, passed together. Child writes through it.let name = Ref(get: { parent.name }, set: { parent.name = $0 })
name.value = name.value.uppercased()
Apple's Binding has exactly these parts: a wrappedValue with a nonmutating set, and a projectedValue that is the binding itself, so $name on a @Binding property passes it further down.
One thing the hand-written version lacks, and Apple's has: when the value changes, @AppStorage tells the view to draw again. Stored saves the name and the screen does not change until the next launch; @AppStorage does both.
importObservation/// What the feeder has seen today. On the main actor: the screen reads it.@MainActor@ObservablefinalclassSightings {
varrobins = 0varsparrows = 0
}
A macro, so Xcode shows its expansion. For every stored property, shortened:
Sightings.swift — from Xcode’s expansion of the macro (shortened), not code to type
A registrar is added to the class; each property moves into an underscored stored property; and the property you wrote becomes accessors that tell the registrar on every read (access) and every write (withMutation, or _modify for a change made in place). That is how a view knows which properties its body read, and is redrawn when one of those — and only those — changes. Lesson 16 writes the same accessors by hand first, and then reads these.
As you write it: a block of statements, each a widget
let card = stack {
Line("Seattle")
Line("5 nights")
}
A result builder block is rewritten into calls on the builder, one per statement. Lesson 4's WidgetBuilder — a miniature @ViewBuilder — has two:
The builder
@resultBuilderenumWidgetBuilder {
staticfuncbuildPartialBlock<W: Widget>(first: W) -> W {
first
}
staticfuncbuildPartialBlock<A: Widget, B: Widget>(accumulated: A, next: B) -> Column<A, B> {
Column(accumulated, next)
}
}
What the compiler makes of it
// the block, as the compiler rewrites it: one call on the builder per statementlet card = WidgetBuilder.buildPartialBlock(
accumulated: WidgetBuilder.buildPartialBlock(first: Line("Seattle")),
next: Line("5 nights")
)
A SwiftUI body is the same trick: VStack { Text("a"); Text("b") } is buildBlock(Text("a"), Text("b")), an if is buildEither, an if without else is buildOptional. The result is a nested type that spells out the whole tree — which is why body returns some View and not a named type.
/// Ada flies to a flower and comes back with what she found. The flight is an `await`./// `@MainActor`: she leaves from the queen's actor — on purpose, to see what the wait does to it.@MainActorfuncfly() asyncthrows -> Int {
print("Ada leaves from \(whereAmI())")
let (data, _) = tryawaitURLSession.shared.data(from: meadow)
print("Ada is back on \(whereAmI())")
return data.count
}
Written out: the compiler cuts the function in two at the await. The first half runs up to the wait and returns, so its thread is free; when the result arrives the second half runs, with the locals it needs handed along, back on the actor the first half was on.
What the compiler makes of it
importFoundation// fly(), as the compiler builds it: two ordinary functions, and the request's completion handler between them.// You never write this. It is what `await` means.@MainActorfuncflyFirstHalf(then: @escaping@Sendable (Int) -> Void) {
// everything before the awaitprint("Ada leaves from \(whereAmI())")
// the wait. The closure runs when the data has arrived, on some other thread —// so it hops back to the actor the first half was on, and runs the second half thereURLSession.shared.dataTask(with: meadow) { data, _, _ inTask { @MainActorinflySecondHalf(data ?? Data(), then: then)
}
}.resume()
// and the first half returns here: the thread is free
}
@MainActorfuncflySecondHalf(_ data: Data, then: (Int) -> Void) {
// everything after the awaitprint("Ada is back on \(whereAmI())")
then(data.count)
}
A completion handler, the rest of the function inside it, and a hop back — what a 2016 iOS developer wrote by hand for every request. await is the compiler writing it. From it: a thread is never kept waiting at an await; the locals live in a frame off the thread; and the code after an await is on the actor it left.
/// Counting every grain of pollen in the meadow: a long computation, with nothing to wait for —/// now on the shared thread pool, so that the queen is free while it runs.@concurrentfunccountPollen() async -> Int {
var grains = 0for i in0..<60_000_000wheresin(Double(i)) > 0.999 {
grains += 1
}
print("counted on \(whereAmI())")
return grains
}
Written out: hand the body to the shared thread pool and wait for it. The caller's actor is free while you wait.
What the compiler makes of it
importFoundation// @concurrent, written out as the Swift you could write before it existed: hand the body to the shared pool,// and wait for it, where you are. The queen is free while you wait.nonisolated(nonsending) funccountPollenByHand() async -> Int {
// a new task, on the shared pool, on no actor — and .value: suspend here until it is doneawaitTask.detached {
var grains = 0for i in0..<60_000_000wheresin(Double(i)) > 0.999 {
grains += 1
}
print("counted on \(whereAmI())")
return grains
}.value
}
With one difference, which is the reason the word exists. Task.detached makes a new task — its own priority, its own cancellation, nothing of the caller's. @concurrent moves the same task to the pool and back: cancel the caller, and a @concurrent function sees it; a detached task does not. And @concurrent moves only the body you wrote: anything it calls that is main-actor code pulls it straight back to the main actor.
async let is a declaration, and it can only be written inside an async function or closure. So the smallest complete thing that compiles is a function with the line in it, and that is the whole program here. The compiler charges every instruction it emits to one line of your source; below, each line is followed by exactly the instructions charged to it, as the compiler printed them, with only its bookkeeping removed.
As you write it
funcf() async -> Int { // stands for anything you could call1
}
funct() async {
asynclet x = f()
}
Part
What can go there
x
What can go thereA new constant — async var is an error. It may carry a type, async let x: Int = f(); be a tuple, async let (n, s) = pair(); be _; or be several in one line, async let a = f(), b = g().
f()
What can go thereAny expression with a value: a call to an async function, to a throwing one (try is optional here), to a plain function, a closure call, arithmetic, even 5. An await inside is allowed and not needed. It cannot be left out.
func t() async {
What can go thereThe line can only sit inside an async function or closure — a Task { } body counts. In a plain function it is an error, and so is an async let stored property.
await x
What can go thereEvery read is await x — try await x if f() can throw. The first read waits only if the slot is still empty; later reads take the value again. A read without await is a warning in the Swift 5 mode and an error in Swift 6.
The compiler's words for the ones that are not allowed:
async var x = f(): 'async' can only be used with 'let' declarations
async let x: Int with nothing after it: 'async let' binding requires an initializer expression
in a function that is not async: 'async let' in a function that does not support concurrency
as a stored property: 'async let' can only be used on local declarations
return x without await: expression is 'async' but is not marked with 'await'; this is an error in the Swift 6 language mode
await x without try, when f() throws: reading 'async let' can throw but is not marked with 'try'; this is an error in the Swift 6 language mode
A function in a new project is main-actor code, async or not, so the block begins by fetching the main actor and hopping onto it. Every wait inside the function will hop back here. That is all the block adds.
async let x = f() — what the line compiles to
%4 = alloc_stack $Int
%5 = address_to_pointer %4 to $Builtin.RawPointer
%6 = enum $Optional<Builtin.RawPointer>, #Optional.none!enumelt
// function_ref implicit closure #1 in t()
%7 = function_ref @$s7program1tyyYaFSiyYaYbcfu_ : $@convention(thin) @Sendable @async @substituted <τ_0_0> () -> (@out τ_0_0, @error any Error) for <Int>
%8 = convert_function %7 to $@convention(thin) @async @substituted <τ_0_0> () -> (@out τ_0_0, @error any Error) for <Int>
%9 = thin_to_thick_function %8 to $@noescape @async @callee_guaranteed @substituted <τ_0_0> () -> (@out τ_0_0, @error any Error) for <Int>
%10 = builtin "startAsyncLetWithLocalBuffer"<Int>(%6, %9, %5) : $Builtin.RawPointer
Three things. alloc_stack $Int: a slot for the result, in t()'s own frame. implicit closure #1 in t(): the right-hand side, f(), has been turned into a function of its own, shown next. startAsyncLetWithLocalBuffer: start a child task of the current task running that function (%9) and writing into that slot (%5), with no options (%6), and carry on — one level further down it is a single call into the Swift runtime, swift_asyncLet_begin. Nothing here waits, and x is not an Int: it is the slot and the child. That is why the line has no await.
f() — the right-hand side, as the function it became
This is what the child runs: on no actor — the Optional<any Actor>.none it hops to is the shared pool — call f(); f is main-actor code, so that call goes to the main actor and comes back; hop back to no actor; store the answer in the slot. So async let does not by itself move work off the main actor. The expression runs wherever the things it calls live.
The brace carries the rest. finishAsyncLet: if x was never read, cancel the child; wait for it to end; discard the result. A hop back onto the main actor, because that wait was a suspension. endAsyncLetLifetime, and the slot is freed. Nothing you can write puts instructions on a closing brace, which is why async let is a keyword and not a function.
So the one line is: a slot, the right-hand side as a function, start it now. The read, await x, is another line. The cancel-and-wait is the brace's.
extensionJar {
/// A slow pour: look at the jar, go and get the honey, come back and put one drop on top of what you saw.funcpourSlowly() async {
let seen = drops
try? awaitTask.sleep(for: .milliseconds(10))
drops = seen + 1
}
}
Written out: the method is cut at the await into two jobs for the actor, and between them the actor belongs to nobody. Another task's first job can run there and see the same drops you saw.
What the compiler makes of it
// pourSlowly(), as the compiler builds it: the method is cut at the await into two jobs for the jar.// Between them the jar is free — so another bee's first job can run, and see the same `drops` you saw.extensionJar {
funcpourSlowlyByHand() async {
let seen = startPour() // job 1, in the jartry? awaitTask.sleep(for: .milliseconds(10)) // not in the jar: anyone may pour nowfinishPour(seen) // job 2, in the jar again
}
funcstartPour() -> Int {
drops
}
funcfinishPour(_ seen: Int) {
drops = seen + 1
}
}
This is reentrancy, and the one place the GCD picture breaks: a block holds its serial queue to the end, an actor method holds the actor only until its next await. Lesson 37 measures it — 100 slow pours leave 1 drop. Keep a read-modify-write on one side of an await.
importFoundation/// The actor this call is on: "MainActor", the name of an actor of yours, or "no actor". From lesson 1.funcwhereAmI(_ isolation: isolated (anyActor)? = #isolation) -> String {
isolation.map { "\(type(of: $0))" } ?? "no actor"
}
/// Whether this code is on the main thread. Only a synchronous function may ask — in `async` code the answer can/// change at every `await`, and Swift 6 refuses the question there. On no actor: it answers for whoever calls it.funconMainThread() -> Bool {
Thread.isMainThread
}
Written out: #isolation is the caller's own actor, put into the argument at the call site; isolated on the parameter means the function runs on whatever actor it is handed.
What the compiler makes of it, at three call sites
// #isolation, written out: the compiler puts the caller's own actor into the argument./// whereAmI(), called from a view or any main-actor code.@MainActorfuncwhereIsHereByHand() -> String {
whereAmI(MainActor.shared)
}
/// whereAmI(), called from a @concurrent function, as whereIsThePool() does.@concurrentfuncwhereIsThePoolByHand() async -> String {
whereAmI(nil)
}
extensionJar {
/// whereAmI(), called from inside one of the jar's methods, as whereIsTheJar() does.funcwhereIsTheJarByHand() -> String {
whereAmI(self)
}
}
So whereAmI() with no argument is always answered for whoever called it — and passing a different actor really runs the function there.
The modifier does the keeping and the cancelling for you, and runs the task on the main actor like the view. Task { } in onAppearwithout the onDisappear is the common mistake: an unstructured task that outlives its view.
Written out: a new task on the shared pool that first hops to the actor of the code that created it, and keeps that code's priority. That inherited hop is why a Task { } in a view may touch @State.
What the compiler makes of it
// Task { } is a new task on the shared pool that first hops to the actor of the code that made it —// here the main actor — and keeps that code's priority.Task.detached(priority: Task.currentPriority) {
awaitMainActor.run {
print("Task { } by hand: \(whereAmI())")
}
}
Task.detached { } is the version without the hop and without the inheritance: no actor, default priority, no task-local values — and, like Task { }, no parent to be cancelled by.
The compiler inserts the hop wherever code on one actor calls code on another; MainActor.run is the same hop spelled out. In GCD this was DispatchQueue.main.async { } — written by you, every time, and with no hop back.
Flash cards
14 cards, one for each expansion above. Each is also in the deck of the lesson it comes from. Answer in your head first, then tap the card.
Syntax · Property wrappersWhat does @ClampedToTen var food: Int expand to?
structHandWrittenPet {
privatevar_food = ClampedToTen() // the hidden storage: the wrapper itselfvarfood: Int {
get {
_food.wrappedValue
}
set {
_food.wrappedValue = newValue
}
}
}
A hidden property _food holding the wrapper, and food as a computed property over its wrappedValue; $food would be _food.projectedValue. Set to 42, the sugar and the hand-written struct both printed 10 10.
Syntax · StateWhat does @State private var count = 0 expand to?
From Xcode’s expansion of the macro (shortened) — not code to type
structCounter: View {
@Stateprivatevarcount = 0
{
get {
__count.wrappedValue
}
nonmutatingset {
__count.wrappedValue = newValue
}
}
privatevar__count = SwiftUICore.State._makeStorage({ 0 })
@SwiftUICore._StateProjectedValueprivatevar $count = SwiftUICore.State(initialValue: 0).projectedValue
{
get {
__count.projectedValue
}
}
// …and `_count`, and a private static that holds the starting value.@Bindingvarname: Stringvarbody: someView {
Text("\(name): \(count)")
}
}
Xcode's own expansion of the macro: hidden storage __count, made with State._makeStorage; count as a computed property over it with a nonmutating set; and $count as the storage's projectedValue — a Binding. The storage also tells SwiftUI when the value changes, which no hand-written wrapper can.
A getter and a setter, passed together: access to a value that lives somewhere else. Writing through the hand-written Ref changed the original — it printed PIP. $name on a @State is one of these, and a @Binding property's own $name is itself.
Syntax · AppStorageWhat does @AppStorage("petName") var petName = "Pip" expand to?
var wrappedValue: Value {
get {
UserDefaults.standard.object(forKey: key) as? Value ?? defaultValue
}
nonmutatingset {
UserDefaults.standard.set(newValue, forKey: key)
}
}
A wrapper whose storage is UserDefaults: the getter reads the key (or the default), the nonmutating setter writes it. A second Preferences read what the first had set: Biscuit. Apple's version adds the one thing this lacks — it tells the view to draw again.
Syntax · ObservationWhat does @Observable write into a class?
importObservation/// What the feeder has seen today. On the main actor: the screen reads it.@MainActor@ObservablefinalclassSightings {
varrobins = 0varsparrows = 0
}
From Xcode’s expansion of the macro (shortened) — not code to type
Xcode's own expansion: an ObservationRegistrar; each stored property moved into an underscored one; and the property you wrote turned into accessors that tell the registrar on every read (access) and every write (withMutation). A view's body is tracked through those reads, and redrawn when one of the properties it read changes.
Syntax · Result buildersWhat does a @ViewBuilder block — two statements — expand to?
Calls on the builder, one per statement, nested: the block's result is the type those calls return — here a Column<Line, Line>. The sugar and the hand-written calls gave the same lines the same type. In SwiftUI, buildBlock, buildEither and buildOptional do the same for a body.
Syntax · awaitWhat does the compiler do with an async function at its await?
funcflyFirstHalf(then: @escaping@Sendable (Int) -> Void) {
// everything before the awaitprint("Ada leaves from \(whereAmI())")
// the wait. The closure runs when the data has arrived, on some other thread —// so it hops back to the actor the first half was on, and runs the second half thereURLSession.shared.dataTask(with: meadow) { data, _, _ inTask { @MainActorinflySecondHalf(data ?? Data(), then: then)
}
}.resume()
// and the first half returns here: the thread is free
}
@MainActor
It cuts the function in two. The first half runs to the await and returns — the thread is free; the second half runs when the result is ready, with the locals handed along, back on the actor the first half was on. Run side by side, the sugar and the two hand-written halves printed: Ada leaves from MainActor
Ada is back on MainActor
Ada leaves from MainActor
Ada is back on MainActor
both brought nectar, both back on the main actor
Syntax · @concurrentWhat is @concurrent, written out — and what is the one thing the written-out version gets wrong?
funccountPollenByHand() async -> Int {
// a new task, on the shared pool, on no actor — and .value: suspend here until it is doneawaitTask.detached {
var grains = 0for i in0..<60_000_000wheresin(Double(i)) > 0.999 {
grains += 1
}
print("counted on \(whereAmI())")
return grains
}.value
}
await Task.detached { body }.value: the body on the pool, the caller's actor free meanwhile. Both ran on no actor and returned to the main actor: counted on no actor
counted on no actor
the same grains and back on MainActor. The difference: Task.detached is a new task, so the caller's cancellation never reaches it; @concurrent moves the same task, and sees it.
Syntax · Tasksasync let x = f() — what does that one line do, by itself?
/// A one-second errand that reports whether it was cancelled.funcerrand() async -> String {
do {
tryawaitTask.sleep(for: .seconds(1))
return"done"
} catch {
print("errand: cancelled")
return"cancelled"
}
}
/// The line, then a read of it.funcstartThenRead() async -> (lineTook: TimeInterval, readTook: TimeInterval, value: String) {
let before = Date()
asynclet x = errand()
let lineTook = Date().timeIntervalSince(before)
let value = await x
return (lineTook, Date().timeIntervalSince(before) - lineTook, value)
}
/// The line, and never a read: the closing brace cancels the child, then waits for her.funcstartAndLeave() async {
asynclet _ = errand()
}
It starts f()now, as a child task of the task you are in, and makes x the name of the slot its result will land in. The line does not wait: in the compiler's output it is a slot in your frame, the right-hand side turned into a function of its own, and one startAsyncLetWithLocalBuffer. Reading is another line, await x, which waits only while the slot is empty. The closing brace is where a child nobody read is cancelled and then waited for. The run shows all three: the line costing nothing, the read waiting a second, and startAndLeave returning only after its cancelled errand had ended.
Syntax · ActorsWhat does an actor method with an await inside it expand to?
funcpourSlowlyByHand() async {
let seen = startPour() // job 1, in the jartry? awaitTask.sleep(for: .milliseconds(10)) // not in the jar: anyone may pour nowfinishPour(seen) // job 2, in the jar again
}
Two jobs for the actor, with the actor free between them — so another task's first job runs there and sees the same value. 100 tasks each reading, waiting and writing: both lost the drops: 1 and 1 of 100. Keep a read-modify-write on one side of an await.
Syntax · IsolationWhat does #isolation expand to at a call site?
The caller's own actor, as the argument: from main-actor code whereAmI() is whereAmI(MainActor.shared), from a @concurrent function it is whereAmI(nil). Both comparisons printed true true. isolated on the parameter makes the function run on whatever actor it is handed.
Syntax · .taskWhat does the .task { } modifier expand to?
A task started in onAppear, kept in state, and cancelled in onDisappear — on the main actor, like the view. Hiding both views printed: onAppear and onDisappear: cancelled
the .task modifier: cancelled. The modifier does the keeping and the cancelling; Task { } in onAppear alone leaves a task running after its view is gone.
Syntax · TasksWhat does Task { } expand to, created from main-actor code?
A new task on the pool that first hops to its creator's actor and keeps its creator's priority — which is why a Task { } in a view may touch @State. Without the hop it is Task.detached: Task { }: MainActor
Task { } by hand: MainActor
Task.detached: no actor. Neither has a parent to be cancelled by; keep the Task value if you need to cancel it.
Syntax · Main actor@concurrent code calls a main-actor function, and the compiler makes you write await. What is that await, written out?
A hop to the main actor and back: await MainActor.run { report() }. Both versions ran the report on the main actor: report on MainActor
report on MainActor. In GCD this was DispatchQueue.main.async { }, written by hand every time and with no way back.