SwiftQARandom

How does @Published work under the hood?

@Published looks like a small convenience, but it sits on top of a fairly specific and slightly hacky corner of the language that was added for Combine. Here’s the full chain, from the property wrapper down to the compiler feature that makes it actually notify SwiftUI.

1. What the wrapper desugars to

@Published var count: Int = 0 inside a class expands (roughly) to:

private var _count = Published(wrappedValue: 0)
var count: Int {
    get { _count.wrappedValue }
    set { _count.wrappedValue = newValue }
}
var $count: Published<Int>.Publisher {
    mutating get { _count.projectedValue }
}

That much is ordinary property-wrapper mechanics. Published<Value> itself is a struct, not a class, and its wrappedValue getter/setter just reads and writes the current value. projectedValue returns a Published<Value>.Publisher, which conforms to Publisher with Output == Value and Failure == Never.

Notice the projected-value getter is marked mutating. That’s a hint that something lazy is going on: internally, Published’s storage starts as a bare value (no Combine machinery allocated at all), and only gets promoted to a real publisher — backed by something like a CurrentValueSubject — the first time someone actually reads $count. Since most @Published properties in a typical SwiftUI app are never subscribed to directly (consumers just rely on objectWillChange), this avoids allocating a subject with its lock and subscriber list for every property on every model object. The mutation is self flipping from a .value(x) case to a .publisher(box) case internally.

2. The part that actually notifies ObservableObject — _enclosingInstance

This is the interesting bit. Normal property wrappers only ever see their own storage; they have no way to reach back into the object that contains them. Swift added a private, underscored feature specifically to support Combine: a special static subscript a wrapper type can implement,

static subscript<EnclosingSelf>(
    _enclosingInstance instance: EnclosingSelf,
    wrapped wrappedKeyPath: ReferenceWritableKeyPath<EnclosingSelf, Value>,
    storage storageKeyPath: ReferenceWritableKeyPath<EnclosingSelf, Self>
) -> Value

When the compiler synthesizes accessors for a wrapped property on a class, it checks whether the wrapper type has one of these _enclosingInstance subscripts whose generic constraints are satisfiable by the enclosing type. If so, it uses that subscript instead of the plain local get/set pattern shown above. Published provides exactly one, constrained like this:

extension Published {
    public static subscript<EnclosingSelf: ObservableObject>(
        _enclosingInstance instance: EnclosingSelf,
        wrapped wrappedKeyPath: ReferenceWritableKeyPath<EnclosingSelf, Value>,
        storage storageKeyPath: ReferenceWritableKeyPath<EnclosingSelf, Self>
    ) -> Value {
        get { instance[keyPath: storageKeyPath].wrappedValue }
        set {
            (instance.objectWillChange as? ObservableObjectPublisher)?.send()
            instance[keyPath: storageKeyPath].wrappedValue = newValue
        }
    }
}

This explains several things people find surprising:

It only auto-notifies inside an ObservableObject. Stick @Published on a plain class and it’s just a Combine publisher property with no free notification — the generic constraint on that subscript can’t be satisfied, so the compiler falls back to the ordinary accessor pattern.

It fires on willSet, not didSet. The send() call happens before the underlying storage is actually mutated. That’s why the type is ObservableObjectPublisher and the convention is “objectWillChange,” not “objectDidChange”: it mirrors willSet semantics. SwiftUI receives the signal, marks the view as needing a re-render, but doesn’t actually re-evaluate body until later in the run loop, by which point the synchronous setter has already finished assigning the new value — so it never actually reads stale data despite the notification technically preceding the mutation.

There’s no equality check anywhere in this path. Every single assignment — even setting a value to what it already was — sends through objectWillChange, unconditionally. @Published does not deduplicate, so assigning the same value in a tight loop will still trigger a render pass each time. This is a common source of “why is my view re-rendering constantly” bugs.

3. Where objectWillChange itself comes from

If you don’t declare objectWillChange yourself, the compiler synthesizes it for any type conforming to ObservableObject whose ObjectWillChangePublisher is left as the default ObservableObjectPublisher. ObservableObjectPublisher is essentially a PassthroughSubject<Void, Never> — it carries no payload, it’s purely a “something changed” ping, which is also why SwiftUI has to re-read whatever properties it actually depends on rather than being told what changed.

Since you never declared a stored property for it, the compiler has to conjure storage for it too — this is genuine compiler-level synthesis (in the same family as the compiler synthesizing Codable’s init(from:)), not a protocol-extension default with no state, because a protocol extension can’t add a stored property. It lazily creates one shared publisher instance per object the first time it’s accessed and stashes it.

4. Threading and lifetime

None of this is thread-safe by contract — Published’s underlying subject uses locking internally to protect concurrent send/subscribe, but there’s no dispatch to main queue anywhere in this chain. If you mutate a @Published property off the main thread, objectWillChange.send() fires synchronously on whatever thread did the mutation, and SwiftUI (or any other subscriber) receiving it off-main is on you to redirect — historically a very common bug, “Publishing changes from background threads is not allowed.”

One consequence worth internalizing: @Published’s own $property publisher (a CurrentValueSubject-flavored publisher) and the class’s objectWillChange are two separate notification paths that happen to be wired together only through that _enclosingInstance subscript. Subscribing to $count gives you the actual new values, replayed to late subscribers (current-value-subject behavior); subscribing to objectWillChange gives you a bare “something on this object is about to change” signal with no payload at all, fired for any @Published property on the object, not just this one.

If you want to see the reference-quality open-source reimplementation of this exact mechanism (Apple’s own Combine is closed-source), OpenCombine on GitHub mirrors this _enclosingInstance/ObservableObjectPublisher design closely and is a good way to step through working code rather than decompiled internals.